git-cracked 1.2.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 +21 -0
- package/README.md +156 -0
- package/config.example.json +11 -0
- package/package.json +50 -0
- package/scripts/install-linux.js +57 -0
- package/scripts/install-mac.js +63 -0
- package/scripts/install-windows.js +38 -0
- package/src/ai.js +3 -0
- package/src/cli.js +37 -0
- package/src/committer.js +37 -0
- package/src/config.js +36 -0
- package/src/dashboard.js +485 -0
- package/src/index.js +64 -0
- package/src/logger.js +29 -0
- package/src/messages.js +197 -0
- package/src/mutator.js +373 -0
- package/src/paths.js +24 -0
- package/src/setup.js +50 -0
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { writeFileSync, existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { getActivity } from './logger.js';
|
|
4
|
+
import { runCommit } from './committer.js';
|
|
5
|
+
import { CONFIG_PATH } from './paths.js';
|
|
6
|
+
|
|
7
|
+
const PORT = 4856;
|
|
8
|
+
|
|
9
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function esc(str) {
|
|
12
|
+
return String(str ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readConfig() {
|
|
16
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
17
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function timeAgo(iso) {
|
|
21
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
22
|
+
const m = Math.round(diff / 60000);
|
|
23
|
+
if (m < 1) return 'just now';
|
|
24
|
+
if (m < 60) return `${m}m ago`;
|
|
25
|
+
const h = Math.round(m / 60);
|
|
26
|
+
if (h < 24) return `${h}h ago`;
|
|
27
|
+
return `${Math.round(h / 24)}d ago`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nextRuns(schedule) {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const results = [];
|
|
33
|
+
for (const expr of (schedule ?? [])) {
|
|
34
|
+
const parts = expr.trim().split(/\s+/);
|
|
35
|
+
if (parts.length !== 5) continue;
|
|
36
|
+
const [minute, hour, , , dow] = parts;
|
|
37
|
+
const m = parseInt(minute, 10), h = parseInt(hour, 10);
|
|
38
|
+
const dowSet = new Set();
|
|
39
|
+
if (dow === '*') { for (let i = 0; i <= 6; i++) dowSet.add(i); }
|
|
40
|
+
else for (const p of dow.split(',')) {
|
|
41
|
+
if (p.includes('-')) { const [a, b] = p.split('-').map(Number); for (let i = a; i <= b; i++) dowSet.add(i % 7); }
|
|
42
|
+
else dowSet.add(Number(p) % 7);
|
|
43
|
+
}
|
|
44
|
+
for (let d = 0; d <= 7; d++) {
|
|
45
|
+
const c = new Date(now); c.setDate(c.getDate() + d); c.setHours(h, m, 0, 0);
|
|
46
|
+
if (dowSet.has(c.getDay()) && c > now) { results.push(c); break; }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results.sort((a, b) => a - b);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rel(date) {
|
|
53
|
+
const mins = Math.round((date - Date.now()) / 60000);
|
|
54
|
+
if (mins < 60) return `in ${mins}m`;
|
|
55
|
+
const h = Math.round(mins / 60);
|
|
56
|
+
if (h < 24) return `in ${h}h`;
|
|
57
|
+
return `in ${Math.round(h / 24)}d`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── shared shell ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const SHELL = (body, { title = 'git-cracked', active = '' } = {}) => `<!DOCTYPE html>
|
|
63
|
+
<html lang="en">
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="UTF-8">
|
|
66
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
67
|
+
<title>${esc(title)} · git-cracked</title>
|
|
68
|
+
<style>
|
|
69
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
70
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#e6edf3;min-height:100vh}
|
|
71
|
+
a{color:inherit;text-decoration:none}
|
|
72
|
+
|
|
73
|
+
/* nav */
|
|
74
|
+
nav{background:#161b22;border-bottom:1px solid #30363d;display:flex;align-items:center;padding:0 24px;height:52px;gap:0}
|
|
75
|
+
.nav-logo{font-size:16px;font-weight:700;color:#3fb950;margin-right:24px;white-space:nowrap}
|
|
76
|
+
.nav-logo span{color:#e6edf3}
|
|
77
|
+
.nav-link{padding:0 14px;height:52px;display:flex;align-items:center;font-size:13px;color:#8b949e;border-bottom:2px solid transparent;transition:color .15s}
|
|
78
|
+
.nav-link:hover{color:#e6edf3}
|
|
79
|
+
.nav-link.active{color:#e6edf3;border-bottom-color:#3fb950}
|
|
80
|
+
.nav-dot{width:8px;height:8px;border-radius:50%;background:#3fb950;box-shadow:0 0 6px #3fb950;margin-left:auto;animation:pulse 2s infinite}
|
|
81
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
|
82
|
+
|
|
83
|
+
/* layout */
|
|
84
|
+
main{max-width:960px;margin:0 auto;padding:32px 24px}
|
|
85
|
+
h1{font-size:20px;font-weight:600;margin-bottom:24px}
|
|
86
|
+
h2{font-size:13px;font-weight:600;color:#8b949e;text-transform:uppercase;letter-spacing:.6px;margin-bottom:12px}
|
|
87
|
+
|
|
88
|
+
/* cards */
|
|
89
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:28px}
|
|
90
|
+
.card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:18px}
|
|
91
|
+
.card-label{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px}
|
|
92
|
+
.card-value{font-size:30px;font-weight:700;line-height:1}
|
|
93
|
+
.card-sub{font-size:11px;color:#8b949e;margin-top:5px}
|
|
94
|
+
|
|
95
|
+
/* section */
|
|
96
|
+
.section{margin-bottom:28px}
|
|
97
|
+
|
|
98
|
+
/* next runs */
|
|
99
|
+
.next-row{display:flex;gap:10px;flex-wrap:wrap}
|
|
100
|
+
.next-chip{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:10px 16px}
|
|
101
|
+
.next-chip .t{font-size:17px;font-weight:700;color:#58a6ff}
|
|
102
|
+
.next-chip .r{font-size:11px;color:#8b949e;margin-top:3px}
|
|
103
|
+
|
|
104
|
+
/* table */
|
|
105
|
+
.tbl-wrap{background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden}
|
|
106
|
+
table{width:100%;border-collapse:collapse}
|
|
107
|
+
thead th{background:#21262d;padding:9px 14px;text-align:left;font-size:11px;color:#8b949e;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
|
|
108
|
+
tbody tr{border-top:1px solid #21262d;transition:background .1s}
|
|
109
|
+
tbody tr:hover{background:#1c2128}
|
|
110
|
+
td{padding:9px 14px;font-size:13px;vertical-align:middle}
|
|
111
|
+
.td-msg{color:#e6edf3}
|
|
112
|
+
.td-file{color:#8b949e;font-family:monospace;font-size:12px}
|
|
113
|
+
.td-time{color:#8b949e;white-space:nowrap}
|
|
114
|
+
|
|
115
|
+
/* form */
|
|
116
|
+
.form-group{margin-bottom:18px}
|
|
117
|
+
label{display:block;font-size:13px;font-weight:500;margin-bottom:6px;color:#c9d1d9}
|
|
118
|
+
input[type=text],select{width:100%;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:9px 12px;color:#e6edf3;font-size:14px;outline:none;transition:border-color .15s}
|
|
119
|
+
input[type=text]:focus,select:focus{border-color:#58a6ff}
|
|
120
|
+
.hint{font-size:12px;color:#8b949e;margin-top:5px}
|
|
121
|
+
.toggle-row{display:flex;align-items:center;gap:10px}
|
|
122
|
+
input[type=checkbox]{width:16px;height:16px;accent-color:#3fb950;cursor:pointer}
|
|
123
|
+
|
|
124
|
+
/* buttons */
|
|
125
|
+
.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:opacity .15s}
|
|
126
|
+
.btn:hover{opacity:.85}
|
|
127
|
+
.btn:disabled{opacity:.4;cursor:not-allowed}
|
|
128
|
+
.btn-green{background:#238636;color:#fff}
|
|
129
|
+
.btn-blue{background:#1f6feb;color:#fff}
|
|
130
|
+
.btn-red{background:#da3633;color:#fff}
|
|
131
|
+
.btn-ghost{background:transparent;border:1px solid #30363d;color:#c9d1d9}
|
|
132
|
+
.btn-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
|
|
133
|
+
|
|
134
|
+
/* alerts */
|
|
135
|
+
.alert{padding:12px 16px;border-radius:8px;font-size:13px;margin-bottom:18px}
|
|
136
|
+
.alert-green{background:#0d2818;border:1px solid #238636;color:#3fb950}
|
|
137
|
+
.alert-red{background:#2d1117;border:1px solid #da3633;color:#ff7b72}
|
|
138
|
+
.alert-blue{background:#0c1929;border:1px solid #1f6feb;color:#58a6ff}
|
|
139
|
+
|
|
140
|
+
/* misc */
|
|
141
|
+
.mono{font-family:monospace;font-size:13px;color:#79c0ff;background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px 14px;word-break:break-all}
|
|
142
|
+
.empty{text-align:center;padding:40px;color:#8b949e;font-size:13px}
|
|
143
|
+
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:#21262d;color:#8b949e}
|
|
144
|
+
.tag-green{background:#0d2818;color:#3fb950}
|
|
145
|
+
footer{text-align:right;font-size:11px;color:#484f58;margin-top:24px}
|
|
146
|
+
</style>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<nav>
|
|
150
|
+
<div class="nav-logo">git<span>-cracked</span></div>
|
|
151
|
+
<a class="nav-link ${active === 'dashboard' ? 'active' : ''}" href="/">Dashboard</a>
|
|
152
|
+
<a class="nav-link ${active === 'settings' ? 'active' : ''}" href="/settings">Settings</a>
|
|
153
|
+
<div class="nav-dot"></div>
|
|
154
|
+
</nav>
|
|
155
|
+
<main>${body}</main>
|
|
156
|
+
</body>
|
|
157
|
+
</html>`;
|
|
158
|
+
|
|
159
|
+
// ─── pages ───────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function pageSetup(flash = '') {
|
|
162
|
+
return SHELL(`
|
|
163
|
+
<h1>Welcome to git-cracked</h1>
|
|
164
|
+
<p style="color:#8b949e;font-size:14px;margin-bottom:24px">Let's get you set up. This takes about 30 seconds.</p>
|
|
165
|
+
|
|
166
|
+
${flash ? `<div class="alert alert-red">${esc(flash)}</div>` : ''}
|
|
167
|
+
|
|
168
|
+
<div class="alert alert-blue">
|
|
169
|
+
You need a <strong>private GitHub repo</strong> with some source code files cloned to your machine.
|
|
170
|
+
Don't have one? <a href="https://github.com/new" target="_blank" style="color:#79c0ff">Create one on GitHub →</a>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<form method="POST" action="/api/setup">
|
|
174
|
+
<div class="form-group">
|
|
175
|
+
<label for="repoPath">Path to your private repo</label>
|
|
176
|
+
<input type="text" id="repoPath" name="repoPath" placeholder="C:\\Users\\you\\repos\\my-private-repo" required>
|
|
177
|
+
<div class="hint">The full path to the local folder where you cloned your private repo</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="form-group">
|
|
181
|
+
<label for="branch">Branch name</label>
|
|
182
|
+
<input type="text" id="branch" name="branch" value="main" required>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="form-group">
|
|
186
|
+
<label>Schedule — when should commits happen?</label>
|
|
187
|
+
<select name="schedule">
|
|
188
|
+
<option value="3x">3× per weekday — 9am, 1pm, 4pm (recommended)</option>
|
|
189
|
+
<option value="2x">2× per weekday — 9am, 3pm</option>
|
|
190
|
+
<option value="1x">1× per weekday — 10am only</option>
|
|
191
|
+
<option value="4x">4× per weekday — 9am, 12pm, 3pm, 6pm</option>
|
|
192
|
+
</select>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="form-group">
|
|
196
|
+
<div class="toggle-row">
|
|
197
|
+
<input type="checkbox" id="push" name="push" checked>
|
|
198
|
+
<label for="push" style="margin:0">Push to GitHub after each commit</label>
|
|
199
|
+
</div>
|
|
200
|
+
<div class="hint">Uncheck if you only want local commits</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<button type="submit" class="btn btn-green">Save and open dashboard →</button>
|
|
204
|
+
</form>
|
|
205
|
+
`, { title: 'Setup', active: '' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pageDashboard(config, activity) {
|
|
209
|
+
const commits = activity.commits ?? [];
|
|
210
|
+
const today = new Date();
|
|
211
|
+
const todayCount = commits.filter(c => {
|
|
212
|
+
const d = new Date(c.timestamp);
|
|
213
|
+
return d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
|
|
214
|
+
}).length;
|
|
215
|
+
const runs = nextRuns(config.schedule);
|
|
216
|
+
|
|
217
|
+
const nextHTML = runs.length
|
|
218
|
+
? runs.map(d => `<div class="next-chip"><div class="t">${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div><div class="r">${rel(d)}</div></div>`).join('')
|
|
219
|
+
: '<span style="color:#8b949e;font-size:13px">No upcoming runs today</span>';
|
|
220
|
+
|
|
221
|
+
const rows = commits.slice(0, 100).map(c => `
|
|
222
|
+
<tr>
|
|
223
|
+
<td class="td-msg">${esc(c.message)}</td>
|
|
224
|
+
<td class="td-file">${esc(c.file ?? '—')}</td>
|
|
225
|
+
<td class="td-time">${timeAgo(c.timestamp)}</td>
|
|
226
|
+
</tr>`).join('');
|
|
227
|
+
|
|
228
|
+
return SHELL(`
|
|
229
|
+
<div class="grid">
|
|
230
|
+
<div class="card">
|
|
231
|
+
<div class="card-label">Total commits</div>
|
|
232
|
+
<div class="card-value">${commits.length}</div>
|
|
233
|
+
<div class="card-sub">all time</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="card">
|
|
236
|
+
<div class="card-label">Today</div>
|
|
237
|
+
<div class="card-value">${todayCount}</div>
|
|
238
|
+
<div class="card-sub">${today.toLocaleDateString([], { weekday: 'long' })}</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="card">
|
|
241
|
+
<div class="card-label">Frequency</div>
|
|
242
|
+
<div class="card-value">${config.schedule.length}×</div>
|
|
243
|
+
<div class="card-sub">per weekday</div>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="card">
|
|
246
|
+
<div class="card-label">Last commit</div>
|
|
247
|
+
<div class="card-value" style="font-size:18px;padding-top:6px">${commits[0] ? timeAgo(commits[0].timestamp) : '—'}</div>
|
|
248
|
+
<div class="card-sub"> </div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<div class="section">
|
|
253
|
+
<h2>Next scheduled commits</h2>
|
|
254
|
+
<div class="next-row">${nextHTML}</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div class="section">
|
|
258
|
+
<h2>Target repository</h2>
|
|
259
|
+
<div class="mono">${esc(config.repoPath)}</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div class="section">
|
|
263
|
+
<h2>Quick actions</h2>
|
|
264
|
+
<div class="btn-row">
|
|
265
|
+
<button class="btn btn-green" onclick="commitNow(this)">⚡ Commit now</button>
|
|
266
|
+
<a href="/settings" class="btn btn-ghost">⚙ Settings</a>
|
|
267
|
+
</div>
|
|
268
|
+
<div id="commit-result" style="margin-top:12px"></div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="section">
|
|
272
|
+
<h2>Recent activity</h2>
|
|
273
|
+
${commits.length === 0
|
|
274
|
+
? '<div class="empty">No commits yet — click "Commit now" above to test it.</div>'
|
|
275
|
+
: `<div class="tbl-wrap"><table>
|
|
276
|
+
<thead><tr><th>Commit message</th><th>File changed</th><th>When</th></tr></thead>
|
|
277
|
+
<tbody>${rows}</tbody>
|
|
278
|
+
</table></div>`}
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<footer>Auto-refreshes every 30 seconds</footer>
|
|
282
|
+
|
|
283
|
+
<script>
|
|
284
|
+
async function commitNow(btn) {
|
|
285
|
+
btn.disabled = true;
|
|
286
|
+
btn.textContent = 'Committing…';
|
|
287
|
+
const res = await fetch('/api/commit-now', { method: 'POST' });
|
|
288
|
+
const data = await res.json();
|
|
289
|
+
const el = document.getElementById('commit-result');
|
|
290
|
+
if (data.ok) {
|
|
291
|
+
el.innerHTML = '<div class="alert alert-green">✓ ' + data.message + '</div>';
|
|
292
|
+
setTimeout(() => location.reload(), 1500);
|
|
293
|
+
} else {
|
|
294
|
+
el.innerHTML = '<div class="alert alert-red">✗ ' + data.error + '</div>';
|
|
295
|
+
btn.disabled = false;
|
|
296
|
+
btn.textContent = '⚡ Commit now';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
setTimeout(() => location.reload(), 30000);
|
|
300
|
+
</script>
|
|
301
|
+
`, { title: 'Dashboard', active: 'dashboard' });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function pageSettings(config, flash = '') {
|
|
305
|
+
const scheduleVal =
|
|
306
|
+
JSON.stringify(config.schedule) === JSON.stringify(['0 9 * * 1-5','0 13 * * 1-5','0 16 * * 1-5']) ? '3x' :
|
|
307
|
+
JSON.stringify(config.schedule) === JSON.stringify(['0 9 * * 1-5','0 15 * * 1-5']) ? '2x' :
|
|
308
|
+
JSON.stringify(config.schedule) === JSON.stringify(['0 10 * * 1-5']) ? '1x' :
|
|
309
|
+
JSON.stringify(config.schedule) === JSON.stringify(['0 9 * * 1-5','0 12 * * 1-5','0 15 * * 1-5','0 18 * * 1-5']) ? '4x' : '3x';
|
|
310
|
+
|
|
311
|
+
return SHELL(`
|
|
312
|
+
<h1>Settings</h1>
|
|
313
|
+
|
|
314
|
+
${flash ? `<div class="alert ${flash.startsWith('✓') ? 'alert-green' : 'alert-red'}">${esc(flash)}</div>` : ''}
|
|
315
|
+
|
|
316
|
+
<form method="POST" action="/api/settings">
|
|
317
|
+
<div class="form-group">
|
|
318
|
+
<label for="repoPath">Repo path</label>
|
|
319
|
+
<input type="text" id="repoPath" name="repoPath" value="${esc(config.repoPath)}" required>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div class="form-group">
|
|
323
|
+
<label for="branch">Branch</label>
|
|
324
|
+
<input type="text" id="branch" name="branch" value="${esc(config.branch ?? 'main')}" required>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div class="form-group">
|
|
328
|
+
<label>Commit frequency</label>
|
|
329
|
+
<select name="schedule">
|
|
330
|
+
<option value="3x" ${scheduleVal === '3x' ? 'selected' : ''}>3× per weekday — 9am, 1pm, 4pm</option>
|
|
331
|
+
<option value="2x" ${scheduleVal === '2x' ? 'selected' : ''}>2× per weekday — 9am, 3pm</option>
|
|
332
|
+
<option value="1x" ${scheduleVal === '1x' ? 'selected' : ''}>1× per weekday — 10am only</option>
|
|
333
|
+
<option value="4x" ${scheduleVal === '4x' ? 'selected' : ''}>4× per weekday — 9am, 12pm, 3pm, 6pm</option>
|
|
334
|
+
</select>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div class="form-group">
|
|
338
|
+
<div class="toggle-row">
|
|
339
|
+
<input type="checkbox" id="push" name="push" ${config.pushAfterCommit !== false ? 'checked' : ''}>
|
|
340
|
+
<label for="push" style="margin:0">Push to GitHub after each commit</label>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<div class="btn-row">
|
|
345
|
+
<button type="submit" class="btn btn-blue">Save settings</button>
|
|
346
|
+
<a href="/" class="btn btn-ghost">Cancel</a>
|
|
347
|
+
</div>
|
|
348
|
+
</form>
|
|
349
|
+
|
|
350
|
+
<div style="margin-top:36px;padding-top:24px;border-top:1px solid #21262d">
|
|
351
|
+
<h2 style="margin-bottom:12px">Auto-start on boot</h2>
|
|
352
|
+
<p style="font-size:13px;color:#8b949e;margin-bottom:14px">Run one of these commands in your terminal to make git-cracked start automatically when your computer turns on:</p>
|
|
353
|
+
<div class="mono" style="margin-bottom:8px">npm run install-windows</div>
|
|
354
|
+
<div class="mono" style="margin-bottom:8px">npm run install-mac</div>
|
|
355
|
+
<div class="mono">npm run install-linux</div>
|
|
356
|
+
</div>
|
|
357
|
+
`, { title: 'Settings', active: 'settings' });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── schedule map ─────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
const SCHEDULES = {
|
|
363
|
+
'3x': ['0 9 * * 1-5', '0 13 * * 1-5', '0 16 * * 1-5'],
|
|
364
|
+
'2x': ['0 9 * * 1-5', '0 15 * * 1-5'],
|
|
365
|
+
'1x': ['0 10 * * 1-5'],
|
|
366
|
+
'4x': ['0 9 * * 1-5', '0 12 * * 1-5', '0 15 * * 1-5', '0 18 * * 1-5'],
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
function parseBody(raw) {
|
|
370
|
+
return Object.fromEntries(new URLSearchParams(raw));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── server ──────────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
export function startDashboard({ onConfigSaved } = {}) {
|
|
376
|
+
const server = createServer(async (req, res) => {
|
|
377
|
+
const config = readConfig();
|
|
378
|
+
const url = req.url.split('?')[0];
|
|
379
|
+
|
|
380
|
+
// ── GET routes ──
|
|
381
|
+
if (req.method === 'GET') {
|
|
382
|
+
if (url === '/setup') {
|
|
383
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
384
|
+
return res.end(pageSetup());
|
|
385
|
+
}
|
|
386
|
+
if (url === '/settings') {
|
|
387
|
+
if (!config) return redirect(res, '/setup');
|
|
388
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
389
|
+
return res.end(pageSettings(config));
|
|
390
|
+
}
|
|
391
|
+
if (url === '/api/activity') {
|
|
392
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
393
|
+
return res.end(JSON.stringify(getActivity()));
|
|
394
|
+
}
|
|
395
|
+
// default: dashboard
|
|
396
|
+
if (!config) return redirect(res, '/setup');
|
|
397
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
398
|
+
return res.end(pageDashboard(config, getActivity()));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── POST routes ──
|
|
402
|
+
if (req.method === 'POST') {
|
|
403
|
+
const body = await readBody(req);
|
|
404
|
+
const fields = parseBody(body);
|
|
405
|
+
|
|
406
|
+
if (url === '/api/setup') {
|
|
407
|
+
const repoPath = (fields.repoPath ?? '').trim();
|
|
408
|
+
if (!repoPath) {
|
|
409
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
410
|
+
return res.end(pageSetup('Repo path is required.'));
|
|
411
|
+
}
|
|
412
|
+
const cfg = {
|
|
413
|
+
repoPath,
|
|
414
|
+
branch: (fields.branch ?? 'main').trim() || 'main',
|
|
415
|
+
remoteName: 'origin',
|
|
416
|
+
pushAfterCommit: fields.push === 'on',
|
|
417
|
+
schedule: SCHEDULES[fields.schedule] ?? SCHEDULES['3x'],
|
|
418
|
+
};
|
|
419
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
420
|
+
if (onConfigSaved) onConfigSaved(cfg);
|
|
421
|
+
return redirect(res, '/');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (url === '/api/settings') {
|
|
425
|
+
if (!config) return redirect(res, '/setup');
|
|
426
|
+
const cfg = {
|
|
427
|
+
...config,
|
|
428
|
+
repoPath: (fields.repoPath ?? '').trim() || config.repoPath,
|
|
429
|
+
branch: (fields.branch ?? 'main').trim() || 'main',
|
|
430
|
+
pushAfterCommit: fields.push === 'on',
|
|
431
|
+
schedule: SCHEDULES[fields.schedule] ?? config.schedule,
|
|
432
|
+
};
|
|
433
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
434
|
+
if (onConfigSaved) onConfigSaved(cfg);
|
|
435
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
436
|
+
return res.end(pageSettings(cfg, '✓ Settings saved and applied.'));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (url === '/api/commit-now') {
|
|
440
|
+
if (!config) {
|
|
441
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
442
|
+
return res.end(JSON.stringify({ ok: false, error: 'Not configured yet.' }));
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
await runCommit(config);
|
|
446
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
447
|
+
const latest = getActivity().commits[0];
|
|
448
|
+
return res.end(JSON.stringify({ ok: true, message: latest?.message ?? 'Committed!' }));
|
|
449
|
+
} catch (err) {
|
|
450
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
451
|
+
return res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
res.writeHead(404);
|
|
457
|
+
res.end('Not found');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
server.on('error', (err) => {
|
|
461
|
+
if (err.code === 'EADDRINUSE') {
|
|
462
|
+
console.error(`git-cracked is already running — dashboard is at http://localhost:${PORT}`);
|
|
463
|
+
console.error('If you want to restart it, close the other instance first.');
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
throw err;
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
470
|
+
console.log(`Dashboard: http://localhost:${PORT}`);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function redirect(res, to) {
|
|
475
|
+
res.writeHead(302, { Location: to });
|
|
476
|
+
res.end();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function readBody(req) {
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
let data = '';
|
|
482
|
+
req.on('data', chunk => { data += chunk; });
|
|
483
|
+
req.on('end', () => resolve(data));
|
|
484
|
+
});
|
|
485
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import cron from 'node-cron';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { runCommit } from './committer.js';
|
|
5
|
+
import { startDashboard } from './dashboard.js';
|
|
6
|
+
import { CONFIG_PATH } from './paths.js';
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const runNow = args.includes('--now');
|
|
10
|
+
const noDashboard = args.includes('--no-dashboard');
|
|
11
|
+
|
|
12
|
+
if (runNow) {
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
console.log('Running a commit now (--now flag)...');
|
|
15
|
+
try {
|
|
16
|
+
await runCommit(config);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error('Commit failed:', err.message);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let scheduled = false;
|
|
25
|
+
const activeTasks = [];
|
|
26
|
+
|
|
27
|
+
function startSchedule(config) {
|
|
28
|
+
// Replace any previously registered jobs (e.g. after a settings change)
|
|
29
|
+
for (const task of activeTasks) task.stop();
|
|
30
|
+
activeTasks.length = 0;
|
|
31
|
+
|
|
32
|
+
for (const expression of config.schedule) {
|
|
33
|
+
if (!cron.validate(expression)) {
|
|
34
|
+
console.error(`Invalid cron expression in config: "${expression}" — skipping`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const task = cron.schedule(expression, async () => {
|
|
38
|
+
console.log(`[${new Date().toISOString()}] Cron triggered`);
|
|
39
|
+
try {
|
|
40
|
+
await runCommit(config);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('Commit failed:', err.message);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
activeTasks.push(task);
|
|
46
|
+
}
|
|
47
|
+
scheduled = true;
|
|
48
|
+
console.log('Schedule active:', config.schedule.join(', '));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log('Git Cracked running.');
|
|
52
|
+
|
|
53
|
+
if (existsSync(CONFIG_PATH)) {
|
|
54
|
+
startSchedule(loadConfig());
|
|
55
|
+
} else {
|
|
56
|
+
console.log('No configuration yet — complete setup in the browser.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!noDashboard) {
|
|
60
|
+
startDashboard({ onConfigSaved: (config) => startSchedule(config) });
|
|
61
|
+
} else if (!scheduled) {
|
|
62
|
+
console.error('No config and no dashboard — nothing to do. Run without --no-dashboard to set up.');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { ACTIVITY_PATH } from './paths.js';
|
|
3
|
+
|
|
4
|
+
const MAX_ENTRIES = 500;
|
|
5
|
+
|
|
6
|
+
function read() {
|
|
7
|
+
if (!existsSync(ACTIVITY_PATH)) return { commits: [] };
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(ACTIVITY_PATH, 'utf8'));
|
|
10
|
+
} catch {
|
|
11
|
+
return { commits: [] };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function logCommit({ message, file, repoPath }) {
|
|
16
|
+
const data = read();
|
|
17
|
+
data.commits.unshift({
|
|
18
|
+
timestamp: new Date().toISOString(),
|
|
19
|
+
message,
|
|
20
|
+
file,
|
|
21
|
+
repoPath,
|
|
22
|
+
});
|
|
23
|
+
if (data.commits.length > MAX_ENTRIES) data.commits = data.commits.slice(0, MAX_ENTRIES);
|
|
24
|
+
writeFileSync(ACTIVITY_PATH, JSON.stringify(data, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getActivity() {
|
|
28
|
+
return read();
|
|
29
|
+
}
|