gitdone-agent 0.8.6 → 0.8.8

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 -5
  2. package/package.json +5 -2
package/index.js CHANGED
@@ -4,6 +4,8 @@
4
4
  //
5
5
  // Setup (once):
6
6
  // npx gitdone-agent --key=gdo_xxx --root=C:\path\to\projects --install
7
+ // Optional WoW progress sync:
8
+ // npx gitdone-agent --wow="C:\Program Files (x86)\World of Warcraft\_retail_" --install
7
9
  //
8
10
  // Then pick which discovered repos to track from gitdone.eu/github. Add more
9
11
  // roots anytime with --root (repeatable). The running agent reads its config
@@ -29,7 +31,7 @@ import { randomUUID, createHash } from 'node:crypto'
29
31
  // Reported to the server on every sync so the web UI can flag outdated agents.
30
32
  // Keep in lockstep with packages/agent/package.json. The server's offline
31
33
  // fallback is bumped only after this release has actually reached npm.
32
- const AGENT_VERSION = '0.8.6'
34
+ const AGENT_VERSION = '0.8.8'
33
35
 
34
36
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
35
37
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -182,21 +184,25 @@ function parseArgs() {
182
184
  const argv = process.argv.slice(2)
183
185
  const flags = {}
184
186
  const roots = []
187
+ const wowRoots = []
185
188
  for (const a of argv) {
186
189
  if (!a.startsWith('--')) continue
187
190
  const [k, ...rest] = a.slice(2).split('=')
188
191
  const v = rest.join('=')
189
192
  if (k === 'root') { if (v) roots.push(resolve(v)) }
193
+ else if (k === 'wow') { if (v) wowRoots.push(resolve(v)) }
190
194
  else flags[k] = v
191
195
  }
192
196
  return {
193
197
  key: flags.key,
194
198
  roots,
199
+ wowRoots,
195
200
  interval: flags.interval ? Number(flags.interval) : undefined,
196
201
  url: flags.url ? flags.url.replace(/\/$/, '') : undefined,
197
202
  install: 'install' in flags,
198
203
  uninstall: 'uninstall' in flags,
199
204
  doctor: 'doctor' in flags,
205
+ testWow: flags['test-wow'] ? resolve(flags['test-wow']) : undefined,
200
206
  }
201
207
  }
202
208
 
