gitdone-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +209 -0
  2. package/package.json +15 -0
package/index.js ADDED
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ // gitdone-agent — watches a local git repo and pushes snapshots to gitdone.eu
3
+ // Usage: bun index.js --key=gdo_xxx --name=my-repo --path=/path/to/repo [--install]
4
+
5
+ import { execSync } from 'node:child_process'
6
+ import { existsSync, writeFileSync, unlinkSync } from 'node:fs'
7
+ import { resolve, join } from 'node:path'
8
+ import { homedir } from 'node:os'
9
+
10
+ // ─── Arg parsing ─────────────────────────────────────────────────────────────
11
+
12
+ function parseArgs() {
13
+ const args = Object.fromEntries(
14
+ process.argv.slice(2)
15
+ .filter((a) => a.startsWith('--'))
16
+ .map((a) => {
17
+ const [k, ...rest] = a.slice(2).split('=')
18
+ return [k, rest.join('=')]
19
+ })
20
+ )
21
+
22
+ const key = args.key
23
+ const repoPath = args.path ? resolve(args.path) : process.cwd()
24
+ const name = args.name ?? repoPath.split(/[\\/]/).pop() ?? 'repo'
25
+ const projectId = args.project ?? null
26
+ const interval = Number(args.interval ?? 30)
27
+ const url = (args.url ?? 'https://gitdone.eu').replace(/\/$/, '')
28
+ const install = 'install' in args
29
+ const uninstall = 'uninstall' in args
30
+
31
+ if (!key && !uninstall) {
32
+ console.error('Error: --key is required (e.g. --key=gdo_xxx)')
33
+ process.exit(1)
34
+ }
35
+ if (!existsSync(repoPath) && !uninstall) {
36
+ console.error(`Error: path does not exist: ${repoPath}`)
37
+ process.exit(1)
38
+ }
39
+
40
+ return { key, path: repoPath, name, projectId, interval, url, install, uninstall }
41
+ }
42
+
43
+ // ─── Windows auto-start ───────────────────────────────────────────────────────
44
+
45
+ function getStartupDir() {
46
+ return join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
47
+ }
48
+
49
+ function getBunPath() {
50
+ try { return execSync('where bun', { encoding: 'utf8' }).trim().split('\n')[0].trim() } catch {}
51
+ try { return execSync('which bun', { encoding: 'utf8' }).trim() } catch {}
52
+ return 'bun'
53
+ }
54
+
55
+ function installStartup({ name, key, path: repoPath, url, interval, projectId }) {
56
+ const bunPath = getBunPath()
57
+ const agentPath = resolve(process.argv[1])
58
+ const startupDir = getStartupDir()
59
+
60
+ const argLine = [
61
+ `--key=${key}`,
62
+ `--name=${name}`,
63
+ `--path=${repoPath}`,
64
+ `--url=${url}`,
65
+ `--interval=${interval}`,
66
+ ...(projectId ? [`--project=${projectId}`] : []),
67
+ ].join(' ')
68
+
69
+ // VBScript runs the agent silently — no console window pops up on login
70
+ const vbs = [
71
+ 'Set WshShell = CreateObject("WScript.Shell")',
72
+ `WshShell.Run """${bunPath}"" ""${agentPath}"" ${argLine}", 0, False`,
73
+ ].join('\r\n')
74
+
75
+ const vbsPath = join(startupDir, `gitdone-${name}.vbs`)
76
+ writeFileSync(vbsPath, vbs, 'utf8')
77
+
78
+ console.log(`✓ Инсталиран! При следващото влизане в Windows агентът ще стартира автоматично.`)
79
+ console.log(` Файл: ${vbsPath}`)
80
+ console.log(` Repo : ${repoPath}`)
81
+ console.log(` Name : ${name}`)
82
+ console.log()
83
+ }
84
+
85
+ function uninstallStartup(name) {
86
+ const vbsPath = join(getStartupDir(), `gitdone-${name}.vbs`)
87
+ if (existsSync(vbsPath)) {
88
+ unlinkSync(vbsPath)
89
+ console.log(`✓ Премахнат автостарт за "${name}"`)
90
+ } else {
91
+ console.log(`Не е намерен автостарт за "${name}" (${vbsPath})`)
92
+ }
93
+ }
94
+
95
+ // ─── Git helpers ─────────────────────────────────────────────────────────────
96
+
97
+ function git(cmd, cwd) {
98
+ try {
99
+ return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
100
+ } catch {
101
+ return ''
102
+ }
103
+ }
104
+
105
+ function isGitRepo(path) {
106
+ return git('git rev-parse --git-dir', path) !== ''
107
+ }
108
+
109
+ function parseDiffByFile(diffOutput) {
110
+ const files = {}
111
+ if (!diffOutput) return files
112
+ const sections = diffOutput.split(/(?=^diff --git )/m)
113
+ for (const section of sections) {
114
+ if (!section.trim()) continue
115
+ const match = section.match(/^diff --git a\/.+ b\/(.+)$/m)
116
+ if (match) files[match[1]] = section
117
+ }
118
+ return files
119
+ }
120
+
121
+ function getSnapshot(repoPath) {
122
+ const branch = git('git branch --show-current', repoPath) || 'HEAD'
123
+
124
+ const statusLines = git('git status --porcelain', repoPath).split('\n').filter(Boolean)
125
+ const modified = []
126
+ const staged = []
127
+ const statuses = {}
128
+ for (const line of statusLines) {
129
+ const xy = line.slice(0, 2)
130
+ const file = line.slice(3)
131
+ statuses[file] = xy
132
+ if (xy[0] !== ' ' && xy[0] !== '?') staged.push(file)
133
+ if (xy[1] !== ' ') modified.push(file)
134
+ }
135
+
136
+ const aheadBy = parseInt(git('git rev-list --count @{u}..HEAD', repoPath), 10) || 0
137
+ const behindBy = parseInt(git('git rev-list --count HEAD..@{u}', repoPath), 10) || 0
138
+
139
+ const logLine = git('git log -1 --pretty=format:%H|%s|%an|%aI', repoPath)
140
+ let lastCommit = null
141
+ if (logLine) {
142
+ const [sha, message, author, date] = logLine.split('|')
143
+ lastCommit = { sha, message, author, date }
144
+ }
145
+
146
+ const diffs = parseDiffByFile(git('git diff HEAD', repoPath))
147
+
148
+ return { branch, modified, staged, aheadBy, behindBy, lastCommit, statuses, diffs }
149
+ }
150
+
151
+ // ─── Push to API ─────────────────────────────────────────────────────────────
152
+
153
+ async function push({ url, key, name, projectId, snapshot }) {
154
+ const res = await fetch(`${url}/api/v1/agent/snapshot`, {
155
+ method: 'POST',
156
+ headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ repoName: name, projectId, ...snapshot }),
158
+ })
159
+ if (!res.ok) {
160
+ const text = await res.text().catch(() => '')
161
+ throw new Error(`HTTP ${res.status}: ${text}`)
162
+ }
163
+ }
164
+
165
+ // ─── Main ────────────────────────────────────────────────────────────────────
166
+
167
+ async function main() {
168
+ const config = parseArgs()
169
+ const { key, path: repoPath, name, projectId, interval, url, install, uninstall } = config
170
+
171
+ if (uninstall) {
172
+ uninstallStartup(name)
173
+ process.exit(0)
174
+ }
175
+
176
+ if (install) {
177
+ installStartup(config)
178
+ }
179
+
180
+ if (!isGitRepo(repoPath)) {
181
+ console.error(`Error: ${repoPath} is not a git repository`)
182
+ process.exit(1)
183
+ }
184
+
185
+ console.log(`gitdone-agent started`)
186
+ console.log(` repo : ${repoPath}`)
187
+ console.log(` name : ${name}`)
188
+ if (projectId) console.log(` project : ${projectId}`)
189
+ console.log(` server : ${url}`)
190
+ console.log(` interval: ${interval}s`)
191
+ if (install) console.log(` startup : registered`)
192
+ console.log()
193
+
194
+ async function tick() {
195
+ try {
196
+ const snapshot = getSnapshot(repoPath)
197
+ await push({ url, key, name, projectId, snapshot })
198
+ const changed = snapshot.modified.length + snapshot.staged.length
199
+ console.log(`[${new Date().toLocaleTimeString()}] ✓ pushed — branch: ${snapshot.branch}, changes: ${changed}`)
200
+ } catch (err) {
201
+ console.error(`[${new Date().toLocaleTimeString()}] ✗ error: ${err.message}`)
202
+ }
203
+ }
204
+
205
+ await tick()
206
+ setInterval(tick, interval * 1000)
207
+ }
208
+
209
+ main()
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "gitdone-agent",
3
+ "version": "0.1.0",
4
+ "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
+ "type": "module",
6
+ "bin": {
7
+ "gitdone-agent": "index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.js"
11
+ },
12
+ "engines": {
13
+ "node": ">=18"
14
+ }
15
+ }