agent-recon 1.0.1

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 (44) hide show
  1. package/.claude/hooks/send-event-wsl.py +339 -0
  2. package/.claude/hooks/send-event.py +334 -0
  3. package/CHANGELOG.md +66 -0
  4. package/CONTRIBUTING.md +70 -0
  5. package/EULA.md +223 -0
  6. package/INSTALL.md +193 -0
  7. package/LICENSE +287 -0
  8. package/LICENSE-COMMERCIAL +241 -0
  9. package/PRIVACY.md +115 -0
  10. package/README.md +182 -0
  11. package/SECURITY.md +63 -0
  12. package/TERMS.md +233 -0
  13. package/install-service.ps1 +302 -0
  14. package/installer/cli.js +177 -0
  15. package/installer/detect.js +355 -0
  16. package/installer/install.js +195 -0
  17. package/installer/manifest.js +140 -0
  18. package/installer/package.json +12 -0
  19. package/installer/steps/api-keys.js +59 -0
  20. package/installer/steps/directory.js +41 -0
  21. package/installer/steps/env-report.js +48 -0
  22. package/installer/steps/hooks.js +149 -0
  23. package/installer/steps/service.js +159 -0
  24. package/installer/steps/tls.js +104 -0
  25. package/installer/steps/verify.js +117 -0
  26. package/installer/steps/welcome.js +46 -0
  27. package/installer/ui.js +133 -0
  28. package/installer/uninstall.js +233 -0
  29. package/installer/upgrade.js +289 -0
  30. package/package.json +58 -0
  31. package/public/index.html +13953 -0
  32. package/server/fixtures/allowlist-profiles.json +185 -0
  33. package/server/package.json +34 -0
  34. package/server/platform.js +270 -0
  35. package/server/rules/gitleaks.toml +3214 -0
  36. package/server/rules/security.yara +579 -0
  37. package/server/start.js +178 -0
  38. package/service/agent-recon.service +30 -0
  39. package/service/com.agent-recon.server.plist +56 -0
  40. package/setup-linux.sh +259 -0
  41. package/setup-macos.sh +264 -0
  42. package/setup-wsl.sh +248 -0
  43. package/setup.ps1 +171 -0
  44. package/start-agent-recon.bat +4 -0
