gitdone-agent 0.5.2 → 0.5.4
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/index.js +932 -863
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,863 +1,932 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// gitdone-agent — scans root folders for git repos and pushes snapshots of the
|
|
3
|
-
// ones the server marks as "tracked" to gitdone.eu.
|
|
4
|
-
//
|
|
5
|
-
// Setup (once):
|
|
6
|
-
// npx gitdone-agent --key=gdo_xxx --root=C:\path\to\projects --install
|
|
7
|
-
//
|
|
8
|
-
// Then pick which discovered repos to track from gitdone.eu/github. Add more
|
|
9
|
-
// roots anytime with --root (repeatable). The running agent reads its config
|
|
10
|
-
// from ~/.gitdone-agent/config.json, so the autostart entry needs no args.
|
|
11
|
-
|
|
12
|
-
import { execSync, spawn } from 'node:child_process'
|
|
13
|
-
import {
|
|
14
|
-
existsSync, writeFileSync, readFileSync, unlinkSync,
|
|
15
|
-
mkdirSync, copyFileSync, appendFileSync, readdirSync,
|
|
16
|
-
} from 'node:fs'
|
|
17
|
-
import { resolve, join } from 'node:path'
|
|
18
|
-
import { homedir, hostname } from 'node:os'
|
|
19
|
-
import { randomUUID } from 'node:crypto'
|
|
20
|
-
|
|
21
|
-
// ─── Stable agent dir, config + logging ────────────────────────────────────────
|
|
22
|
-
// Everything persistent lives here: a stable copy of the agent script (so
|
|
23
|
-
// autostart never points at a purged npx temp dir), the config file (source of
|
|
24
|
-
// truth for the running agent), and a log file (so failures are visible even
|
|
25
|
-
// when the agent runs in a hidden window).
|
|
26
|
-
|
|
27
|
-
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
28
|
-
// Keep in lockstep with packages/agent/package.json "version" AND
|
|
29
|
-
// src/lib/agentVersion.ts LATEST_AGENT_VERSION.
|
|
30
|
-
const AGENT_VERSION = '0.5.
|
|
31
|
-
|
|
32
|
-
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
33
|
-
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
34
|
-
const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
|
|
35
|
-
const LOG_PATH = join(AGENT_DIR, 'agent.log')
|
|
36
|
-
|
|
37
|
-
function ensureAgentDir() {
|
|
38
|
-
if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function log(msg) {
|
|
42
|
-
const line = `[${new Date().toISOString()}] ${msg}`
|
|
43
|
-
console.log(line)
|
|
44
|
-
try { ensureAgentDir(); appendFileSync(LOG_PATH, line + '\n') } catch { /* best-effort */ }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function readConfig() {
|
|
48
|
-
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function writeConfig(cfg) {
|
|
52
|
-
ensureAgentDir()
|
|
53
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
function parseArgs() {
|
|
59
|
-
const argv = process.argv.slice(2)
|
|
60
|
-
const flags = {}
|
|
61
|
-
const roots = []
|
|
62
|
-
for (const a of argv) {
|
|
63
|
-
if (!a.startsWith('--')) continue
|
|
64
|
-
const [k, ...rest] = a.slice(2).split('=')
|
|
65
|
-
const v = rest.join('=')
|
|
66
|
-
if (k === 'root') { if (v) roots.push(resolve(v)) }
|
|
67
|
-
else flags[k] = v
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
key: flags.key,
|
|
71
|
-
roots,
|
|
72
|
-
interval: flags.interval ? Number(flags.interval) : undefined,
|
|
73
|
-
url: flags.url ? flags.url.replace(/\/$/, '') : undefined,
|
|
74
|
-
install: 'install' in flags,
|
|
75
|
-
uninstall: 'uninstall' in flags,
|
|
76
|
-
doctor: 'doctor' in flags,
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Merge CLI args into the persisted config, generating a machineId on first use.
|
|
81
|
-
function buildConfig(args) {
|
|
82
|
-
const existing = readConfig() ?? {}
|
|
83
|
-
const mergedRoots = Array.from(new Set([...(existing.roots ?? []), ...args.roots]))
|
|
84
|
-
return {
|
|
85
|
-
key: args.key ?? existing.key,
|
|
86
|
-
url: args.url ?? existing.url ?? 'https://gitdone.eu',
|
|
87
|
-
interval: args.interval ?? existing.interval ?? 30,
|
|
88
|
-
machineId: existing.machineId ?? randomUUID(),
|
|
89
|
-
hostname: existing.hostname ?? hostname(),
|
|
90
|
-
roots: mergedRoots,
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ─── Windows auto-start ───────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
function getStartupDir() {
|
|
97
|
-
return join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function getVbsPath() {
|
|
101
|
-
return join(getStartupDir(), 'gitdone-agent.vbs')
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function getNodePath() {
|
|
105
|
-
try { return execSync('where node', { encoding: 'utf8' }).trim().split('\n')[0].trim() } catch {}
|
|
106
|
-
try { return execSync('which node', { encoding: 'utf8' }).trim() } catch {}
|
|
107
|
-
return process.execPath
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Locate the Claude Code CLI. Prefer a real executable (claude.exe from the
|
|
111
|
-
// native installer) so spawn works WITHOUT a shell — that lets us pass a
|
|
112
|
-
// multi-line, non-ASCII prompt as a single argv element with no escaping. An
|
|
113
|
-
// npm shim (claude.cmd) needs shell:true, where we collapse the prompt to one
|
|
114
|
-
// line to survive cmd.exe parsing.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
console.log(
|
|
200
|
-
|
|
201
|
-
console.log(`
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
"
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
'
|
|
480
|
-
"
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
" const
|
|
484
|
-
|
|
485
|
-
"
|
|
486
|
-
"
|
|
487
|
-
"
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
'
|
|
540
|
-
'
|
|
541
|
-
'
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text:
|
|
574
|
-
reportCommandResult(cfg, cmd.id, 'error',
|
|
575
|
-
return
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
)
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
})
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
reportCommandResult(cfg, cmd.id, 'error',
|
|
624
|
-
return
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gitdone-agent — scans root folders for git repos and pushes snapshots of the
|
|
3
|
+
// ones the server marks as "tracked" to gitdone.eu.
|
|
4
|
+
//
|
|
5
|
+
// Setup (once):
|
|
6
|
+
// npx gitdone-agent --key=gdo_xxx --root=C:\path\to\projects --install
|
|
7
|
+
//
|
|
8
|
+
// Then pick which discovered repos to track from gitdone.eu/github. Add more
|
|
9
|
+
// roots anytime with --root (repeatable). The running agent reads its config
|
|
10
|
+
// from ~/.gitdone-agent/config.json, so the autostart entry needs no args.
|
|
11
|
+
|
|
12
|
+
import { execSync, spawn } from 'node:child_process'
|
|
13
|
+
import {
|
|
14
|
+
existsSync, writeFileSync, readFileSync, unlinkSync,
|
|
15
|
+
mkdirSync, copyFileSync, appendFileSync, readdirSync,
|
|
16
|
+
} from 'node:fs'
|
|
17
|
+
import { resolve, join } from 'node:path'
|
|
18
|
+
import { homedir, hostname } from 'node:os'
|
|
19
|
+
import { randomUUID } from 'node:crypto'
|
|
20
|
+
|
|
21
|
+
// ─── Stable agent dir, config + logging ────────────────────────────────────────
|
|
22
|
+
// Everything persistent lives here: a stable copy of the agent script (so
|
|
23
|
+
// autostart never points at a purged npx temp dir), the config file (source of
|
|
24
|
+
// truth for the running agent), and a log file (so failures are visible even
|
|
25
|
+
// when the agent runs in a hidden window).
|
|
26
|
+
|
|
27
|
+
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
28
|
+
// Keep in lockstep with packages/agent/package.json "version" AND
|
|
29
|
+
// src/lib/agentVersion.ts LATEST_AGENT_VERSION.
|
|
30
|
+
const AGENT_VERSION = '0.5.4'
|
|
31
|
+
|
|
32
|
+
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
33
|
+
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
34
|
+
const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
|
|
35
|
+
const LOG_PATH = join(AGENT_DIR, 'agent.log')
|
|
36
|
+
|
|
37
|
+
function ensureAgentDir() {
|
|
38
|
+
if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function log(msg) {
|
|
42
|
+
const line = `[${new Date().toISOString()}] ${msg}`
|
|
43
|
+
console.log(line)
|
|
44
|
+
try { ensureAgentDir(); appendFileSync(LOG_PATH, line + '\n') } catch { /* best-effort */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readConfig() {
|
|
48
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeConfig(cfg) {
|
|
52
|
+
ensureAgentDir()
|
|
53
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function parseArgs() {
|
|
59
|
+
const argv = process.argv.slice(2)
|
|
60
|
+
const flags = {}
|
|
61
|
+
const roots = []
|
|
62
|
+
for (const a of argv) {
|
|
63
|
+
if (!a.startsWith('--')) continue
|
|
64
|
+
const [k, ...rest] = a.slice(2).split('=')
|
|
65
|
+
const v = rest.join('=')
|
|
66
|
+
if (k === 'root') { if (v) roots.push(resolve(v)) }
|
|
67
|
+
else flags[k] = v
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
key: flags.key,
|
|
71
|
+
roots,
|
|
72
|
+
interval: flags.interval ? Number(flags.interval) : undefined,
|
|
73
|
+
url: flags.url ? flags.url.replace(/\/$/, '') : undefined,
|
|
74
|
+
install: 'install' in flags,
|
|
75
|
+
uninstall: 'uninstall' in flags,
|
|
76
|
+
doctor: 'doctor' in flags,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Merge CLI args into the persisted config, generating a machineId on first use.
|
|
81
|
+
function buildConfig(args) {
|
|
82
|
+
const existing = readConfig() ?? {}
|
|
83
|
+
const mergedRoots = Array.from(new Set([...(existing.roots ?? []), ...args.roots]))
|
|
84
|
+
return {
|
|
85
|
+
key: args.key ?? existing.key,
|
|
86
|
+
url: args.url ?? existing.url ?? 'https://gitdone.eu',
|
|
87
|
+
interval: args.interval ?? existing.interval ?? 30,
|
|
88
|
+
machineId: existing.machineId ?? randomUUID(),
|
|
89
|
+
hostname: existing.hostname ?? hostname(),
|
|
90
|
+
roots: mergedRoots,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Windows auto-start ───────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function getStartupDir() {
|
|
97
|
+
return join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getVbsPath() {
|
|
101
|
+
return join(getStartupDir(), 'gitdone-agent.vbs')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getNodePath() {
|
|
105
|
+
try { return execSync('where node', { encoding: 'utf8' }).trim().split('\n')[0].trim() } catch {}
|
|
106
|
+
try { return execSync('which node', { encoding: 'utf8' }).trim() } catch {}
|
|
107
|
+
return process.execPath
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Locate the Claude Code CLI. Prefer a real executable (claude.exe from the
|
|
111
|
+
// native installer) so spawn works WITHOUT a shell — that lets us pass a
|
|
112
|
+
// multi-line, non-ASCII prompt as a single argv element with no escaping. An
|
|
113
|
+
// npm shim (claude.cmd) needs shell:true, where we collapse the prompt to one
|
|
114
|
+
// line to survive cmd.exe parsing. Returns `found:false` when nothing was
|
|
115
|
+
// located, so callers can show a clear message instead of spawning bare
|
|
116
|
+
// `claude` and letting cmd.exe emit "'claude' is not recognized…".
|
|
117
|
+
function findClaude() {
|
|
118
|
+
const tryCmd = (c) => {
|
|
119
|
+
try {
|
|
120
|
+
const out = execSync(c, { encoding: 'utf8' }).trim()
|
|
121
|
+
return out ? out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : []
|
|
122
|
+
} catch { return [] }
|
|
123
|
+
}
|
|
124
|
+
const cands = [...tryCmd('where claude'), ...tryCmd('which claude')]
|
|
125
|
+
|
|
126
|
+
// The agent is auto-started at login, so its PATH is frozen at that moment —
|
|
127
|
+
// a `claude` installed (or a PATH entry added) afterwards is invisible to
|
|
128
|
+
// `where`/`which` above. Probe the well-known install locations directly so a
|
|
129
|
+
// freshly-installed CLI works without a Windows re-login.
|
|
130
|
+
const home = homedir()
|
|
131
|
+
const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming')
|
|
132
|
+
for (const p of [
|
|
133
|
+
join(home, '.local', 'bin', 'claude.exe'), // Windows native installer
|
|
134
|
+
join(appData, 'npm', 'claude.cmd'), // Windows npm global shim
|
|
135
|
+
join(home, '.local', 'bin', 'claude'), // macOS/Linux native installer
|
|
136
|
+
'/usr/local/bin/claude', // Homebrew (Intel) / manual install
|
|
137
|
+
'/opt/homebrew/bin/claude', // Homebrew (Apple silicon)
|
|
138
|
+
]) {
|
|
139
|
+
if (existsSync(p) && !cands.includes(p)) cands.push(p)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const exe = cands.find((p) => /\.exe$/i.test(p))
|
|
143
|
+
if (exe) return { path: exe, shell: false, found: true }
|
|
144
|
+
if (cands.length) return { path: cands[0], shell: true, found: true }
|
|
145
|
+
return { path: 'claude', shell: true, found: false }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Kill any previously-running agent processes so an update applies WITHOUT a
|
|
149
|
+
// Windows re-login. Targets node processes launched from the agent dir
|
|
150
|
+
// (~/.gitdone-agent), excluding ourselves — the npx installer runs from the
|
|
151
|
+
// npx cache, so it won't match. Best-effort; failures are non-fatal.
|
|
152
|
+
function stopRunningAgents() {
|
|
153
|
+
const ps = [
|
|
154
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
155
|
+
"Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" |",
|
|
156
|
+
` Where-Object { $_.CommandLine -like '*.gitdone-agent*' -and $_.ProcessId -ne ${process.pid} } |`,
|
|
157
|
+
' ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
|
|
158
|
+
].join('\n')
|
|
159
|
+
try {
|
|
160
|
+
ensureAgentDir()
|
|
161
|
+
const scriptPath = join(AGENT_DIR, 'stop-agents.ps1')
|
|
162
|
+
writeFileSync(scriptPath, ps, 'utf8')
|
|
163
|
+
execSync(`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`, { stdio: 'ignore' })
|
|
164
|
+
} catch { /* best-effort — at worst the old agent lingers until next login */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function installStartup() {
|
|
168
|
+
ensureAgentDir()
|
|
169
|
+
const nodePath = getNodePath()
|
|
170
|
+
|
|
171
|
+
// Stop the previously-running agent(s) first so we don't end up with the old
|
|
172
|
+
// build still reporting alongside the new one until the next Windows login.
|
|
173
|
+
stopRunningAgents()
|
|
174
|
+
|
|
175
|
+
// Copy this script to a stable location. process.argv[1] may point inside an
|
|
176
|
+
// npx cache dir that gets purged later — referencing it from autostart would
|
|
177
|
+
// silently break on the next login. A stable copy survives.
|
|
178
|
+
try {
|
|
179
|
+
copyFileSync(resolve(process.argv[1]), STABLE_AGENT)
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.error(`Warning: could not copy agent to ${STABLE_AGENT}: ${err.message}`)
|
|
182
|
+
}
|
|
183
|
+
const agentPath = existsSync(STABLE_AGENT) ? STABLE_AGENT : resolve(process.argv[1])
|
|
184
|
+
|
|
185
|
+
// The running agent reads everything from config.json — no args needed.
|
|
186
|
+
// VBScript runs it silently so no console window pops up on login.
|
|
187
|
+
const vbs = [
|
|
188
|
+
'Set WshShell = CreateObject("WScript.Shell")',
|
|
189
|
+
`WshShell.Run """${nodePath}"" ""${agentPath}""", 0, False`,
|
|
190
|
+
].join('\r\n')
|
|
191
|
+
|
|
192
|
+
writeFileSync(getVbsPath(), vbs, 'utf8')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function uninstallStartup() {
|
|
196
|
+
const vbsPath = getVbsPath()
|
|
197
|
+
if (existsSync(vbsPath)) {
|
|
198
|
+
unlinkSync(vbsPath)
|
|
199
|
+
console.log(`✓ Премахнат автостарт за gitdone-agent`)
|
|
200
|
+
} else {
|
|
201
|
+
console.log(`Не е намерен автостарт (${vbsPath})`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Doctor ────────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
function runDoctor() {
|
|
208
|
+
const cfg = readConfig()
|
|
209
|
+
console.log('gitdone-agent doctor')
|
|
210
|
+
console.log(` node : ${getNodePath()}`)
|
|
211
|
+
console.log(` agent dir : ${AGENT_DIR} ${existsSync(AGENT_DIR) ? '(ok)' : '(MISSING)'}`)
|
|
212
|
+
console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
|
|
213
|
+
console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
|
|
214
|
+
console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
|
|
215
|
+
console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
|
|
216
|
+
const claude = findClaude()
|
|
217
|
+
console.log(` claude cli : ${claude.found ? claude.path : 'NOT FOUND — инсталирай от claude.ai/code и рестартирай агента'}`)
|
|
218
|
+
if (cfg) {
|
|
219
|
+
console.log(` machineId : ${cfg.machineId}`)
|
|
220
|
+
console.log(` server : ${cfg.url}`)
|
|
221
|
+
console.log(` interval : ${cfg.interval}s`)
|
|
222
|
+
console.log(` roots :`)
|
|
223
|
+
for (const r of cfg.roots ?? []) console.log(` - ${r} ${existsSync(r) ? '' : '(MISSING)'}`)
|
|
224
|
+
if (cfg.roots?.length) {
|
|
225
|
+
const found = scanRepos(cfg.roots)
|
|
226
|
+
console.log(` found repos : ${found.length}`)
|
|
227
|
+
for (const r of found) console.log(` - ${r.name} (${r.path})`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Git helpers ─────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function git(cmd, cwd) {
|
|
235
|
+
try {
|
|
236
|
+
return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
237
|
+
} catch {
|
|
238
|
+
return ''
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Like git(), but surfaces failures (with stderr) instead of swallowing them —
|
|
243
|
+
// used for push/pull where we need to detect auth rejection.
|
|
244
|
+
function gitTry(cmd, cwd) {
|
|
245
|
+
try {
|
|
246
|
+
const out = execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
247
|
+
return { ok: true, out }
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const out = (err.stderr || err.stdout || err.message || '').toString().trim()
|
|
250
|
+
return { ok: false, out }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Never let a token reach the log file or the server's command-result.
|
|
255
|
+
function redact(text, token) {
|
|
256
|
+
if (!text || !token) return text
|
|
257
|
+
return text.split(token).join('***')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// https://x-access-token:<token>@github.com/<owner>/<repo>.git — an ad-hoc URL
|
|
261
|
+
// passed straight to push/pull, so it never gets written into .git/config.
|
|
262
|
+
function authUrl(auth) {
|
|
263
|
+
const repo = auth.repo.replace(/\.git$/i, '')
|
|
264
|
+
return `https://x-access-token:${auth.token}@github.com/${repo}.git`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Push/pull using the server-issued token when we have one; otherwise fall back
|
|
268
|
+
// to the machine's own git credentials (old behaviour). Throws on failure.
|
|
269
|
+
function pushOrPull(type, repoPath, auth) {
|
|
270
|
+
const branch = git('git branch --show-current', repoPath) || 'HEAD'
|
|
271
|
+
let cmd
|
|
272
|
+
if (auth) {
|
|
273
|
+
const url = authUrl(auth)
|
|
274
|
+
cmd = type === 'push' ? `git push "${url}" HEAD:${branch}` : `git pull "${url}" ${branch}`
|
|
275
|
+
} else {
|
|
276
|
+
cmd = type === 'push' ? 'git push' : 'git pull'
|
|
277
|
+
}
|
|
278
|
+
const r = gitTry(cmd, repoPath)
|
|
279
|
+
if (!r.ok) throw new Error(r.out || `${type} failed`)
|
|
280
|
+
return r.out || (type === 'push' ? 'pushed' : 'pulled')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
|
|
284
|
+
|
|
285
|
+
// Discard all local changes to a SINGLE file, so it disappears from the repo's
|
|
286
|
+
// "changes". Mirrors GitHub Desktop's "Discard changes": unstage first, then
|
|
287
|
+
// either restore the file from HEAD (tracked) or delete it (new/untracked).
|
|
288
|
+
function discardFile(repoPath, file) {
|
|
289
|
+
if (!file) throw new Error('no file')
|
|
290
|
+
// Unstage so index + worktree get reverted together (no-op if not staged).
|
|
291
|
+
gitTry(`git reset -q HEAD -- "${file}"`, repoPath)
|
|
292
|
+
// Does the file exist in the last commit? If so we restore its content; if
|
|
293
|
+
// not, it's a newly-added/untracked file and discarding means removing it.
|
|
294
|
+
const inHead = gitTry(`git cat-file -e "HEAD:${file}"`, repoPath).ok
|
|
295
|
+
if (inHead) {
|
|
296
|
+
const r = gitTry(`git checkout HEAD -- "${file}"`, repoPath)
|
|
297
|
+
if (!r.ok) throw new Error(r.out || 'checkout failed')
|
|
298
|
+
return 'discarded'
|
|
299
|
+
}
|
|
300
|
+
// New/untracked file (now unstaged) — drop it from the working tree.
|
|
301
|
+
const r = gitTry(`git clean -fdq -- "${file}"`, repoPath)
|
|
302
|
+
if (!r.ok) throw new Error(r.out || 'clean failed')
|
|
303
|
+
return 'discarded (new file removed)'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function parseDiffByFile(diffOutput) {
|
|
307
|
+
const files = {}
|
|
308
|
+
if (!diffOutput) return files
|
|
309
|
+
const sections = diffOutput.split(/(?=^diff --git )/m)
|
|
310
|
+
for (const section of sections) {
|
|
311
|
+
if (!section.trim()) continue
|
|
312
|
+
const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
|
|
313
|
+
if (match) files[match[1]] = section
|
|
314
|
+
}
|
|
315
|
+
return files
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getSnapshot(repoPath) {
|
|
319
|
+
const branch = git('git branch --show-current', repoPath) || 'HEAD'
|
|
320
|
+
|
|
321
|
+
const statusLines = git('git status --porcelain', repoPath).split('\n').filter(Boolean)
|
|
322
|
+
const modified = []
|
|
323
|
+
const staged = []
|
|
324
|
+
const statuses = {}
|
|
325
|
+
for (const line of statusLines) {
|
|
326
|
+
const xy = line.slice(0, 2)
|
|
327
|
+
const file = line.slice(3)
|
|
328
|
+
statuses[file] = xy
|
|
329
|
+
if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
|
|
330
|
+
if (xy[1] !== ' ') modified.push(file)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const aheadBy = parseInt(git('git rev-list --count @{u}..HEAD', repoPath), 10) || 0
|
|
334
|
+
const behindBy = parseInt(git('git rev-list --count HEAD..@{u}', repoPath), 10) || 0
|
|
335
|
+
|
|
336
|
+
const logLine = git('git log -1 --pretty=format:%H|%s|%an|%aI', repoPath)
|
|
337
|
+
let lastCommit = null
|
|
338
|
+
if (logLine) {
|
|
339
|
+
const [sha, message, author, date] = logLine.split('|')
|
|
340
|
+
lastCommit = { sha, message, author, date }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const diffs = parseDiffByFile(git('git diff HEAD', repoPath))
|
|
344
|
+
|
|
345
|
+
// Origin remote → lets the server auto-detect the GitHub repo for the History tab.
|
|
346
|
+
const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
|
|
347
|
+
|
|
348
|
+
return { branch, modified, staged, aheadBy, behindBy, lastCommit, statuses, diffs, remoteUrl }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Repo discovery ─────────────────────────────────────────────────────────────
|
|
352
|
+
// Walk each root looking for directories that contain a `.git` entry. Stop
|
|
353
|
+
// descending once a repo is found (don't recurse into submodules/nested repos),
|
|
354
|
+
// skip noisy dirs, and cap depth so a huge tree can't hang a tick.
|
|
355
|
+
|
|
356
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', '.cache', 'vendor', '.venv', '__pycache__'])
|
|
357
|
+
|
|
358
|
+
function scanRepos(roots, maxDepth = 5) {
|
|
359
|
+
const found = []
|
|
360
|
+
const seen = new Set()
|
|
361
|
+
|
|
362
|
+
function walk(dir, depth) {
|
|
363
|
+
if (depth > maxDepth) return
|
|
364
|
+
let entries
|
|
365
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
366
|
+
|
|
367
|
+
if (entries.some((e) => e.name === '.git')) {
|
|
368
|
+
const path = resolve(dir)
|
|
369
|
+
if (!seen.has(path)) {
|
|
370
|
+
seen.add(path)
|
|
371
|
+
found.push({ name: path.split(/[\\/]/).pop() ?? path, path })
|
|
372
|
+
}
|
|
373
|
+
return // a repo — don't descend further
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const e of entries) {
|
|
377
|
+
if (!e.isDirectory()) continue
|
|
378
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
|
|
379
|
+
walk(join(dir, e.name), depth + 1)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const root of roots) walk(resolve(root), 0)
|
|
384
|
+
return found
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Server sync + commands ─────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
async function api(cfg, path, body) {
|
|
390
|
+
const res = await fetch(`${cfg.url}${path}`, {
|
|
391
|
+
method: 'POST',
|
|
392
|
+
headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
|
|
393
|
+
body: JSON.stringify(body),
|
|
394
|
+
})
|
|
395
|
+
if (!res.ok) {
|
|
396
|
+
const text = await res.text().catch(() => '')
|
|
397
|
+
throw new Error(`HTTP ${res.status} ${path}: ${text}`)
|
|
398
|
+
}
|
|
399
|
+
return res.json().catch(() => ({}))
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function reportCommandResult(cfg, id, status, result) {
|
|
403
|
+
await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Post a batch of console events (and optional lifecycle status) for an AiRun.
|
|
407
|
+
async function postRunEvents(cfg, runId, events, status, result) {
|
|
408
|
+
await api(cfg, '/api/v1/agent/ai-run/events', {
|
|
409
|
+
runId,
|
|
410
|
+
events,
|
|
411
|
+
...(status ? { status } : {}),
|
|
412
|
+
...(result !== undefined ? { result } : {}),
|
|
413
|
+
}).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Post transcript lines (and optional turn status / Claude session id) for an
|
|
417
|
+
// interactive AiSession chat turn.
|
|
418
|
+
async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId) {
|
|
419
|
+
await api(cfg, '/api/v1/agent/ai-session/events', {
|
|
420
|
+
sessionId,
|
|
421
|
+
events,
|
|
422
|
+
...(status ? { status } : {}),
|
|
423
|
+
...(claudeSessionId ? { claudeSessionId } : {}),
|
|
424
|
+
}).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Turn one stream-json line from `claude -p` into console events. Phase 1 shows
|
|
428
|
+
// what the model writes (TEXT) and which tools it calls (TOOL_CALL); raw tool
|
|
429
|
+
// results (Bash stdout etc.) come in Phase 2 via a PostToolUse hook.
|
|
430
|
+
function parseStreamLine(line, push, onInit) {
|
|
431
|
+
let ev
|
|
432
|
+
try { ev = JSON.parse(line) } catch { return }
|
|
433
|
+
if (ev.type === 'system') {
|
|
434
|
+
if (ev.subtype === 'init') {
|
|
435
|
+
push('SYSTEM', `Сесия стартирана${ev.model ? ` (${ev.model})` : ''}.`)
|
|
436
|
+
// Surface Claude's own session id so chat sessions can --resume it.
|
|
437
|
+
if (ev.session_id && typeof onInit === 'function') onInit(ev.session_id)
|
|
438
|
+
}
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (ev.type === 'assistant' && ev.message?.content) {
|
|
442
|
+
for (const block of ev.message.content) {
|
|
443
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
444
|
+
push('TEXT', block.text.trim())
|
|
445
|
+
} else if (block.type === 'tool_use') {
|
|
446
|
+
const input = block.input ? JSON.stringify(block.input) : ''
|
|
447
|
+
push('TOOL_CALL', `${block.name}${input ? ` ${input.slice(0, 400)}` : ''}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
if (ev.type === 'result' && ev.subtype && ev.subtype !== 'success') {
|
|
453
|
+
push('SYSTEM', `Резултат: ${ev.subtype}`)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Phase 2: write (once) a PostToolUse hook that forwards each tool's raw output
|
|
458
|
+
// (Bash stdout, edit results, …) to the run's console, plus the settings file
|
|
459
|
+
// that registers it. The hook reads GITDONE_* from its env (set per run on the
|
|
460
|
+
// claude process). Returns the settings path to pass via --settings.
|
|
461
|
+
const AI_HOOK_SCRIPT = join(AGENT_DIR, 'ai-run-hook.mjs')
|
|
462
|
+
const AI_SETTINGS_PATH = join(AGENT_DIR, 'ai-run-settings.json')
|
|
463
|
+
|
|
464
|
+
function ensureAiHookFiles() {
|
|
465
|
+
ensureAgentDir()
|
|
466
|
+
const hookSrc = [
|
|
467
|
+
"import process from 'node:process'",
|
|
468
|
+
"let data = ''",
|
|
469
|
+
"process.stdin.setEncoding('utf8')",
|
|
470
|
+
"process.stdin.on('data', (c) => { data += c })",
|
|
471
|
+
'process.stdin.on(\'end\', async () => {',
|
|
472
|
+
' try {',
|
|
473
|
+
" const ev = JSON.parse(data || '{}')",
|
|
474
|
+
' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, runId = process.env.GITDONE_RUN_ID',
|
|
475
|
+
' if (url && key && runId) {',
|
|
476
|
+
" const name = ev.tool_name || 'tool'",
|
|
477
|
+
' const o = ev.tool_output',
|
|
478
|
+
" let out = ''",
|
|
479
|
+
" if (typeof o === 'string') out = o",
|
|
480
|
+
" else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
|
|
481
|
+
" out = String(out || '').slice(0, 2000)",
|
|
482
|
+
" const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
|
|
483
|
+
" const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
|
|
484
|
+
" await fetch(url + '/api/v1/agent/ai-run/events', {",
|
|
485
|
+
" method: 'POST',",
|
|
486
|
+
" headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
|
|
487
|
+
" body: JSON.stringify({ runId, events: [{ kind: 'TOOL_RESULT', text }] }),",
|
|
488
|
+
' }).catch(() => {})',
|
|
489
|
+
' }',
|
|
490
|
+
' } catch {}',
|
|
491
|
+
' process.exit(0)',
|
|
492
|
+
'})',
|
|
493
|
+
].join('\n')
|
|
494
|
+
writeFileSync(AI_HOOK_SCRIPT, hookSrc, 'utf8')
|
|
495
|
+
|
|
496
|
+
const nodePath = getNodePath()
|
|
497
|
+
const settings = {
|
|
498
|
+
hooks: {
|
|
499
|
+
PostToolUse: [
|
|
500
|
+
{ matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_HOOK_SCRIPT}"` }] },
|
|
501
|
+
],
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
|
|
505
|
+
return AI_SETTINGS_PATH
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Same PostToolUse hook, for interactive chat sessions: posts raw tool output to
|
|
509
|
+
// the session endpoint, reading GITDONE_SESSION_ID from the per-turn env.
|
|
510
|
+
const AI_SESSION_HOOK_SCRIPT = join(AGENT_DIR, 'ai-session-hook.mjs')
|
|
511
|
+
const AI_SESSION_SETTINGS_PATH = join(AGENT_DIR, 'ai-session-settings.json')
|
|
512
|
+
|
|
513
|
+
function ensureAiSessionHookFiles() {
|
|
514
|
+
ensureAgentDir()
|
|
515
|
+
const hookSrc = [
|
|
516
|
+
"import process from 'node:process'",
|
|
517
|
+
"let data = ''",
|
|
518
|
+
"process.stdin.setEncoding('utf8')",
|
|
519
|
+
"process.stdin.on('data', (c) => { data += c })",
|
|
520
|
+
'process.stdin.on(\'end\', async () => {',
|
|
521
|
+
' try {',
|
|
522
|
+
" const ev = JSON.parse(data || '{}')",
|
|
523
|
+
' const url = process.env.GITDONE_URL, key = process.env.GITDONE_KEY, sessionId = process.env.GITDONE_SESSION_ID',
|
|
524
|
+
' if (url && key && sessionId) {',
|
|
525
|
+
" const name = ev.tool_name || 'tool'",
|
|
526
|
+
' const o = ev.tool_output',
|
|
527
|
+
" let out = ''",
|
|
528
|
+
" if (typeof o === 'string') out = o",
|
|
529
|
+
" else if (o && typeof o === 'object') out = o.text || o.stdout || o.content || JSON.stringify(o)",
|
|
530
|
+
" out = String(out || '').slice(0, 2000)",
|
|
531
|
+
" const inp = ev.tool_input ? JSON.stringify(ev.tool_input).slice(0, 300) : ''",
|
|
532
|
+
" const text = name + (inp ? ' ' + inp : '') + (out ? '\\n' + out : ' ✓')",
|
|
533
|
+
" await fetch(url + '/api/v1/agent/ai-session/events', {",
|
|
534
|
+
" method: 'POST',",
|
|
535
|
+
" headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },",
|
|
536
|
+
" body: JSON.stringify({ sessionId, events: [{ role: 'TOOL_RESULT', text }] }),",
|
|
537
|
+
' }).catch(() => {})',
|
|
538
|
+
' }',
|
|
539
|
+
' } catch {}',
|
|
540
|
+
' process.exit(0)',
|
|
541
|
+
'})',
|
|
542
|
+
].join('\n')
|
|
543
|
+
writeFileSync(AI_SESSION_HOOK_SCRIPT, hookSrc, 'utf8')
|
|
544
|
+
|
|
545
|
+
const nodePath = getNodePath()
|
|
546
|
+
const settings = {
|
|
547
|
+
hooks: {
|
|
548
|
+
PostToolUse: [
|
|
549
|
+
{ matcher: '', hooks: [{ type: 'command', command: `"${nodePath}" "${AI_SESSION_HOOK_SCRIPT}"` }] },
|
|
550
|
+
],
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
writeFileSync(AI_SESSION_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8')
|
|
554
|
+
return AI_SESSION_SETTINGS_PATH
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Run a headless Claude Code session for an `ai_run` command and stream its
|
|
558
|
+
// output back. Long-running and fire-and-forget: it wires up async handlers and
|
|
559
|
+
// returns immediately so the agent's snapshot loop is never blocked.
|
|
560
|
+
function runAiCommand(cfg, cmd, repoPath) {
|
|
561
|
+
const runId = cmd.payload?.runId
|
|
562
|
+
const prompt = cmd.payload?.prompt ?? ''
|
|
563
|
+
const allowCommit = cmd.payload?.allowCommit === true
|
|
564
|
+
if (!runId || !prompt) {
|
|
565
|
+
reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_run payload')
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const { path: claudePath, shell, found } = findClaude()
|
|
570
|
+
if (!found) {
|
|
571
|
+
const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
|
|
572
|
+
log(`✗ ai_run ${runId}: claude not found`)
|
|
573
|
+
postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `✗ ${msg}` }], 'error', 'claude not found')
|
|
574
|
+
reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let settingsPath
|
|
579
|
+
try { settingsPath = ensureAiHookFiles() } catch (e) { log(`✗ ai hook setup failed: ${e.message}`) }
|
|
580
|
+
|
|
581
|
+
// The prompt is delivered on STDIN, not as a `-p <arg>` command-line value.
|
|
582
|
+
// A multi-line, non-ASCII (Cyrillic) prompt passed as an argv element gets
|
|
583
|
+
// mangled under the claude.cmd npm shim (shell:true → cmd.exe splits/strips
|
|
584
|
+
// it), so Claude received no prompt and fell back to an empty stdin
|
|
585
|
+
// ("no stdin data received…"), then ran blind off whatever was in the repo.
|
|
586
|
+
// Piping it to stdin is robust for both the native exe and the shim.
|
|
587
|
+
const args = [
|
|
588
|
+
'-p',
|
|
589
|
+
'--output-format', 'stream-json',
|
|
590
|
+
'--verbose',
|
|
591
|
+
'--permission-mode', 'acceptEdits',
|
|
592
|
+
'--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
|
|
593
|
+
// Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
|
|
594
|
+
...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
|
|
595
|
+
...(settingsPath ? ['--settings', settingsPath] : []),
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
// The PostToolUse hook reads these from its env to post raw tool output.
|
|
599
|
+
const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_RUN_ID: runId }
|
|
600
|
+
|
|
601
|
+
// Batch events on a timer so we don't hammer the server per token/line.
|
|
602
|
+
let pending = []
|
|
603
|
+
let flushing = false
|
|
604
|
+
const flush = async () => {
|
|
605
|
+
if (flushing || pending.length === 0) return
|
|
606
|
+
flushing = true
|
|
607
|
+
const batch = pending; pending = []
|
|
608
|
+
await postRunEvents(cfg, runId, batch)
|
|
609
|
+
flushing = false
|
|
610
|
+
}
|
|
611
|
+
const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
|
|
612
|
+
const timer = setInterval(flush, 800)
|
|
613
|
+
|
|
614
|
+
log(`▶ ai_run ${runId} @ ${repoPath} via ${claudePath}`)
|
|
615
|
+
postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Стартиране на Claude Code в ${repoPath}…` }], 'running')
|
|
616
|
+
|
|
617
|
+
let child
|
|
618
|
+
try {
|
|
619
|
+
child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
|
|
620
|
+
} catch (err) {
|
|
621
|
+
clearInterval(timer)
|
|
622
|
+
postRunEvents(cfg, runId, [{ kind: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error', err.message)
|
|
623
|
+
reportCommandResult(cfg, cmd.id, 'error', err.message)
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Write the prompt to stdin and close it so Claude reads it as the task.
|
|
628
|
+
// Swallow EPIPE in case the process exits before we finish writing.
|
|
629
|
+
if (child.stdin) {
|
|
630
|
+
child.stdin.on('error', () => {})
|
|
631
|
+
try { child.stdin.write(prompt); child.stdin.end() }
|
|
632
|
+
catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let buf = ''
|
|
636
|
+
child.stdout.on('data', (d) => {
|
|
637
|
+
buf += d.toString()
|
|
638
|
+
let nl
|
|
639
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
640
|
+
const line = buf.slice(0, nl).trim()
|
|
641
|
+
buf = buf.slice(nl + 1)
|
|
642
|
+
if (line) parseStreamLine(line, push)
|
|
643
|
+
}
|
|
644
|
+
})
|
|
645
|
+
child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
|
|
646
|
+
child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
|
|
647
|
+
child.on('close', async (code) => {
|
|
648
|
+
clearInterval(timer)
|
|
649
|
+
if (buf.trim()) parseStreamLine(buf.trim(), push)
|
|
650
|
+
await flush()
|
|
651
|
+
const ok = code === 0
|
|
652
|
+
await postRunEvents(
|
|
653
|
+
cfg, runId,
|
|
654
|
+
[{ kind: 'SYSTEM', text: ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.` }],
|
|
655
|
+
ok ? 'done' : 'error',
|
|
656
|
+
ok ? 'ok' : `exit ${code}`,
|
|
657
|
+
)
|
|
658
|
+
reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
|
|
659
|
+
log(`■ ai_run ${runId} приключи (code ${code})`)
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Run one turn of an interactive chat session: `claude -p` (resuming the prior
|
|
664
|
+
// Claude session when known) with the user's message on stdin, streaming the
|
|
665
|
+
// reply back to the session console. Long-running + fire-and-forget like ai_run.
|
|
666
|
+
function runAiChat(cfg, cmd, repoPath) {
|
|
667
|
+
const sessionId = cmd.payload?.sessionId
|
|
668
|
+
const prompt = cmd.payload?.prompt ?? ''
|
|
669
|
+
const claudeSessionId = cmd.payload?.claudeSessionId || null
|
|
670
|
+
const allowCommit = cmd.payload?.allowCommit === true
|
|
671
|
+
if (!sessionId || !prompt) {
|
|
672
|
+
reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const { path: claudePath, shell, found } = findClaude()
|
|
677
|
+
if (!found) {
|
|
678
|
+
const msg = `Claude Code CLI не е намерен на този компютър (${cfg.hostname}). Инсталирай го от claude.ai/code и се увери, че „claude" е в PATH, после рестартирай агента.`
|
|
679
|
+
log(`✗ ai_chat ${sessionId}: claude not found`)
|
|
680
|
+
postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `✗ ${msg}` }], 'error')
|
|
681
|
+
reportCommandResult(cfg, cmd.id, 'error', 'claude not found')
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
let settingsPath
|
|
686
|
+
try { settingsPath = ensureAiSessionHookFiles() } catch (e) { log(`✗ ai session hook setup failed: ${e.message}`) }
|
|
687
|
+
|
|
688
|
+
const args = [
|
|
689
|
+
'-p',
|
|
690
|
+
'--output-format', 'stream-json',
|
|
691
|
+
'--verbose',
|
|
692
|
+
'--permission-mode', 'acceptEdits',
|
|
693
|
+
'--allowedTools', 'Read,Edit,Write,Bash,mcp__gitdone__*',
|
|
694
|
+
// Block git commit/push unless this repo opted in (per-repo aiAutoCommit).
|
|
695
|
+
...(allowCommit ? [] : ['--disallowedTools', 'Bash(git commit *),Bash(git push *)']),
|
|
696
|
+
...(claudeSessionId ? ['--resume', claudeSessionId] : []),
|
|
697
|
+
...(settingsPath ? ['--settings', settingsPath] : []),
|
|
698
|
+
]
|
|
699
|
+
|
|
700
|
+
const childEnv = { ...process.env, GITDONE_URL: cfg.url, GITDONE_KEY: cfg.key, GITDONE_SESSION_ID: sessionId }
|
|
701
|
+
|
|
702
|
+
// Map console kinds → session transcript roles (model text → ASSISTANT).
|
|
703
|
+
const roleFor = (kind) => (kind === 'TEXT' ? 'ASSISTANT' : kind)
|
|
704
|
+
let pending = []
|
|
705
|
+
let capturedSession = null // Claude's session id, sent (idempotently) for --resume
|
|
706
|
+
let flushing = false
|
|
707
|
+
const flush = async () => {
|
|
708
|
+
if (flushing || pending.length === 0) return
|
|
709
|
+
flushing = true
|
|
710
|
+
const batch = pending.map((e) => ({ role: roleFor(e.kind), text: e.text })); pending = []
|
|
711
|
+
await postSessionEvents(cfg, sessionId, batch, 'running', capturedSession || undefined)
|
|
712
|
+
flushing = false
|
|
713
|
+
}
|
|
714
|
+
const push = (kind, text) => { if (text != null && String(text) !== '') pending.push({ kind, text: String(text) }) }
|
|
715
|
+
const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
|
|
716
|
+
const timer = setInterval(flush, 800)
|
|
717
|
+
|
|
718
|
+
log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}) via ${claudePath}`)
|
|
719
|
+
|
|
720
|
+
let child
|
|
721
|
+
try {
|
|
722
|
+
child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
|
|
723
|
+
} catch (err) {
|
|
724
|
+
clearInterval(timer)
|
|
725
|
+
postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
|
|
726
|
+
reportCommandResult(cfg, cmd.id, 'error', err.message)
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (child.stdin) {
|
|
731
|
+
child.stdin.on('error', () => {})
|
|
732
|
+
try { child.stdin.write(prompt); child.stdin.end() }
|
|
733
|
+
catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
let buf = ''
|
|
737
|
+
child.stdout.on('data', (d) => {
|
|
738
|
+
buf += d.toString()
|
|
739
|
+
let nl
|
|
740
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
741
|
+
const line = buf.slice(0, nl).trim()
|
|
742
|
+
buf = buf.slice(nl + 1)
|
|
743
|
+
if (line) parseStreamLine(line, push, onInit)
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
child.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) push('SYSTEM', s) })
|
|
747
|
+
child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
|
|
748
|
+
child.on('close', async (code) => {
|
|
749
|
+
clearInterval(timer)
|
|
750
|
+
if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
|
|
751
|
+
await flush()
|
|
752
|
+
const ok = code === 0
|
|
753
|
+
await postSessionEvents(
|
|
754
|
+
cfg, sessionId,
|
|
755
|
+
ok ? [] : [{ role: 'SYSTEM', text: `✗ Ходът приключи с код ${code}.` }],
|
|
756
|
+
ok ? 'idle' : 'error',
|
|
757
|
+
capturedSession || undefined,
|
|
758
|
+
)
|
|
759
|
+
reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}`)
|
|
760
|
+
log(`■ ai_chat ${sessionId} приключи (code ${code})`)
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function executeCommand(cfg, cmd, repoPath) {
|
|
765
|
+
// ai_run is long-running + streaming — launch it in the background and return
|
|
766
|
+
// so the snapshot loop keeps going. It reports its own result on exit.
|
|
767
|
+
if (cmd.type === 'ai_run') {
|
|
768
|
+
runAiCommand(cfg, cmd, repoPath)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
// ai_chat — one interactive turn, same fire-and-forget model.
|
|
772
|
+
if (cmd.type === 'ai_chat') {
|
|
773
|
+
runAiChat(cfg, cmd, repoPath)
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
let result = 'ok'
|
|
778
|
+
let status = 'done'
|
|
779
|
+
const auth = cfg.auth?.[repoPath] ?? null
|
|
780
|
+
try {
|
|
781
|
+
if (cmd.type === 'commit') {
|
|
782
|
+
const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
|
|
783
|
+
// When the UI sends a list of selected files, stage ONLY those so the
|
|
784
|
+
// resulting commit (and the push that follows) contains just the checked
|
|
785
|
+
// files. No list → stage everything (old behaviour / older UIs).
|
|
786
|
+
const files = Array.isArray(cmd.payload?.files)
|
|
787
|
+
? cmd.payload.files.filter((f) => typeof f === 'string' && f.trim())
|
|
788
|
+
: []
|
|
789
|
+
if (files.length) {
|
|
790
|
+
const quoted = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ')
|
|
791
|
+
git(`git add -- ${quoted}`, repoPath)
|
|
792
|
+
} else {
|
|
793
|
+
git('git add -A', repoPath)
|
|
794
|
+
}
|
|
795
|
+
result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
|
|
796
|
+
} else if (cmd.type === 'push' || cmd.type === 'pull') {
|
|
797
|
+
result = pushOrPull(cmd.type, repoPath, auth)
|
|
798
|
+
} else if (cmd.type === 'discard') {
|
|
799
|
+
result = discardFile(repoPath, cmd.payload?.file)
|
|
800
|
+
}
|
|
801
|
+
log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
|
|
802
|
+
} catch (err) {
|
|
803
|
+
status = 'error'
|
|
804
|
+
result = redact(err.message, auth?.token)
|
|
805
|
+
// Cached token rejected (e.g. rotated/expired on the server) → drop it so
|
|
806
|
+
// the next snapshot reports hasGithubAuth:false and the server reissues one.
|
|
807
|
+
if (auth && AUTH_FAIL.test(err.message)) {
|
|
808
|
+
delete cfg.auth[repoPath]
|
|
809
|
+
writeConfig(cfg)
|
|
810
|
+
result += ' — токенът е изчистен, пробвай командата пак'
|
|
811
|
+
}
|
|
812
|
+
log(`✗ ${cmd.type} failed @ ${repoPath}: ${result}`)
|
|
813
|
+
}
|
|
814
|
+
await reportCommandResult(cfg, cmd.id, status, result)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Report discovered repos + machine info, get back which repos to track.
|
|
818
|
+
async function sync(cfg, discovered) {
|
|
819
|
+
const data = await api(cfg, '/api/v1/agent/sync', {
|
|
820
|
+
machineId: cfg.machineId,
|
|
821
|
+
hostname: cfg.hostname,
|
|
822
|
+
agentVersion: AGENT_VERSION,
|
|
823
|
+
roots: cfg.roots,
|
|
824
|
+
repos: discovered,
|
|
825
|
+
})
|
|
826
|
+
return data.tracked ?? []
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Push one tracked repo's snapshot and run any pending commands for it.
|
|
830
|
+
async function pushSnapshot(cfg, repo) {
|
|
831
|
+
const snapshot = getSnapshot(repo.path)
|
|
832
|
+
const data = await api(cfg, '/api/v1/agent/snapshot', {
|
|
833
|
+
machineId: cfg.machineId,
|
|
834
|
+
path: repo.path,
|
|
835
|
+
repoName: repo.name,
|
|
836
|
+
// Tells the server whether we already hold a push token for this repo.
|
|
837
|
+
// Always a boolean → marks us as a token-capable agent.
|
|
838
|
+
hasGithubAuth: !!cfg.auth?.[repo.path],
|
|
839
|
+
...snapshot,
|
|
840
|
+
})
|
|
841
|
+
// Server issued a (fresh) push token — cache it on disk so we ask only once.
|
|
842
|
+
if (data.githubAuth?.token && data.githubAuth?.repo) {
|
|
843
|
+
cfg.auth = cfg.auth ?? {}
|
|
844
|
+
cfg.auth[repo.path] = { token: data.githubAuth.token, repo: data.githubAuth.repo }
|
|
845
|
+
writeConfig(cfg)
|
|
846
|
+
}
|
|
847
|
+
for (const cmd of data.commands ?? []) {
|
|
848
|
+
await executeCommand(cfg, cmd, repo.path)
|
|
849
|
+
}
|
|
850
|
+
return snapshot
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
854
|
+
|
|
855
|
+
async function runLoop(cfg) {
|
|
856
|
+
log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
|
|
857
|
+
log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
|
|
858
|
+
|
|
859
|
+
async function tick() {
|
|
860
|
+
try {
|
|
861
|
+
const discovered = scanRepos(cfg.roots)
|
|
862
|
+
const tracked = await sync(cfg, discovered)
|
|
863
|
+
let pushed = 0
|
|
864
|
+
for (const repo of tracked) {
|
|
865
|
+
try { await pushSnapshot(cfg, repo); pushed++ }
|
|
866
|
+
catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
|
|
867
|
+
}
|
|
868
|
+
log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
|
|
869
|
+
} catch (err) {
|
|
870
|
+
log(`✗ sync error: ${err.message}`)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
await tick()
|
|
875
|
+
setInterval(tick, cfg.interval * 1000)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function main() {
|
|
879
|
+
const args = parseArgs()
|
|
880
|
+
|
|
881
|
+
if (args.doctor) {
|
|
882
|
+
runDoctor()
|
|
883
|
+
process.exit(0)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (args.uninstall) {
|
|
887
|
+
uninstallStartup()
|
|
888
|
+
process.exit(0)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Setup / install path: merge CLI args into config, optionally (re)install.
|
|
892
|
+
if (args.install || args.key || args.roots.length || args.url || args.interval) {
|
|
893
|
+
const cfg = buildConfig(args)
|
|
894
|
+
if (!cfg.key) {
|
|
895
|
+
console.error('Error: --key is required on first setup (e.g. --key=gdo_xxx)')
|
|
896
|
+
process.exit(1)
|
|
897
|
+
}
|
|
898
|
+
writeConfig(cfg)
|
|
899
|
+
|
|
900
|
+
if (args.install) {
|
|
901
|
+
installStartup()
|
|
902
|
+
console.log(`✓ Инсталиран! При следващото влизане в Windows агентът ще стартира автоматично.`)
|
|
903
|
+
console.log(` Агент : ${STABLE_AGENT}`)
|
|
904
|
+
console.log(` Config : ${CONFIG_PATH}`)
|
|
905
|
+
console.log(` Лог : ${LOG_PATH}`)
|
|
906
|
+
console.log(` Roots :`)
|
|
907
|
+
for (const r of cfg.roots) console.log(` - ${r}`)
|
|
908
|
+
console.log()
|
|
909
|
+
|
|
910
|
+
// Launch silently in the background right now.
|
|
911
|
+
const child = spawn('wscript.exe', [getVbsPath()], { detached: true, stdio: 'ignore' })
|
|
912
|
+
child.unref()
|
|
913
|
+
console.log('✓ Агентът е стартиран в заден план — можеш да затвориш прозореца.')
|
|
914
|
+
console.log(' Сега избери кои репота да следи от gitdone.eu/github.')
|
|
915
|
+
process.exit(0)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// --key/--root without --install: just run the loop in the foreground.
|
|
919
|
+
await runLoop(cfg)
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// No args → running mode (this is how autostart launches us): read config.
|
|
924
|
+
const cfg = readConfig()
|
|
925
|
+
if (!cfg || !cfg.key) {
|
|
926
|
+
console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
|
|
927
|
+
process.exit(1)
|
|
928
|
+
}
|
|
929
|
+
await runLoop(cfg)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
main()
|