git-cracked 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-cracked",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Auto-commits realistic-looking code changes to keep your GitHub green — free, no API key required",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "author": "Trenton Scott",
34
34
  "dependencies": {
35
- "node-cron": "^3.0.3",
35
+ "node-cron": "^4.2.1",
36
36
  "simple-git": "^3.27.0"
37
37
  },
38
38
  "engines": {
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import { CONFIG_PATH } from './paths.js';
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const hasConfig = existsSync(CONFIG_PATH);
10
- const PORT = 4856;
10
+ const PORT = Number(process.env.GIT_CRACKED_PORT) || 4856;
11
11
 
12
12
  // Start the main process
13
13
  const child = spawn(process.execPath, [join(__dirname, 'index.js')], {
package/src/dashboard.js CHANGED
@@ -1,485 +1,626 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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">&nbsp;</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
- }
1
+ import { createServer } from 'http';
2
+ import { execFile, spawn } from 'child_process';
3
+ import { writeFileSync, existsSync, readFileSync } from 'fs';
4
+ import { getActivity } from './logger.js';
5
+ import { runCommit } from './committer.js';
6
+ import { CONFIG_PATH } from './paths.js';
7
+
8
+ const PORT = Number(process.env.GIT_CRACKED_PORT) || 4856;
9
+
10
+ // ─── helpers ────────────────────────────────────────────────────────────────
11
+
12
+ function esc(str) {
13
+ return String(str ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ }
15
+
16
+ function readConfig() {
17
+ if (!existsSync(CONFIG_PATH)) return null;
18
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return null; }
19
+ }
20
+
21
+ function timeAgo(iso) {
22
+ const diff = Date.now() - new Date(iso).getTime();
23
+ const m = Math.round(diff / 60000);
24
+ if (m < 1) return 'just now';
25
+ if (m < 60) return `${m}m ago`;
26
+ const h = Math.round(m / 60);
27
+ if (h < 24) return `${h}h ago`;
28
+ return `${Math.round(h / 24)}d ago`;
29
+ }
30
+
31
+ function nextRuns(schedule) {
32
+ const now = new Date();
33
+ const results = [];
34
+ for (const expr of (schedule ?? [])) {
35
+ const parts = expr.trim().split(/\s+/);
36
+ if (parts.length !== 5) continue;
37
+ const [minute, hour, , , dow] = parts;
38
+ const m = parseInt(minute, 10), h = parseInt(hour, 10);
39
+ const dowSet = new Set();
40
+ if (dow === '*') { for (let i = 0; i <= 6; i++) dowSet.add(i); }
41
+ else for (const p of dow.split(',')) {
42
+ if (p.includes('-')) { const [a, b] = p.split('-').map(Number); for (let i = a; i <= b; i++) dowSet.add(i % 7); }
43
+ else dowSet.add(Number(p) % 7);
44
+ }
45
+ for (let d = 0; d <= 7; d++) {
46
+ const c = new Date(now); c.setDate(c.getDate() + d); c.setHours(h, m, 0, 0);
47
+ if (dowSet.has(c.getDay()) && c > now) { results.push(c); break; }
48
+ }
49
+ }
50
+ return results.sort((a, b) => a - b);
51
+ }
52
+
53
+ // Windows: spawning powershell.exe and loading WinForms takes many seconds
54
+ // cold (Defender scan + JIT), so keep one helper process alive that loads the
55
+ // assembly once and shows a dialog each time it reads a line from stdin.
56
+ let winPicker = null;
57
+
58
+ function getWinPicker() {
59
+ if (winPicker && winPicker.exitCode === null) return winPicker;
60
+ const script = `
61
+ Add-Type -AssemblyName System.Windows.Forms | Out-Null
62
+ while ($true) {
63
+ $line = [Console]::In.ReadLine()
64
+ if ($null -eq $line) { break }
65
+ $owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; WindowState = 'Minimized' }
66
+ $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
67
+ $dlg.Description = 'Select your repo folder'
68
+ if ($dlg.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {
69
+ [Console]::Out.WriteLine($dlg.SelectedPath)
70
+ } else {
71
+ [Console]::Out.WriteLine('')
72
+ }
73
+ $owner.Dispose()
74
+ $dlg.Dispose()
75
+ }`;
76
+ const proc = spawn('powershell.exe', ['-NoProfile', '-STA', '-Command', script], {
77
+ stdio: ['pipe', 'pipe', 'ignore'],
78
+ windowsHide: true,
79
+ });
80
+ proc.stdout.setEncoding('utf8');
81
+ proc.on('exit', () => { if (winPicker === proc) winPicker = null; });
82
+ proc.on('error', () => { if (winPicker === proc) winPicker = null; });
83
+ winPicker = proc;
84
+ return proc;
85
+ }
86
+
87
+ // Opens the OS-native folder picker and resolves with the chosen absolute
88
+ // path, or null if the user cancelled. Only one dialog at a time.
89
+ let pickerOpen = false;
90
+
91
+ function pickFolder() {
92
+ return new Promise((resolve, reject) => {
93
+ if (pickerOpen) return reject(new Error('A folder picker is already open — check your taskbar.'));
94
+ pickerOpen = true;
95
+ const done = (err, path) => { pickerOpen = false; err ? reject(err) : resolve(path); };
96
+
97
+ if (process.platform === 'win32') {
98
+ const proc = getWinPicker();
99
+ let buf = '';
100
+ const onData = (chunk) => {
101
+ buf += chunk;
102
+ const nl = buf.indexOf('\n');
103
+ if (nl === -1) return;
104
+ proc.stdout.off('data', onData);
105
+ proc.off('exit', onExit);
106
+ done(null, buf.slice(0, nl).trim() || null);
107
+ };
108
+ const onExit = () => done(new Error('Could not open the folder picker.'));
109
+ proc.stdout.on('data', onData);
110
+ proc.once('exit', onExit);
111
+ proc.stdin.write('pick\n');
112
+ } else if (process.platform === 'darwin') {
113
+ execFile('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select your repo folder")'], (err, stdout) => {
114
+ if (err) return done(null, null); // cancelled
115
+ done(null, stdout.trim().replace(/\/$/, '') || null);
116
+ });
117
+ } else {
118
+ execFile('zenity', ['--file-selection', '--directory', '--title=Select your repo folder'], (err, stdout) => {
119
+ if (err) {
120
+ if (err.code === 'ENOENT') return done(new Error('Folder picker requires zenity (sudo apt install zenity).'));
121
+ return done(null, null); // cancelled
122
+ }
123
+ done(null, stdout.trim() || null);
124
+ });
125
+ }
126
+ });
127
+ }
128
+
129
+ function rel(date) {
130
+ const mins = Math.round((date - Date.now()) / 60000);
131
+ if (mins < 60) return `in ${mins}m`;
132
+ const h = Math.round(mins / 60);
133
+ if (h < 24) return `in ${h}h`;
134
+ return `in ${Math.round(h / 24)}d`;
135
+ }
136
+
137
+ // ─── shared shell ────────────────────────────────────────────────────────────
138
+
139
+ const SHELL = (body, { title = 'git-cracked', active = '' } = {}) => `<!DOCTYPE html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="UTF-8">
143
+ <meta name="viewport" content="width=device-width,initial-scale=1">
144
+ <title>${esc(title)} · git-cracked</title>
145
+ <style>
146
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
147
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#e6edf3;min-height:100vh}
148
+ a{color:inherit;text-decoration:none}
149
+
150
+ /* nav */
151
+ nav{background:#161b22;border-bottom:1px solid #30363d;display:flex;align-items:center;padding:0 24px;height:52px;gap:0}
152
+ .nav-logo{font-size:16px;font-weight:700;color:#3fb950;margin-right:24px;white-space:nowrap}
153
+ .nav-logo span{color:#e6edf3}
154
+ .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}
155
+ .nav-link:hover{color:#e6edf3}
156
+ .nav-link.active{color:#e6edf3;border-bottom-color:#3fb950}
157
+ .nav-dot{width:8px;height:8px;border-radius:50%;background:#3fb950;box-shadow:0 0 6px #3fb950;margin-left:auto;animation:pulse 2s infinite}
158
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
159
+
160
+ /* layout */
161
+ main{max-width:960px;margin:0 auto;padding:32px 24px}
162
+ h1{font-size:20px;font-weight:600;margin-bottom:24px}
163
+ h2{font-size:13px;font-weight:600;color:#8b949e;text-transform:uppercase;letter-spacing:.6px;margin-bottom:12px}
164
+
165
+ /* cards */
166
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:28px}
167
+ .card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:18px}
168
+ .card-label{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px}
169
+ .card-value{font-size:30px;font-weight:700;line-height:1}
170
+ .card-sub{font-size:11px;color:#8b949e;margin-top:5px}
171
+
172
+ /* section */
173
+ .section{margin-bottom:28px}
174
+
175
+ /* next runs */
176
+ .next-row{display:flex;gap:10px;flex-wrap:wrap}
177
+ .next-chip{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:10px 16px}
178
+ .next-chip .t{font-size:17px;font-weight:700;color:#58a6ff}
179
+ .next-chip .r{font-size:11px;color:#8b949e;margin-top:3px}
180
+
181
+ /* table */
182
+ .tbl-wrap{background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden}
183
+ table{width:100%;border-collapse:collapse}
184
+ thead th{background:#21262d;padding:9px 14px;text-align:left;font-size:11px;color:#8b949e;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
185
+ tbody tr{border-top:1px solid #21262d;transition:background .1s}
186
+ tbody tr:hover{background:#1c2128}
187
+ td{padding:9px 14px;font-size:13px;vertical-align:middle}
188
+ .td-msg{color:#e6edf3}
189
+ .td-file{color:#8b949e;font-family:monospace;font-size:12px}
190
+ .td-time{color:#8b949e;white-space:nowrap}
191
+
192
+ /* form */
193
+ .form-group{margin-bottom:18px}
194
+ label{display:block;font-size:13px;font-weight:500;margin-bottom:6px;color:#c9d1d9}
195
+ 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}
196
+ input[type=text]:focus,select:focus{border-color:#58a6ff}
197
+ .hint{font-size:12px;color:#8b949e;margin-top:5px}
198
+ .input-row{display:flex;gap:8px}
199
+ .input-row input{flex:1}
200
+ .btn-pick{flex:none;width:38px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#8b949e;font-size:18px;line-height:1;cursor:pointer;transition:border-color .15s,color .15s}
201
+ .btn-pick:hover{border-color:#58a6ff;color:#e6edf3}
202
+ .btn-pick:disabled{opacity:.4;cursor:wait}
203
+ .toggle-row{display:flex;align-items:center;gap:10px}
204
+ input[type=checkbox]{width:16px;height:16px;accent-color:#3fb950;cursor:pointer}
205
+
206
+ /* buttons */
207
+ .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}
208
+ .btn:hover{opacity:.85}
209
+ .btn:disabled{opacity:.4;cursor:not-allowed}
210
+ .btn-green{background:#238636;color:#fff}
211
+ .btn-blue{background:#1f6feb;color:#fff}
212
+ .btn-red{background:#da3633;color:#fff}
213
+ .btn-ghost{background:transparent;border:1px solid #30363d;color:#c9d1d9}
214
+ .btn-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
215
+
216
+ /* alerts */
217
+ .alert{padding:12px 16px;border-radius:8px;font-size:13px;margin-bottom:18px}
218
+ .alert-green{background:#0d2818;border:1px solid #238636;color:#3fb950}
219
+ .alert-red{background:#2d1117;border:1px solid #da3633;color:#ff7b72}
220
+ .alert-blue{background:#0c1929;border:1px solid #1f6feb;color:#58a6ff}
221
+
222
+ /* misc */
223
+ .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}
224
+ .empty{text-align:center;padding:40px;color:#8b949e;font-size:13px}
225
+ .tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:#21262d;color:#8b949e}
226
+ .tag-green{background:#0d2818;color:#3fb950}
227
+ footer{text-align:right;font-size:11px;color:#484f58;margin-top:24px}
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <nav>
232
+ <div class="nav-logo">git<span>-cracked</span></div>
233
+ <a class="nav-link ${active === 'dashboard' ? 'active' : ''}" href="/">Dashboard</a>
234
+ <a class="nav-link ${active === 'settings' ? 'active' : ''}" href="/settings">Settings</a>
235
+ <div class="nav-dot"></div>
236
+ </nav>
237
+ <main>${body}</main>
238
+ </body>
239
+ </html>`;
240
+
241
+ // Shared by the setup and settings pages: opens the native folder picker via
242
+ // the server and fills the repo-path input with the result.
243
+ const PICK_FOLDER_JS = `
244
+ async function pickFolder(btn) {
245
+ btn.disabled = true;
246
+ try {
247
+ const res = await fetch('/api/pick-folder', { method: 'POST' });
248
+ const data = await res.json();
249
+ if (data.ok && data.path) document.getElementById('repoPath').value = data.path;
250
+ else if (!data.ok) alert(data.error);
251
+ } catch {
252
+ alert('Could not open the folder picker.');
253
+ } finally {
254
+ btn.disabled = false;
255
+ }
256
+ }`;
257
+
258
+ // ─── pages ───────────────────────────────────────────────────────────────────
259
+
260
+ function pageSetup(flash = '') {
261
+ return SHELL(`
262
+ <h1>Welcome to git-cracked</h1>
263
+ <p style="color:#8b949e;font-size:14px;margin-bottom:24px">Let's get you set up. This takes about 30 seconds.</p>
264
+
265
+ ${flash ? `<div class="alert alert-red">${esc(flash)}</div>` : ''}
266
+
267
+ <div class="alert alert-blue">
268
+ You need a <strong>private GitHub repo</strong> with some source code files cloned to your machine.
269
+ Don't have one? <a href="https://github.com/new" target="_blank" style="color:#79c0ff">Create one on GitHub →</a>
270
+ </div>
271
+
272
+ <form method="POST" action="/api/setup">
273
+ <div class="form-group">
274
+ <label for="repoPath">Path to your private repo</label>
275
+ <div class="input-row">
276
+ <input type="text" id="repoPath" name="repoPath" placeholder="C:\\Users\\you\\repos\\my-private-repo" required>
277
+ <button type="button" class="btn-pick" onclick="pickFolder(this)" title="Browse for folder…">+</button>
278
+ </div>
279
+ <div class="hint">The full path to the local folder where you cloned your private repo — or click + to browse</div>
280
+ </div>
281
+
282
+ <div class="form-group">
283
+ <label for="branch">Branch name</label>
284
+ <input type="text" id="branch" name="branch" value="main" required>
285
+ </div>
286
+
287
+ <div class="form-group">
288
+ <label>Schedule when should commits happen?</label>
289
+ <select name="schedule">
290
+ <option value="3x">3× per weekday — 9am, 1pm, 4pm (recommended)</option>
291
+ <option value="2x">2× per weekday 9am, 3pm</option>
292
+ <option value="1x">1× per weekday — 10am only</option>
293
+ <option value="4x">4× per weekday — 9am, 12pm, 3pm, 6pm</option>
294
+ </select>
295
+ </div>
296
+
297
+ <div class="form-group">
298
+ <div class="toggle-row">
299
+ <input type="checkbox" id="push" name="push" checked>
300
+ <label for="push" style="margin:0">Push to GitHub after each commit</label>
301
+ </div>
302
+ <div class="hint">Uncheck if you only want local commits</div>
303
+ </div>
304
+
305
+ <button type="submit" class="btn btn-green">Save and open dashboard →</button>
306
+ </form>
307
+
308
+ <script>${PICK_FOLDER_JS}</script>
309
+ `, { title: 'Setup', active: '' });
310
+ }
311
+
312
+ function pageDashboard(config, activity) {
313
+ const commits = activity.commits ?? [];
314
+ const today = new Date();
315
+ const todayCount = commits.filter(c => {
316
+ const d = new Date(c.timestamp);
317
+ return d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
318
+ }).length;
319
+ const runs = nextRuns(config.schedule);
320
+
321
+ const nextHTML = runs.length
322
+ ? 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('')
323
+ : '<span style="color:#8b949e;font-size:13px">No upcoming runs today</span>';
324
+
325
+ const rows = commits.slice(0, 100).map(c => `
326
+ <tr>
327
+ <td class="td-msg">${esc(c.message)}</td>
328
+ <td class="td-file">${esc(c.file ?? '—')}</td>
329
+ <td class="td-time">${timeAgo(c.timestamp)}</td>
330
+ </tr>`).join('');
331
+
332
+ return SHELL(`
333
+ <div class="grid">
334
+ <div class="card">
335
+ <div class="card-label">Total commits</div>
336
+ <div class="card-value">${commits.length}</div>
337
+ <div class="card-sub">all time</div>
338
+ </div>
339
+ <div class="card">
340
+ <div class="card-label">Today</div>
341
+ <div class="card-value">${todayCount}</div>
342
+ <div class="card-sub">${today.toLocaleDateString([], { weekday: 'long' })}</div>
343
+ </div>
344
+ <div class="card">
345
+ <div class="card-label">Frequency</div>
346
+ <div class="card-value">${config.schedule.length}×</div>
347
+ <div class="card-sub">per weekday</div>
348
+ </div>
349
+ <div class="card">
350
+ <div class="card-label">Last commit</div>
351
+ <div class="card-value" style="font-size:18px;padding-top:6px">${commits[0] ? timeAgo(commits[0].timestamp) : '—'}</div>
352
+ <div class="card-sub">&nbsp;</div>
353
+ </div>
354
+ </div>
355
+
356
+ <div class="section">
357
+ <h2>Next scheduled commits</h2>
358
+ <div class="next-row">${nextHTML}</div>
359
+ </div>
360
+
361
+ <div class="section">
362
+ <h2>Target repository</h2>
363
+ <div class="mono">${esc(config.repoPath)}</div>
364
+ </div>
365
+
366
+ <div class="section">
367
+ <h2>Quick actions</h2>
368
+ <div class="btn-row">
369
+ <button class="btn btn-green" onclick="commitNow(this)">⚡ Commit now</button>
370
+ <a href="/settings" class="btn btn-ghost">⚙ Settings</a>
371
+ </div>
372
+ <div id="commit-result" style="margin-top:12px"></div>
373
+ </div>
374
+
375
+ <div class="section">
376
+ <h2>Recent activity</h2>
377
+ ${commits.length === 0
378
+ ? '<div class="empty">No commits yet — click "Commit now" above to test it.</div>'
379
+ : `<div class="tbl-wrap"><table>
380
+ <thead><tr><th>Commit message</th><th>File changed</th><th>When</th></tr></thead>
381
+ <tbody>${rows}</tbody>
382
+ </table></div>`}
383
+ </div>
384
+
385
+ <footer>Auto-refreshes every 30 seconds</footer>
386
+
387
+ <script>
388
+ async function commitNow(btn) {
389
+ btn.disabled = true;
390
+ btn.textContent = 'Committing…';
391
+ const res = await fetch('/api/commit-now', { method: 'POST' });
392
+ const data = await res.json();
393
+ const el = document.getElementById('commit-result');
394
+ if (data.ok) {
395
+ el.innerHTML = '<div class="alert alert-green">✓ ' + data.message + '</div>';
396
+ setTimeout(() => location.reload(), 1500);
397
+ } else {
398
+ el.innerHTML = '<div class="alert alert-red">✗ ' + data.error + '</div>';
399
+ btn.disabled = false;
400
+ btn.textContent = '⚡ Commit now';
401
+ }
402
+ }
403
+ setTimeout(() => location.reload(), 30000);
404
+ </script>
405
+ `, { title: 'Dashboard', active: 'dashboard' });
406
+ }
407
+
408
+ function pageSettings(config, flash = '') {
409
+ const scheduleVal =
410
+ JSON.stringify(config.schedule) === JSON.stringify(['0 9 * * 1-5','0 13 * * 1-5','0 16 * * 1-5']) ? '3x' :
411
+ JSON.stringify(config.schedule) === JSON.stringify(['0 9 * * 1-5','0 15 * * 1-5']) ? '2x' :
412
+ JSON.stringify(config.schedule) === JSON.stringify(['0 10 * * 1-5']) ? '1x' :
413
+ JSON.stringify(config.schedule) === JSON.stringify(['0 9 * * 1-5','0 12 * * 1-5','0 15 * * 1-5','0 18 * * 1-5']) ? '4x' : '3x';
414
+
415
+ return SHELL(`
416
+ <h1>Settings</h1>
417
+
418
+ ${flash ? `<div class="alert ${flash.startsWith('✓') ? 'alert-green' : 'alert-red'}">${esc(flash)}</div>` : ''}
419
+
420
+ <form method="POST" action="/api/settings">
421
+ <div class="form-group">
422
+ <label for="repoPath">Repo path</label>
423
+ <div class="input-row">
424
+ <input type="text" id="repoPath" name="repoPath" value="${esc(config.repoPath)}" required>
425
+ <button type="button" class="btn-pick" onclick="pickFolder(this)" title="Browse for folder…">+</button>
426
+ </div>
427
+ </div>
428
+
429
+ <div class="form-group">
430
+ <label for="branch">Branch</label>
431
+ <input type="text" id="branch" name="branch" value="${esc(config.branch ?? 'main')}" required>
432
+ </div>
433
+
434
+ <div class="form-group">
435
+ <label>Commit frequency</label>
436
+ <select name="schedule">
437
+ <option value="3x" ${scheduleVal === '3x' ? 'selected' : ''}>3× per weekday — 9am, 1pm, 4pm</option>
438
+ <option value="2x" ${scheduleVal === '2x' ? 'selected' : ''}>2× per weekday — 9am, 3pm</option>
439
+ <option value="1x" ${scheduleVal === '1x' ? 'selected' : ''}>1× per weekday — 10am only</option>
440
+ <option value="4x" ${scheduleVal === '4x' ? 'selected' : ''}>4× per weekday — 9am, 12pm, 3pm, 6pm</option>
441
+ </select>
442
+ </div>
443
+
444
+ <div class="form-group">
445
+ <div class="toggle-row">
446
+ <input type="checkbox" id="push" name="push" ${config.pushAfterCommit !== false ? 'checked' : ''}>
447
+ <label for="push" style="margin:0">Push to GitHub after each commit</label>
448
+ </div>
449
+ </div>
450
+
451
+ <div class="btn-row">
452
+ <button type="submit" class="btn btn-blue">Save settings</button>
453
+ <a href="/" class="btn btn-ghost">Cancel</a>
454
+ </div>
455
+ </form>
456
+
457
+ <div style="margin-top:36px;padding-top:24px;border-top:1px solid #21262d">
458
+ <h2 style="margin-bottom:12px">Auto-start on boot</h2>
459
+ <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>
460
+ <div class="mono" style="margin-bottom:8px">npm run install-windows</div>
461
+ <div class="mono" style="margin-bottom:8px">npm run install-mac</div>
462
+ <div class="mono">npm run install-linux</div>
463
+ </div>
464
+
465
+ <script>${PICK_FOLDER_JS}</script>
466
+ `, { title: 'Settings', active: 'settings' });
467
+ }
468
+
469
+ // ─── schedule map ─────────────────────────────────────────────────────────────
470
+
471
+ const SCHEDULES = {
472
+ '3x': ['0 9 * * 1-5', '0 13 * * 1-5', '0 16 * * 1-5'],
473
+ '2x': ['0 9 * * 1-5', '0 15 * * 1-5'],
474
+ '1x': ['0 10 * * 1-5'],
475
+ '4x': ['0 9 * * 1-5', '0 12 * * 1-5', '0 15 * * 1-5', '0 18 * * 1-5'],
476
+ };
477
+
478
+ function parseBody(raw) {
479
+ return Object.fromEntries(new URLSearchParams(raw));
480
+ }
481
+
482
+ // ─── server ──────────────────────────────────────────────────────────────────
483
+
484
+ const ALLOWED_HOSTS = new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`]);
485
+ const ALLOWED_ORIGINS = new Set([`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`]);
486
+
487
+ // Block CSRF and DNS-rebinding: requests must come from the dashboard itself,
488
+ // not from another website the user happens to have open.
489
+ function isRequestAllowed(req) {
490
+ if (!ALLOWED_HOSTS.has(req.headers.host)) return false;
491
+ // Browsers send Origin on cross-site POSTs; same-origin form posts and
492
+ // fetch() send our own origin. Absent Origin (curl, same-origin GET) is fine.
493
+ if (req.headers.origin && !ALLOWED_ORIGINS.has(req.headers.origin)) return false;
494
+ return true;
495
+ }
496
+
497
+ export function startDashboard({ onConfigSaved } = {}) {
498
+ // Pre-warm the folder-picker helper so the first click is instant.
499
+ if (process.platform === 'win32') getWinPicker();
500
+
501
+ const server = createServer(async (req, res) => {
502
+ if (!isRequestAllowed(req)) {
503
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
504
+ return res.end('Forbidden');
505
+ }
506
+
507
+ const config = readConfig();
508
+ const url = req.url.split('?')[0];
509
+
510
+ // ── GET routes ──
511
+ if (req.method === 'GET') {
512
+ if (url === '/setup') {
513
+ res.writeHead(200, { 'Content-Type': 'text/html' });
514
+ return res.end(pageSetup());
515
+ }
516
+ if (url === '/settings') {
517
+ if (!config) return redirect(res, '/setup');
518
+ res.writeHead(200, { 'Content-Type': 'text/html' });
519
+ return res.end(pageSettings(config));
520
+ }
521
+ if (url === '/api/activity') {
522
+ res.writeHead(200, { 'Content-Type': 'application/json' });
523
+ return res.end(JSON.stringify(getActivity()));
524
+ }
525
+ // default: dashboard
526
+ if (!config) return redirect(res, '/setup');
527
+ res.writeHead(200, { 'Content-Type': 'text/html' });
528
+ return res.end(pageDashboard(config, getActivity()));
529
+ }
530
+
531
+ // ── POST routes ──
532
+ if (req.method === 'POST') {
533
+ const body = await readBody(req);
534
+ const fields = parseBody(body);
535
+
536
+ if (url === '/api/setup') {
537
+ const repoPath = (fields.repoPath ?? '').trim();
538
+ if (!repoPath) {
539
+ res.writeHead(200, { 'Content-Type': 'text/html' });
540
+ return res.end(pageSetup('Repo path is required.'));
541
+ }
542
+ const cfg = {
543
+ repoPath,
544
+ branch: (fields.branch ?? 'main').trim() || 'main',
545
+ remoteName: 'origin',
546
+ pushAfterCommit: fields.push === 'on',
547
+ schedule: SCHEDULES[fields.schedule] ?? SCHEDULES['3x'],
548
+ };
549
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
550
+ if (onConfigSaved) onConfigSaved(cfg);
551
+ return redirect(res, '/');
552
+ }
553
+
554
+ if (url === '/api/settings') {
555
+ if (!config) return redirect(res, '/setup');
556
+ const cfg = {
557
+ ...config,
558
+ repoPath: (fields.repoPath ?? '').trim() || config.repoPath,
559
+ branch: (fields.branch ?? 'main').trim() || 'main',
560
+ pushAfterCommit: fields.push === 'on',
561
+ schedule: SCHEDULES[fields.schedule] ?? config.schedule,
562
+ };
563
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
564
+ if (onConfigSaved) onConfigSaved(cfg);
565
+ res.writeHead(200, { 'Content-Type': 'text/html' });
566
+ return res.end(pageSettings(cfg, '✓ Settings saved and applied.'));
567
+ }
568
+
569
+ if (url === '/api/pick-folder') {
570
+ try {
571
+ const path = await pickFolder();
572
+ res.writeHead(200, { 'Content-Type': 'application/json' });
573
+ return res.end(JSON.stringify({ ok: true, path }));
574
+ } catch (err) {
575
+ res.writeHead(200, { 'Content-Type': 'application/json' });
576
+ return res.end(JSON.stringify({ ok: false, error: err.message }));
577
+ }
578
+ }
579
+
580
+ if (url === '/api/commit-now') {
581
+ if (!config) {
582
+ res.writeHead(400, { 'Content-Type': 'application/json' });
583
+ return res.end(JSON.stringify({ ok: false, error: 'Not configured yet.' }));
584
+ }
585
+ try {
586
+ await runCommit(config);
587
+ res.writeHead(200, { 'Content-Type': 'application/json' });
588
+ const latest = getActivity().commits[0];
589
+ return res.end(JSON.stringify({ ok: true, message: latest?.message ?? 'Committed!' }));
590
+ } catch (err) {
591
+ res.writeHead(200, { 'Content-Type': 'application/json' });
592
+ return res.end(JSON.stringify({ ok: false, error: err.message }));
593
+ }
594
+ }
595
+ }
596
+
597
+ res.writeHead(404);
598
+ res.end('Not found');
599
+ });
600
+
601
+ server.on('error', (err) => {
602
+ if (err.code === 'EADDRINUSE') {
603
+ console.error(`git-cracked is already running — dashboard is at http://localhost:${PORT}`);
604
+ console.error('If you want to restart it, close the other instance first.');
605
+ process.exit(1);
606
+ }
607
+ throw err;
608
+ });
609
+
610
+ server.listen(PORT, '127.0.0.1', () => {
611
+ console.log(`Dashboard: http://localhost:${PORT}`);
612
+ });
613
+ }
614
+
615
+ function redirect(res, to) {
616
+ res.writeHead(302, { Location: to });
617
+ res.end();
618
+ }
619
+
620
+ function readBody(req) {
621
+ return new Promise((resolve) => {
622
+ let data = '';
623
+ req.on('data', chunk => { data += chunk; });
624
+ req.on('end', () => resolve(data));
625
+ });
626
+ }