@@ -0,0 +1,355 @@
1
+ // Copyright 2026 PNW Great Loop LLC. All rights reserved.
2
+ // Licensed under the Agent Recon™ Commercial License — see LICENSE-COMMERCIAL.
3
+
4
+ 'use strict';
5
+
6
+ /**
7
+ * Agent Recon Installer — Environment Detection (Task 8.1)
8
+ *
9
+ * Detects OS, arch, Node, Python, Claude Code presence, existing Agent Recon
10
+ * installation, credential backend, and platform capabilities.
11
+ *
12
+ * Pure detection logic is extracted into testable _-prefixed helpers.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { execFileSync } = require('child_process');
18
+ const crypto = require('crypto');
19
+
20
+ // Reuse platform.js from the server module for consistent OS detection
21
+ const platform = require('../server/platform');
22
+
23
+ // ── Constants ───────────────────────────────────────────────────────────────
24
+
25
+ const HOOK_SCRIPT_MARKER = 'Agent Recon';
26
+
27
+ // ── Pure helpers (testable) ─────────────────────────────────────────────────
28
+
29
+ /**
30
+ * SHA-256 hash of a file's contents.
31
+ * @param {string} filePath
32
+ * @returns {string|null} hex digest, or null if file doesn't exist
33
+ */
34
+ function _hashFile(filePath) {
35
+ try {
36
+ const buf = fs.readFileSync(filePath);
37
+ return crypto.createHash('sha256').update(buf).digest('hex');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Count how many Agent Recon hook events are registered in a settings object.
45
+ * @param {object} settings — parsed ~/.claude/settings.json
46
+ * @param {string} hookCommand — the hook command string to search for
47
+ * @returns {number} count of events with a matching hook
48
+ */
49
+ function _countRegisteredHooks(settings, hookCommand) {
50
+ if (!settings || !settings.hooks) return 0;
51
+ let count = 0;
52
+ for (const [, groups] of Object.entries(settings.hooks)) {
53
+ if (!Array.isArray(groups)) continue;
54
+ for (const group of groups) {
55
+ const hooks = group.hooks || [];
56
+ for (const h of hooks) {
57
+ if (h.command && h.command.includes(hookCommand)) {
58
+ count++;
59
+ break; // count the event once even if registered multiple times
60
+ }
61
+ }
62
+ }
63
+ }
64
+ return count;
65
+ }
66
+
67
+ /**
68
+ * Parse settings.json to find the Agent Recon hook command string.
69
+ * @param {object} settings — parsed settings.json
70
+ * @returns {string|null} the hook command, or null if not found
71
+ */
72
+ function _findHookCommand(settings) {
73
+ if (!settings || !settings.hooks) return null;
74
+ for (const [, groups] of Object.entries(settings.hooks)) {
75
+ if (!Array.isArray(groups)) continue;
76
+ for (const group of groups) {
77
+ for (const h of (group.hooks || [])) {
78
+ if (h.command && (h.command.includes('send-event.py') || h.command.includes('send-event-wsl.py'))) {
79
+ return h.command;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Extract the hook script destination path from a hook command string.
89
+ * e.g. "python3 /home/user/.claude/hooks/send-event.py" → "/home/user/.claude/hooks/send-event.py"
90
+ * @param {string} hookCommand
91
+ * @returns {string|null}
92
+ */
93
+ function _hookScriptFromCommand(hookCommand) {
94
+ if (!hookCommand) return null;
95
+ const parts = hookCommand.split(' ');
96
+ // The script path is the last part that ends with .py
97
+ for (let i = parts.length - 1; i >= 0; i--) {
98
+ if (parts[i].endsWith('.py')) return parts[i];
99
+ }
100
+ return null;
101
+ }
102
+
103
+ // ── Exec helpers ────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Find a working Python 3 executable.
107
+ * Tries python3, python, py in order (matching setup.ps1 logic).
108
+ * @returns {{path: string, version: string}|null}
109
+ */
110
+ function findPython() {
111
+ const candidates = process.platform === 'win32'
112
+ ? ['python', 'py', 'python3']
113
+ : ['python3', 'python'];
114
+
115
+ for (const cmd of candidates) {
116
+ try {
117
+ const ver = execFileSync(cmd, ['--version'], {
118
+ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
119
+ }).trim();
120
+ if (/Python 3/i.test(ver)) {
121
+ return { path: cmd, version: ver.replace(/^Python\s*/i, '') };
122
+ }
123
+ } catch { /* not found or wrong version */ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Get the current Node.js info.
130
+ * @returns {{path: string, version: string}}
131
+ */
132
+ function findNode() {
133
+ return {
134
+ path: process.execPath,
135
+ version: process.version.replace(/^v/, ''),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Path to Claude Code's user-global settings file.
141
+ * @returns {string}
142
+ */
143
+ function findClaudeSettings() {
144
+ return path.join(platform.homedir(), '.claude', 'settings.json');
145
+ }
146
+
147
+ /**
148
+ * Check if the Agent Recon server is healthy.
149
+ * @param {number} [port=3131]
150
+ * @returns {Promise<{ok: boolean, eventCount: number, dbEventCount: number, clients: number}|null>}
151
+ */
152
+ function checkServerHealth(port) {
153
+ const p = port || 3131;
154
+ return new Promise(resolve => {
155
+ const http = require('http');
156
+ const req = http.get(`http://localhost:${p}/health`, { timeout: 2000 }, res => {
157
+ let body = '';
158
+ res.on('data', chunk => { body += chunk; });
159
+ res.on('end', () => {
160
+ try { resolve(JSON.parse(body)); } catch { resolve(null); }
161
+ });
162
+ });
163
+ req.on('error', () => resolve(null));
164
+ req.on('timeout', () => { req.destroy(); resolve(null); });
165
+ });
166
+ }
167
+
168
+ // ── Service detection ───────────────────────────────────────────────────────
169
+
170
+ function _hasCommand(cmd) {
171
+ try {
172
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
173
+ encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
174
+ });
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ function _serviceInstalled(osType) {
182
+ try {
183
+ if (osType === 'macos') {
184
+ const plist = path.join(platform.homedir(), 'Library', 'LaunchAgents', 'com.agent-recon.server.plist');
185
+ return fs.existsSync(plist);
186
+ }
187
+ if (osType === 'linux') {
188
+ const unit = path.join(platform.homedir(), '.config', 'systemd', 'user', 'agent-recon.service');
189
+ return fs.existsSync(unit);
190
+ }
191
+ if (osType === 'windows' || osType === 'wsl') {
192
+ // Check NSSM service — best effort
193
+ try {
194
+ const ps = osType === 'wsl'
195
+ ? '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
196
+ : 'powershell.exe';
197
+ const result = execFileSync(ps, ['-NoProfile', '-Command',
198
+ 'Get-Service AgentRecon -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status'],
199
+ { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
200
+ return result.trim().length > 0;
201
+ } catch { return false; }
202
+ }
203
+ } catch { /* ignore */ }
204
+ return false;
205
+ }
206
+
207
+ // ── Existing install detection ──────────────────────────────────────────────
208
+
209
+ /**
210
+ * Detect an existing Agent Recon installation.
211
+ * Checks manifest first, falls back to heuristic detection.
212
+ * @returns {object|null}
213
+ */
214
+ function findExistingInstall() {
215
+ const osType = platform.detectOS();
216
+ const confDir = platform.configDir();
217
+ const manifestFile = path.join(confDir, 'manifest.json');
218
+
219
+ // ── Try manifest first
220
+ if (fs.existsSync(manifestFile)) {
221
+ try {
222
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
223
+ const hookDest = manifest.hookScript && manifest.hookScript.destination;
224
+ const hookSrc = manifest.hookScript && manifest.hookScript.source;
225
+ const hookCurrent = hookDest && hookSrc
226
+ ? _hashFile(hookDest) === _hashFile(hookSrc)
227
+ : false;
228
+
229
+ const settingsPath = manifest.claudeSettingsPath || findClaudeSettings();
230
+ let hooksRegistered = 0;
231
+ if (fs.existsSync(settingsPath)) {
232
+ try {
233
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
234
+ const hookCmd = manifest.hookCommand || 'send-event';
235
+ hooksRegistered = _countRegisteredHooks(settings, hookCmd);
236
+ } catch { /* corrupt settings */ }
237
+ }
238
+
239
+ const svcInstalled = _serviceInstalled(osType);
240
+
241
+ return {
242
+ installDir: manifest.installDir,
243
+ version: manifest.version,
244
+ hookScriptPath: hookDest,
245
+ hookScriptCurrent: hookCurrent,
246
+ hooksRegistered,
247
+ serviceInstalled: svcInstalled,
248
+ serviceRunning: false, // filled by caller via checkServerHealth
249
+ serverHealthy: false, // filled by caller
250
+ manifestPath: manifestFile,
251
+ dbPath: manifest.dbPath || null,
252
+ dbSizeBytes: _fileSize(manifest.dbPath),
253
+ };
254
+ } catch { /* corrupt manifest, fall through to heuristic */ }
255
+ }
256
+
257
+ // ── Heuristic detection (no manifest)
258
+ const settingsPath = findClaudeSettings();
259
+ if (!fs.existsSync(settingsPath)) return null;
260
+
261
+ let settings;
262
+ try {
263
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
264
+ } catch { return null; }
265
+
266
+ const hookCommand = _findHookCommand(settings);
267
+ if (!hookCommand) return null;
268
+
269
+ const hookScriptPath = _hookScriptFromCommand(hookCommand);
270
+ const hooksRegistered = _countRegisteredHooks(settings, hookCommand);
271
+ const svcInstalled = _serviceInstalled(osType);
272
+
273
+ return {
274
+ installDir: null, // unknown without manifest
275
+ version: null,
276
+ hookScriptPath,
277
+ hookScriptCurrent: false, // can't determine without knowing source
278
+ hooksRegistered,
279
+ serviceInstalled: svcInstalled,
280
+ serviceRunning: false,
281
+ serverHealthy: false,
282
+ manifestPath: null,
283
+ dbPath: null,
284
+ dbSizeBytes: 0,
285
+ };
286
+ }
287
+
288
+ function _fileSize(filePath) {
289
+ if (!filePath) return 0;
290
+ try { return fs.statSync(filePath).size; } catch { return 0; }
291
+ }
292
+
293
+ // ── Main detection ──────────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * Run full environment detection.
297
+ * @returns {Promise<object>} EnvReport
298
+ */
299
+ async function detectEnv() {
300
+ const osType = platform.detectOS();
301
+ const node = findNode();
302
+ const python = findPython();
303
+ const claudeSettingsPath = findClaudeSettings();
304
+ const claudeSettingsExists = fs.existsSync(claudeSettingsPath);
305
+ const home = platform.homedir();
306
+ const confDir = platform.configDir();
307
+ const existing = findExistingInstall();
308
+
309
+ // Check server health (async)
310
+ const health = await checkServerHealth();
311
+ if (existing && health && health.ok) {
312
+ existing.serverHealthy = true;
313
+ existing.serviceRunning = true;
314
+ }
315
+
316
+ return {
317
+ os: osType,
318
+ arch: process.arch,
319
+ nodeVersion: node.version,
320
+ nodePath: node.path,
321
+ pythonVersion: python ? python.version : null,
322
+ pythonPath: python ? python.path : null,
323
+ home,
324
+ configDir: confDir,
325
+ claudeSettingsPath,
326
+ claudeSettingsExists,
327
+ claudeHooksDir: path.join(home, '.claude', 'hooks'),
328
+ existingInstall: existing,
329
+ credentialBackend: platform.credentialBackend(),
330
+ // Platform capabilities
331
+ hasNssm: osType === 'windows' ? _hasCommand('nssm') : false,
332
+ hasSystemctl: (osType === 'linux') ? _hasCommand('systemctl') : false,
333
+ hasLaunchctl: osType === 'macos' ? _hasCommand('launchctl') : false,
334
+ hasSecretTool: osType === 'linux' ? _hasCommand('secret-tool') : false,
335
+ hasCurl: _hasCommand('curl'),
336
+ };
337
+ }
338
+
339
+ // ── Exports ─────────────────────────────────────────────────────────────────
340
+
341
+ module.exports = {
342
+ detectEnv,
343
+ findClaudeSettings,
344
+ findPython,
345
+ findNode,
346
+ findExistingInstall,
347
+ checkServerHealth,
348
+ // Testable pure helpers
349
+ _hashFile,
350
+ _countRegisteredHooks,
351
+ _findHookCommand,
352
+ _hookScriptFromCommand,
353
+ _serviceInstalled,
354
+ _fileSize,
355
+ };
@@ -0,0 +1,195 @@
1
+ // Copyright 2026 PNW Great Loop LLC. All rights reserved.
2
+ // Licensed under the Agent Recon™ Commercial License — see LICENSE-COMMERCIAL.
3
+
4
+ 'use strict';
5
+
6
+ /**
7
+ * Agent Recon Installer — Guided Install Orchestrator (Task 8.2)
8
+ *
9
+ * Runs steps in sequence: welcome → env-report → directory → hooks →
10
+ * api-keys → service → verify → write manifest.
11
+ */
12
+
13
+ const path = require('path');
14
+ const ui = require('./ui');
15
+ const manifest = require('./manifest');
16
+
17
+ const welcome = require('./steps/welcome');
18
+ const envReport = require('./steps/env-report');
19
+ const directory = require('./steps/directory');
20
+ const hooks = require('./steps/hooks');
21
+ const apiKeys = require('./steps/api-keys');
22
+ const service = require('./steps/service');
23
+ const tls = require('./steps/tls');
24
+ const verify = require('./steps/verify');
25
+
26
+ const TOTAL_STEPS = 8;
27
+
28
+ /**
29
+ * Run the guided install flow.
30
+ * @param {object} env — from detect.detectEnv()
31
+ * @param {object} opts — {version, force}
32
+ */
33
+ async function install(env, opts) {
34
+ const ctx = {
35
+ envReport: env,
36
+ installDir: null,
37
+ version: opts.version || '1.0.0',
38
+ force: opts.force || false,
39
+ tlsPreselect: opts.tls ? 'mkcert' : null,
40
+ results: {},
41
+ };
42
+
43
+ // ── Step 1: Welcome + EULA ──────────────────────────────────────────────
44
+ ui.step(1, TOTAL_STEPS, 'Welcome & License');
45
+ const welcomeResult = await welcome.run(ctx);
46
+ if (!welcomeResult.success) {
47
+ ui.error(welcomeResult.error || 'Installation cancelled.');
48
+ return;
49
+ }
50
+ ctx.results.welcome = welcomeResult;
51
+
52
+ // ── Step 2: Environment Report ──────────────────────────────────────────
53
+ ui.step(2, TOTAL_STEPS, 'Environment Detection');
54
+ const envResult = await envReport.run(ctx);
55
+ if (!envResult.success) {
56
+ ui.error(envResult.error || 'Installation cancelled.');
57
+ return;
58
+ }
59
+ ctx.results.env = envResult;
60
+
61
+ // ── Step 3: Installation Directory ──────────────────────────────────────
62
+ ui.step(3, TOTAL_STEPS, 'Installation Directory');
63
+ const dirResult = await directory.run(ctx);
64
+ if (!dirResult.success) {
65
+ ui.error(dirResult.error || 'Installation cancelled.');
66
+ return;
67
+ }
68
+ ctx.installDir = dirResult.installDir;
69
+ ctx.results.directory = dirResult;
70
+
71
+ // ── Step 4: Hook Installation ───────────────────────────────────────────
72
+ ui.step(4, TOTAL_STEPS, 'Hook Installation');
73
+ const hooksResult = await hooks.run(ctx);
74
+ if (!hooksResult.success) {
75
+ ui.error(hooksResult.error || 'Hook installation failed.');
76
+ return;
77
+ }
78
+ ctx.results.hooks = hooksResult;
79
+
80
+ // ── Step 5: API Key Configuration (optional) ───────────────────────────
81
+ ui.step(5, TOTAL_STEPS, 'API Key Configuration');
82
+ try {
83
+ const keysResult = await apiKeys.run(ctx);
84
+ ctx.results.apiKeys = keysResult;
85
+ } catch (err) {
86
+ ui.warn(`API key configuration failed: ${err.message}`);
87
+ ctx.results.apiKeys = { success: true, skipped: true, configured: [] };
88
+ }
89
+
90
+ // ── Step 6: Auto-Start Service (optional) ──────────────────────────────
91
+ ui.step(6, TOTAL_STEPS, 'Auto-Start Service');
92
+ try {
93
+ const svcResult = await service.run(ctx);
94
+ ctx.results.service = svcResult;
95
+ } catch (err) {
96
+ ui.warn(`Service setup failed: ${err.message}`);
97
+ ctx.results.service = { success: true, skipped: true, serviceType: 'none' };
98
+ }
99
+
100
+ // ── Step 7: TLS / HTTPS ────────────────────────────────────────────────
101
+ ui.step(7, TOTAL_STEPS, 'TLS / HTTPS');
102
+ try {
103
+ // Pre-select mkcert if --tls flag was passed
104
+ if (ctx.force && ctx.tlsPreselect) ctx.tlsPreselect = ctx.tlsPreselect;
105
+ const tlsResult = await tls.run(ctx);
106
+ ctx.results.tls = tlsResult;
107
+ } catch (err) {
108
+ ui.warn(`TLS setup skipped: ${err.message}`);
109
+ ctx.results.tls = { success: true, tlsChoice: 'http', tlsEnabled: false, tlsMode: 'mkcert' };
110
+ }
111
+
112
+ // ── Step 8: Verification ───────────────────────────────────────────────
113
+ ui.step(8, TOTAL_STEPS, 'Verification');
114
+ try {
115
+ const verifyResult = await verify.run(ctx);
116
+ ctx.results.verify = verifyResult;
117
+ } catch (err) {
118
+ ui.warn(`Verification failed: ${err.message}`);
119
+ ctx.results.verify = { success: false, error: err.message };
120
+ }
121
+
122
+ // ── Write manifest ─────────────────────────────────────────────────────
123
+ const platform = require('../server/platform');
124
+ const crypto = require('crypto');
125
+ const fs = require('fs');
126
+
127
+ // Generate a unique install ID (UUID v4, not derived from PII)
128
+ const installationId = crypto.randomUUID();
129
+
130
+ let hookSha256 = null;
131
+ if (ctx.results.hooks && ctx.results.hooks.hookScriptDest) {
132
+ try {
133
+ const buf = fs.readFileSync(ctx.results.hooks.hookScriptDest);
134
+ hookSha256 = crypto.createHash('sha256').update(buf).digest('hex');
135
+ } catch { /* best effort */ }
136
+ }
137
+
138
+ const m = manifest.createManifest({
139
+ version: ctx.version,
140
+ os: env.os,
141
+ arch: env.arch,
142
+ installDir: ctx.installDir,
143
+ hookScriptSource: ctx.results.hooks
144
+ ? path.join(ctx.installDir, '.claude', 'hooks',
145
+ env.os === 'wsl' ? 'send-event-wsl.py' : 'send-event.py')
146
+ : null,
147
+ hookScriptDest: ctx.results.hooks ? ctx.results.hooks.hookScriptDest : null,
148
+ hookScriptSha256: hookSha256,
149
+ hooksRegistered: ctx.results.hooks ? ctx.results.hooks.hooksAdded : [],
150
+ hookCommand: ctx.results.hooks ? ctx.results.hooks.hookCommand : null,
151
+ claudeSettingsPath: env.claudeSettingsPath,
152
+ serviceType: ctx.results.service ? ctx.results.service.serviceType || 'none' : 'none',
153
+ serviceUnit: ctx.results.service ? ctx.results.service.serviceUnit || null : null,
154
+ servicePath: ctx.results.service ? ctx.results.service.servicePath || null : null,
155
+ apiKeysConfigured: ctx.results.apiKeys ? ctx.results.apiKeys.configured || [] : [],
156
+ credentialBackend: env.credentialBackend,
157
+ dbPath: path.join(ctx.installDir, 'data', 'agent-recon.db'),
158
+ credentialDir: path.join(platform.configDir(), 'credentials'),
159
+ installationId,
160
+ telemetryEnabled: ctx.results.welcome ? ctx.results.welcome.telemetryEnabled : false,
161
+ eulaAccepted: true,
162
+ tlsEnabled: ctx.results.tls ? ctx.results.tls.tlsEnabled : false,
163
+ tlsMode: ctx.results.tls ? ctx.results.tls.tlsMode : 'mkcert',
164
+ tlsCertPath: ctx.results.tls ? ctx.results.tls.tlsCertPath || null : null,
165
+ tlsKeyPath: ctx.results.tls ? ctx.results.tls.tlsKeyPath || null : null,
166
+ });
167
+
168
+ manifest.writeManifest(m);
169
+ ui.ok('Installation manifest saved');
170
+
171
+ // ── Summary ────────────────────────────────────────────────────────────
172
+ console.log('');
173
+ ui.divider();
174
+ ui.ok(`Agent Recon v${ctx.version} installed successfully!`);
175
+ ui.divider();
176
+ ui.info(`Install directory: ${ctx.installDir}`);
177
+ ui.info(`Hooks: ${ctx.results.hooks ? ctx.results.hooks.hooksAdded.length : 0} events registered`);
178
+ ui.info(`Service: ${ctx.results.service ? ctx.results.service.serviceType || 'none' : 'none'}`);
179
+ ui.info(`Install ID: ${installationId}`);
180
+ ui.info(`Telemetry: ${ctx.results.welcome.telemetryEnabled ? 'enabled' : 'disabled'}`);
181
+ ui.info(`TLS: ${ctx.results.tls ? ctx.results.tls.tlsChoice : 'http'}`);
182
+ ui.info(`API keys: ${(ctx.results.apiKeys && ctx.results.apiKeys.configured || []).join(', ') || 'none'}`);
183
+ if (ctx.results.verify && ctx.results.verify.success) {
184
+ ui.ok('Server verified and running');
185
+ const dashUrl = ctx.results.tls && ctx.results.tls.tlsEnabled
186
+ ? 'https://localhost:3132' : 'http://localhost:3131';
187
+ ui.info(`Open ${dashUrl} in your browser to view the dashboard`);
188
+ } else {
189
+ ui.warn('Server verification did not complete — start manually:');
190
+ ui.info(` cd ${ctx.installDir}/server && node start.js`);
191
+ }
192
+ ui.divider();
193
+ }
194
+
195
+ module.exports = install;
@@ -0,0 +1,140 @@
1
+ // Copyright 2026 PNW Great Loop LLC. All rights reserved.
2
+ // Licensed under the Agent Recon™ Commercial License — see LICENSE-COMMERCIAL.
3
+
4
+ 'use strict';
5
+
6
+ /**
7
+ * Agent Recon Installer — Installation Manifest
8
+ *
9
+ * Records what was installed, where, and when. Single source of truth
10
+ * for upgrade and uninstall operations.
11
+ *
12
+ * Stored at platform.configDir()/manifest.json:
13
+ * macOS: ~/Library/Application Support/agent-recon/manifest.json
14
+ * Windows: %APPDATA%/agent-recon/manifest.json
15
+ * Linux/WSL: ~/.config/agent-recon/manifest.json
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const platform = require('../server/platform');
21
+
22
+ const MANIFEST_VERSION = 2;
23
+
24
+ // ── Path ────────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Absolute path to the manifest file.
28
+ * @returns {string}
29
+ */
30
+ function manifestPath() {
31
+ return path.join(platform.configDir(), 'manifest.json');
32
+ }
33
+
34
+ // ── Read ────────────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Read and parse the manifest file.
38
+ * @returns {object|null} parsed manifest, or null if missing/corrupt
39
+ */
40
+ function readManifest() {
41
+ const p = manifestPath();
42
+ try {
43
+ const raw = fs.readFileSync(p, 'utf8');
44
+ const manifest = JSON.parse(raw);
45
+ if (!manifest || typeof manifest !== 'object') return null;
46
+ return manifest;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // ── Write ───────────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Write the manifest to disk atomically.
56
+ * Creates the config directory if needed.
57
+ * @param {object} manifest
58
+ */
59
+ function writeManifest(manifest) {
60
+ const p = manifestPath();
61
+ fs.mkdirSync(path.dirname(p), { recursive: true });
62
+
63
+ const data = JSON.stringify(manifest, null, 2) + '\n';
64
+ const tmp = p + '.tmp';
65
+ fs.writeFileSync(tmp, data, 'utf8');
66
+ fs.renameSync(tmp, p);
67
+ }
68
+
69
+ // ── Delete ──────────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Delete the manifest file. No-op if it doesn't exist.
73
+ */
74
+ function deleteManifest() {
75
+ const p = manifestPath();
76
+ try { fs.unlinkSync(p); } catch { /* not found — fine */ }
77
+ }
78
+
79
+ // ── Factory ─────────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Create a fresh manifest object with required fields.
83
+ * @param {object} opts
84
+ * @returns {object}
85
+ */
86
+ function createManifest(opts) {
87
+ const now = new Date().toISOString();
88
+ return {
89
+ manifestVersion: MANIFEST_VERSION,
90
+ installedAt: now,
91
+ updatedAt: now,
92
+ version: opts.version || '1.0.0',
93
+ os: opts.os || platform.detectOS(),
94
+ arch: opts.arch || process.arch,
95
+ installDir: opts.installDir,
96
+ serverDir: opts.serverDir || path.join(opts.installDir, 'server'),
97
+ hookScript: {
98
+ source: opts.hookScriptSource || null,
99
+ destination: opts.hookScriptDest || null,
100
+ sha256: opts.hookScriptSha256 || null,
101
+ },
102
+ hooksRegistered: opts.hooksRegistered || [],
103
+ hookCommand: opts.hookCommand || null,
104
+ claudeSettingsPath: opts.claudeSettingsPath || null,
105
+ service: {
106
+ type: opts.serviceType || 'none',
107
+ unit: opts.serviceUnit || null,
108
+ path: opts.servicePath || null,
109
+ },
110
+ apiKeys: {
111
+ configured: opts.apiKeysConfigured || [],
112
+ backend: opts.credentialBackend || platform.credentialBackend(),
113
+ },
114
+ dbPath: opts.dbPath || null,
115
+ credentialDir: opts.credentialDir || null,
116
+ installation_id: opts.installationId || null,
117
+ tls: {
118
+ enabled: opts.tlsEnabled || false,
119
+ mode: opts.tlsMode || 'mkcert',
120
+ certPath: opts.tlsCertPath || null,
121
+ keyPath: opts.tlsKeyPath || null,
122
+ },
123
+ settings: {
124
+ telemetry_enabled: opts.telemetryEnabled || false,
125
+ eula_accepted: opts.eulaAccepted || false,
126
+ eula_accepted_at: opts.eulaAccepted ? now : null,
127
+ },
128
+ };
129
+ }
130
+
131
+ // ── Exports ─────────────────────────────────────────────────────────────────
132
+
133
+ module.exports = {
134
+ MANIFEST_VERSION,
135
+ manifestPath,
136
+ readManifest,
137
+ writeManifest,
138
+ deleteManifest,
139
+ createManifest,
140
+ };