@nhic-lab/srv-wrapper 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.
@@ -0,0 +1,21 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.srv-wrapper.daemon</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>__NODE_PATH__</string>
10
+ <string>__DAEMON_DIST_PATH__</string>
11
+ </array>
12
+ <key>RunAtLoad</key>
13
+ <true/>
14
+ <key>KeepAlive</key>
15
+ <true/>
16
+ <key>StandardOutPath</key>
17
+ <string>__SRV_HOME__/daemon.log</string>
18
+ <key>StandardErrorPath</key>
19
+ <string>__SRV_HOME__/daemon.error.log</string>
20
+ </dict>
21
+ </plist>
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time compaction of ~/.srv/log.db.
4
+ *
5
+ * New runs are capped at write time by LogStore, but runs recorded before that
6
+ * cap existed can be enormous (a handful of `mysqldump`/`docker exec` runs held
7
+ * 96% of a 1.8 GB database, and made GET /api/history fail outright with
8
+ * "RangeError: Invalid string length").
9
+ *
10
+ * For every oversized row this keeps the first and last 128 KB of output with
11
+ * an elision marker between them, records the original size in output_bytes so
12
+ * the UI still reports the true volume, then VACUUMs to reclaim the space.
13
+ * Run metadata — id, server, agent, command, exit code, timings — is untouched,
14
+ * so nothing disappears from the audit history.
15
+ *
16
+ * DESTRUCTIVE: the elided middle of those outputs cannot be recovered.
17
+ * The daemon must be stopped first, or SQLite will be writing underneath us.
18
+ *
19
+ * launchctl bootout gui/$(id -u)/com.srv-wrapper.daemon
20
+ * node scripts/compact-log.mjs --yes
21
+ * launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.srv-wrapper.daemon.plist
22
+ */
23
+ import Database from 'better-sqlite3'
24
+ import fs from 'node:fs'
25
+ import os from 'node:os'
26
+ import path from 'node:path'
27
+
28
+ const HEAD = 128 * 1024
29
+ const TAIL = 128 * 1024
30
+ const CAP = HEAD + TAIL
31
+
32
+ const dbPath = process.env.SRV_LOG_DB || path.join(os.homedir(), '.srv', 'log.db')
33
+ const confirmed = process.argv.includes('--yes')
34
+ const dryRun = process.argv.includes('--dry-run')
35
+
36
+ function fmt(n) {
37
+ if (n < 1024) return `${n} B`
38
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
39
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
40
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
41
+ }
42
+
43
+ if (!fs.existsSync(dbPath)) {
44
+ console.error(`no log database at ${dbPath}`)
45
+ process.exit(1)
46
+ }
47
+
48
+ // Refuse to run while the daemon still holds the socket — a concurrent writer
49
+ // would make the row rewrites race and leave the WAL enormous.
50
+ const sockPath = path.join(path.dirname(dbPath), 'srv.sock')
51
+ if (!dryRun && fs.existsSync(sockPath)) {
52
+ try {
53
+ const net = await import('node:net')
54
+ await new Promise((resolve, reject) => {
55
+ const probe = net.createConnection(sockPath)
56
+ probe.on('connect', () => { probe.destroy(); reject(new Error('daemon is running')) })
57
+ probe.on('error', () => resolve())
58
+ setTimeout(() => { probe.destroy(); resolve() }, 400)
59
+ })
60
+ } catch {
61
+ console.error('The srv daemon appears to be running. Stop it first:')
62
+ console.error(' launchctl bootout gui/$(id -u)/com.srv-wrapper.daemon')
63
+ process.exit(1)
64
+ }
65
+ }
66
+
67
+ const before = fs.statSync(dbPath).size
68
+ const wal = `${dbPath}-wal`
69
+ const beforeWal = fs.existsSync(wal) ? fs.statSync(wal).size : 0
70
+
71
+ const db = new Database(dbPath)
72
+
73
+ // The columns may not exist yet if the new daemon has never opened this file.
74
+ const cols = db.prepare('PRAGMA table_info(runs)').all().map((c) => c.name)
75
+ if (!cols.includes('output_bytes')) db.exec('ALTER TABLE runs ADD COLUMN output_bytes INTEGER NOT NULL DEFAULT 0')
76
+ if (!cols.includes('truncated')) db.exec('ALTER TABLE runs ADD COLUMN truncated INTEGER NOT NULL DEFAULT 0')
77
+
78
+ const stats = db.prepare(`
79
+ SELECT count(*) AS n, coalesce(sum(length(output)), 0) AS bytes
80
+ FROM runs WHERE length(output) > @cap
81
+ `).get({ cap: CAP })
82
+
83
+ const totals = db.prepare('SELECT count(*) AS n, coalesce(sum(length(output)),0) AS bytes FROM runs').get()
84
+
85
+ console.log(`database ${dbPath}`)
86
+ console.log(`file size ${fmt(before)}${beforeWal ? ` (+ ${fmt(beforeWal)} WAL)` : ''}`)
87
+ console.log(`runs ${totals.n} holding ${fmt(totals.bytes)} of output`)
88
+ console.log(`oversized runs ${stats.n} holding ${fmt(stats.bytes)} (cap is ${fmt(CAP)} per run)`)
89
+
90
+ if (stats.n === 0) {
91
+ console.log('\nnothing to compact.')
92
+ db.close()
93
+ process.exit(0)
94
+ }
95
+
96
+ const projected = totals.bytes - stats.bytes + stats.n * CAP
97
+ console.log(`projected ${fmt(totals.bytes)} -> ~${fmt(projected)} of output`)
98
+
99
+ if (dryRun) {
100
+ console.log('\n--dry-run: no changes made.')
101
+ db.close()
102
+ process.exit(0)
103
+ }
104
+ if (!confirmed) {
105
+ console.log('\nThis rewrites those outputs irreversibly. Re-run with --yes to proceed.')
106
+ db.close()
107
+ process.exit(1)
108
+ }
109
+
110
+ // Preserve the true original size before shortening the text.
111
+ db.exec(`UPDATE runs SET output_bytes = max(output_bytes, length(output)) WHERE length(output) > ${CAP}`)
112
+
113
+ const rows = db.prepare(`SELECT id, length(output) AS len FROM runs WHERE length(output) > @cap`).all({ cap: CAP })
114
+ const update = db.prepare('UPDATE runs SET output = @output, truncated = 1 WHERE id = @id')
115
+ const readOne = db.prepare('SELECT output FROM runs WHERE id = ?')
116
+
117
+ let done = 0
118
+ const compact = db.transaction(() => {
119
+ for (const row of rows) {
120
+ const full = readOne.get(row.id).output
121
+ const head = full.slice(0, HEAD)
122
+ const tail = full.slice(full.length - TAIL)
123
+ const elided = Buffer.byteLength(full.slice(HEAD, full.length - TAIL))
124
+ const marker = `\n\n… ${fmt(elided)} of output elided by srv-wrapper (head and tail kept) …\n\n`
125
+ update.run({ id: row.id, output: head + marker + tail })
126
+ done += 1
127
+ if (done % 25 === 0) console.log(` compacted ${done}/${rows.length}`)
128
+ }
129
+ })
130
+ compact()
131
+ console.log(` compacted ${done}/${rows.length}`)
132
+
133
+ console.log('vacuuming…')
134
+ db.pragma('wal_checkpoint(TRUNCATE)')
135
+ db.exec('VACUUM')
136
+ db.close()
137
+
138
+ const after = fs.statSync(dbPath).size
139
+ console.log(`\ndone: ${fmt(before)} -> ${fmt(after)} (freed ${fmt(Math.max(0, before - after))})`)
140
+ console.log(`${done} runs compacted; all ${totals.n} runs still present in history.`)
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ NODE_PATH="$(command -v node)"
6
+ DAEMON_DIST_PATH="$PROJECT_DIR/dist/daemon/index.js"
7
+ SRV_HOME="$HOME/.srv"
8
+ PLIST_DEST="$HOME/Library/LaunchAgents/com.srv-wrapper.daemon.plist"
9
+
10
+ mkdir -p "$SRV_HOME"
11
+
12
+ sed \
13
+ -e "s#__NODE_PATH__#${NODE_PATH}#g" \
14
+ -e "s#__DAEMON_DIST_PATH__#${DAEMON_DIST_PATH}#g" \
15
+ -e "s#__SRV_HOME__#${SRV_HOME}#g" \
16
+ "$PROJECT_DIR/scripts/com.srv-wrapper.daemon.plist" > "$PLIST_DEST"
17
+
18
+ launchctl unload "$PLIST_DEST" 2>/dev/null || true
19
+ launchctl load "$PLIST_DEST"
20
+
21
+ echo "srvd installed and loaded via launchd. Logs: $SRV_HOME/daemon.log"