@@ -204,6 +210,7 @@ function parseArgs() {
204
210
  function buildConfig(args) {
205
211
  const existing = readConfig() ?? {}
206
212
  const mergedRoots = Array.from(new Set([...(existing.roots ?? []), ...args.roots]))
213
+ const mergedWowRoots = Array.from(new Set([...(existing.wowRoots ?? []), ...args.wowRoots]))
207
214
  return {
208
215
  key: args.key ?? existing.key,
209
216
  url: args.url ?? existing.url ?? 'https://gitdone.eu',
@@ -211,6 +218,8 @@ function buildConfig(args) {
211
218
  machineId: existing.machineId ?? randomUUID(),
212
219
  hostname: existing.hostname ?? hostname(),
213
220
  roots: mergedRoots,
221
+ wowRoots: mergedWowRoots,
222
+ ...(existing.auth ? { auth: existing.auth } : {}),
214
223
  }
215
224
  }
216
225
 
@@ -547,6 +556,77 @@ function uninstallStartup() {
547
556
 
548
557
  // ─── Doctor ────────────────────────────────────────────────────────────────────
549
558
 
559
+ // Each sync heartbeat returns the version, URL and SHA-256 of the exact agent
560
+ // script deployed on the server. A supervised stable install downloads it,
561
+ // verifies both checksum and embedded version, atomically replaces itself, and
562
+ // exits; run-agent.cmd then starts the new version within about ten seconds.
563
+ function compareVersions(a, b) {
564
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0)
565
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0)
566
+ const len = Math.max(pa.length, pb.length)
567
+ for (let i = 0; i < len; i++) {
568
+ const difference = (pa[i] ?? 0) - (pb[i] ?? 0)
569
+ if (difference !== 0) return difference > 0 ? 1 : -1
570
+ }
571
+ return 0
572
+ }
573
+
574
+ const UPDATE_RETRY_MS = 60 * 60 * 1000
575
+ let updateInFlight = false
576
+ let lastUpdateAttempt = { version: null, at: 0 }
577
+
578
+ async function selfUpdate(cfg, latestVersion, downloadUrl, expectedSha256) {
579
+ if (updateInFlight || !latestVersion || !downloadUrl) return
580
+ if (compareVersions(latestVersion, AGENT_VERSION) <= 0) return
581
+ if (lastUpdateAttempt.version === latestVersion && Date.now() - lastUpdateAttempt.at < UPDATE_RETRY_MS) return
582
+
583
+ // A foreground/dev invocation has no supervisor to bring it back after the
584
+ // swap. Announce the update there, but only self-replace a stable install.
585
+ const runningStable = resolve(process.argv[1] || '') === STABLE_AGENT
586
+ const supervised = existsSync(join(AGENT_DIR, 'run-agent.cmd'))
587
+ if (!runningStable || !supervised) {
588
+ log(`↑ налична е нова версия ${latestVersion} (текуща ${AGENT_VERSION}) — пусни „gitdone-agent --install“ еднократно, за да включиш автообновяването`)
589
+ lastUpdateAttempt = { version: latestVersion, at: Date.now() }
590
+ return
591
+ }
592
+
593
+ updateInFlight = true
594
+ lastUpdateAttempt = { version: latestVersion, at: Date.now() }
595
+ const url = downloadUrl.startsWith('http') ? downloadUrl : `${cfg.url}${downloadUrl}`
596
+ const tmp = STABLE_AGENT + '.next'
597
+ log(`↑ автообновяване ${AGENT_VERSION} → ${latestVersion}`)
598
+ try {
599
+ if (!/^[a-f0-9]{64}$/i.test(String(expectedSha256 || ''))) {
600
+ throw new Error('сървърът не върна валиден sha256')
601
+ }
602
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${cfg.key}` } })
603
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
604
+ const buffer = Buffer.from(await response.arrayBuffer())
605
+ if (buffer.length < 1000) throw new Error(`подозрително малък файл (${buffer.length} B)`)
606
+
607
+ const actualSha256 = createHash('sha256').update(buffer).digest('hex')
608
+ if (actualSha256.toLowerCase() !== String(expectedSha256).toLowerCase()) {
609
+ throw new Error(`sha256 несъвпадение (очаквано ${expectedSha256}, получено ${actualSha256})`)
610
+ }
611
+ const embeddedVersion = buffer
612
+ .subarray(0, 12_000)
613
+ .toString('utf8')
614
+ .match(/const AGENT_VERSION\s*=\s*['"]([^'"]+)['"]/)?.[1]
615
+ if (embeddedVersion !== latestVersion) {
616
+ throw new Error(`файлът е v${embeddedVersion || '?'}, а сървърът обяви v${latestVersion}`)
617
+ }
618
+
619
+ writeFileSync(tmp, buffer)
620
+ renameSync(tmp, STABLE_AGENT)
621
+ log(`✓ обновено до ${latestVersion} — supervisor-ът ще рестартира агента`)
622
+ process.exit(0)
623
+ } catch (err) {
624
+ updateInFlight = false
625
+ try { if (existsSync(tmp)) unlinkSync(tmp) } catch { /* best-effort cleanup */ }
626
+ log(`✗ автообновяването се провали: ${err?.message || err} — оставам на ${AGENT_VERSION}, ще опитам пак до 1 час`)
627
+ }
628
+ }
629
+
550
630
  function runDoctor() {
551
631
  const cfg = readConfig()
552
632
  console.log('gitdone-agent doctor')
@@ -586,6 +666,20 @@ function runDoctor() {
586
666
  console.log(` found repos : ${found.length}`)
587
667
  for (const r of found) console.log(` - ${r.name} (${r.path})`)
588
668
  }
669
+ console.log(` WoW roots : ${cfg.wowRoots?.length ? '' : '(not configured)'}`)
670
+ for (const root of cfg.wowRoots ?? []) console.log(` - ${root} ${existsSync(root) ? '' : '(MISSING)'}`)
671
+ if (cfg.wowRoots?.length) {
672
+ const files = findWowSavedVariableFiles(cfg.wowRoots)
673
+ console.log(` WoW snapshots : ${files.length}`)
674
+ for (const file of files) {
675
+ try {
676
+ const { snapshot } = decodeWowSavedVariables(file)
677
+ console.log(` - ${snapshot.character.name}-${snapshot.character.realm} (${file})`)
678
+ } catch (err) {
679
+ console.log(` - INVALID (${file}): ${err.message}`)
680
+ }
681
+ }
682
+ }
589
683
  }
590
684
  }
591
685
 
@@ -997,6 +1091,94 @@ function scanRepos(roots, maxDepth = 5) {
997
1091
  return found
998
1092
  }
999
1093
 
1094
+ // ─── World of Warcraft progress bridge ──────────────────────────────────────
1095
+ // WoW addons cannot open network connections. GitDoneProgress therefore writes
1096
+ // a Base64URL-encoded JSON snapshot to its normal SavedVariables file; this
1097
+ // opt-in companion path reads only that explicit file and uploads it with the
1098
+ // same API credential the agent already uses. WoW flushes SavedVariables on
1099
+ // /reload or logout, so there is no process-memory inspection involved.
1100
+
1101
+ function findWowSavedVariableFiles(wowRoots = []) {
1102
+ const files = new Set()
1103
+
1104
+ for (const configuredRoot of wowRoots) {
1105
+ const root = resolve(configuredRoot)
1106
+ if (/GitDoneProgress\.lua$/i.test(root) && existsSync(root)) {
1107
+ files.add(root)
1108
+ continue
1109
+ }
1110
+
1111
+ const normalized = root.replace(/[\\/]+$/, '')
1112
+ const accountRoots = new Set([
1113
+ join(normalized, 'WTF', 'Account'),
1114
+ join(normalized, '_retail_', 'WTF', 'Account'),
1115
+ ])
1116
+ if (/[\\/]WTF$/i.test(normalized)) accountRoots.add(join(normalized, 'Account'))
1117
+ if (/[\\/]Account$/i.test(normalized)) accountRoots.add(normalized)
1118
+
1119
+ for (const accountRoot of accountRoots) {
1120
+ if (!existsSync(accountRoot)) continue
1121
+ let accounts = []
1122
+ try { accounts = readdirSync(accountRoot, { withFileTypes: true }) }
1123
+ catch { continue }
1124
+ for (const account of accounts) {
1125
+ if (!account.isDirectory()) continue
1126
+ const candidate = join(accountRoot, account.name, 'SavedVariables', 'GitDoneProgress.lua')
1127
+ if (existsSync(candidate)) files.add(candidate)
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ return [...files].sort()
1133
+ }
1134
+
1135
+ function decodeWowSavedVariables(path) {
1136
+ const source = readFileSync(path, 'utf8')
1137
+ const match = source.match(/(?:\["payload"\]|payload)\s*=\s*"([A-Za-z0-9_-]+)"/)
1138
+ if (!match) throw new Error('GitDoneProgressDB.payload is missing')
1139
+
1140
+ const json = Buffer.from(match[1], 'base64url').toString('utf8')
1141
+ const snapshot = JSON.parse(json)
1142
+ if (snapshot?.schemaVersion !== 1) {
1143
+ throw new Error(`unsupported snapshot schema ${snapshot?.schemaVersion ?? '?'}`)
1144
+ }
1145
+ if (!snapshot?.character?.guid || !snapshot?.character?.name || !snapshot?.character?.realm) {
1146
+ throw new Error('snapshot character identity is incomplete')
1147
+ }
1148
+ return { snapshot, signature: createHash('sha256').update(match[1]).digest('hex') }
1149
+ }
1150
+
1151
+ const wowSnapshotSigCache = new Map() // SavedVariables path -> last uploaded payload hash
1152
+
1153
+ async function pushWowProgress(cfg) {
1154
+ const files = findWowSavedVariableFiles(cfg.wowRoots)
1155
+ let sent = 0
1156
+ let unchanged = 0
1157
+ let failed = 0
1158
+
1159
+ for (const path of files) {
1160
+ try {
1161
+ const { snapshot, signature } = decodeWowSavedVariables(path)
1162
+ if (wowSnapshotSigCache.get(path) === signature) {
1163
+ unchanged++
1164
+ continue
1165
+ }
1166
+ const result = await api(cfg, '/api/v1/wow/progress', {
1167
+ machineId: cfg.machineId,
1168
+ snapshot,
1169
+ })
1170
+ wowSnapshotSigCache.set(path, signature)
1171
+ sent++
1172
+ log(`✓ WoW progress synced — ${snapshot.character.name}-${snapshot.character.realm}${result.unchanged ? ' (server already current)' : ''}`)
1173
+ } catch (err) {
1174
+ failed++
1175
+ log(`✗ WoW progress sync failed @ ${path}: ${err.message}`)
1176
+ }
1177
+ }
1178
+
1179
+ return { found: files.length, sent, unchanged, failed }
1180
+ }
1181
+
1000
1182
  // ─── Server sync + commands ─────────────────────────────────────────────────────
1001
1183
 
1002
1184
  // Keep the deadline below the server-side session stall window. Without a
@@ -2795,7 +2977,12 @@ async function sync(cfg, discovered) {
2795
2977
  })
2796
2978
  if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
2797
2979
  else syncFullCountdown--
2798
- return data.tracked ?? []
2980
+ return {
2981
+ tracked: data.tracked ?? [],
2982
+ latestVersion: data.latestVersion,
2983
+ downloadUrl: data.downloadUrl,
2984
+ downloadSha256: data.downloadSha256,
2985
+ }
2799
2986
  }
2800
2987
 
2801
2988
  // Per-repo cache of the last snapshot we actually SENT, so an idle repo whose
@@ -2965,18 +3152,27 @@ async function runLoop(cfg) {
2965
3152
  backupConfig(cfg)
2966
3153
  log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
2967
3154
  log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
3155
+ log(` WoW: ${cfg.wowRoots?.join(', ') || '(disabled — add retail path with --wow)'}`)
2968
3156
 
2969
3157
  async function tick() {
2970
3158
  try {
2971
3159
  const discovered = scanRepos(cfg.roots)
2972
- const tracked = await sync(cfg, discovered)
3160
+ const state = await sync(cfg, discovered)
3161
+ // A successful update exits here; the supervisor starts the new stable
3162
+ // script before any more repo work is performed.
3163
+ await selfUpdate(cfg, state.latestVersion, state.downloadUrl, state.downloadSha256)
3164
+ const wow = await pushWowProgress(cfg)
3165
+ const tracked = state.tracked
2973
3166
  let pushed = 0
2974
3167
  let skipped = 0
2975
3168
  for (const repo of tracked) {
2976
3169
  try { (await pushSnapshot(cfg, repo)) ? pushed++ : skipped++ }
2977
3170
  catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
2978
3171
  }
2979
- log(`✓ tick discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}, unchanged: ${skipped}`)
3172
+ const wowStatus = cfg.wowRoots?.length
3173
+ ? `, WoW: ${wow.sent} sent/${wow.unchanged} unchanged/${wow.failed} failed`
3174
+ : ''
3175
+ log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}, unchanged: ${skipped}${wowStatus}`)
2980
3176
  } catch (err) {
2981
3177
  log(`✗ sync error: ${err.message}`)
2982
3178
  }
@@ -2993,6 +3189,12 @@ async function runLoop(cfg) {
2993
3189
  async function main() {
2994
3190
  const args = parseArgs()
2995
3191
 
3192
+ if (args.testWow) {
3193
+ const { snapshot, signature } = decodeWowSavedVariables(args.testWow)
3194
+ console.log(JSON.stringify({ signature, snapshot }, null, 2))
3195
+ process.exit(0)
3196
+ }
3197
+
2996
3198
  if (args.doctor) {
2997
3199
  runDoctor()
2998
3200
  process.exit(0)
@@ -3004,7 +3206,7 @@ async function main() {
3004
3206
  }
3005
3207
 
3006
3208
  // Setup / install path: merge CLI args into config, optionally (re)install.
3007
- if (args.install || args.key || args.roots.length || args.url || args.interval) {
3209
+ if (args.install || args.key || args.roots.length || args.wowRoots.length || args.url || args.interval) {
3008
3210
  const cfg = buildConfig(args)
3009
3211
  if (!cfg.key) {
3010
3212
  console.error('Error: --key is required on first setup (e.g. --key=gdo_xxx)')
@@ -3020,6 +3222,8 @@ async function main() {
3020
3222
  console.log(` Лог : ${LOG_PATH}`)
3021
3223
  console.log(` Roots :`)
3022
3224
  for (const r of cfg.roots) console.log(` - ${r}`)
3225
+ console.log(` WoW :`)
3226
+ for (const r of cfg.wowRoots ?? []) console.log(` - ${r}`)
3023
3227
  console.log()
3024
3228
 
3025
3229
  // Launch silently in the background right now.
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.6",
4
- "description": "Local git agent for gitdone watches a local repo and sends snapshots to gitdone.eu",
3
+ "version": "0.8.8",
4
+ "description": "Local gitDone companion for repository and optional World of Warcraft progress sync",
5
5
  "type": "module",
6
+ "files": [
7
+ "index.js"
8
+ ],
6
9
  "bin": {
7
10
  "gitdone-agent": "index.js"
8
11
  },