lightman-agent 1.0.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 (54) hide show
  1. package/agent.config.template.json +30 -0
  2. package/bin/cms-agent.js +233 -0
  3. package/nssm/nssm.exe +0 -0
  4. package/package.json +52 -0
  5. package/public/assets/index-CcBNCz6h.css +1 -0
  6. package/public/assets/index-H-8HDl46.js +1 -0
  7. package/public/index.html +19 -0
  8. package/scripts/guardian.ps1 +75 -0
  9. package/scripts/install-linux.sh +134 -0
  10. package/scripts/install-rpi.sh +117 -0
  11. package/scripts/install-windows.ps1 +529 -0
  12. package/scripts/launch-kiosk.vbs +101 -0
  13. package/scripts/lightman-agent.logrotate +12 -0
  14. package/scripts/lightman-agent.service +38 -0
  15. package/scripts/lightman-shell.bat +128 -0
  16. package/scripts/reinstall-windows.ps1 +26 -0
  17. package/scripts/restore-desktop.ps1 +32 -0
  18. package/scripts/setup.ps1 +116 -0
  19. package/scripts/setup.sh +115 -0
  20. package/scripts/uninstall-linux.sh +50 -0
  21. package/scripts/uninstall-windows.ps1 +54 -0
  22. package/src/commands/display.ts +177 -0
  23. package/src/commands/kiosk.ts +113 -0
  24. package/src/commands/maintenance.ts +106 -0
  25. package/src/commands/network.ts +129 -0
  26. package/src/commands/power.ts +163 -0
  27. package/src/commands/rpi.ts +45 -0
  28. package/src/commands/screenshot.ts +166 -0
  29. package/src/commands/serial.ts +17 -0
  30. package/src/commands/update.ts +124 -0
  31. package/src/index.ts +652 -0
  32. package/src/lib/config.ts +69 -0
  33. package/src/lib/identity.ts +40 -0
  34. package/src/lib/logger.ts +137 -0
  35. package/src/lib/platform.ts +10 -0
  36. package/src/lib/rpi.ts +180 -0
  37. package/src/lib/screens.ts +128 -0
  38. package/src/lib/types.ts +176 -0
  39. package/src/services/commands.ts +107 -0
  40. package/src/services/health.ts +161 -0
  41. package/src/services/kiosk.ts +395 -0
  42. package/src/services/localEvents.ts +60 -0
  43. package/src/services/logForwarder.ts +72 -0
  44. package/src/services/multiScreenKiosk.ts +324 -0
  45. package/src/services/oscBridge.ts +186 -0
  46. package/src/services/powerScheduler.ts +260 -0
  47. package/src/services/provisioning.ts +120 -0
  48. package/src/services/serialBridge.ts +230 -0
  49. package/src/services/serviceLauncher.ts +183 -0
  50. package/src/services/staticServer.ts +226 -0
  51. package/src/services/updater.ts +249 -0
  52. package/src/services/watchdog.ts +310 -0
  53. package/src/services/websocket.ts +152 -0
  54. package/tsconfig.json +28 -0
@@ -0,0 +1,183 @@
1
+ import { spawn, execSync } from 'child_process';
2
+ import type { ChildProcess } from 'child_process';
3
+ import { resolve } from 'path';
4
+ import type { Logger } from '../lib/logger.js';
5
+
6
+ interface ManagedService {
7
+ name: string;
8
+ cwd: string;
9
+ command: string;
10
+ args: string[];
11
+ port: number;
12
+ process: ChildProcess | null;
13
+ }
14
+
15
+ export class ServiceLauncher {
16
+ private services: ManagedService[] = [];
17
+ private logger: Logger;
18
+ private projectRoot: string;
19
+
20
+ constructor(logger: Logger, projectRoot: string) {
21
+ this.logger = logger;
22
+ this.projectRoot = projectRoot;
23
+ }
24
+
25
+ /**
26
+ * Kill any existing processes on managed ports, then start fresh.
27
+ */
28
+ async startAll(): Promise<void> {
29
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
30
+
31
+ this.services = [
32
+ {
33
+ name: 'server',
34
+ cwd: resolve(this.projectRoot, 'server'),
35
+ command: npmCmd,
36
+ args: ['run', 'dev'],
37
+ port: 3401,
38
+ process: null,
39
+ },
40
+ {
41
+ name: 'display',
42
+ cwd: resolve(this.projectRoot, 'display'),
43
+ command: npmCmd,
44
+ args: ['run', 'dev'],
45
+ port: 3403,
46
+ process: null,
47
+ },
48
+ ];
49
+
50
+ // Kill anything already on these ports for a clean start
51
+ for (const svc of this.services) {
52
+ await this.killProcessOnPort(svc.port);
53
+ }
54
+
55
+ // Small delay to let ports free up
56
+ await new Promise((r) => setTimeout(r, 1_000));
57
+
58
+ for (const svc of this.services) {
59
+ this.logger.info(`Starting ${svc.name} (port ${svc.port})...`);
60
+ svc.process = spawn(svc.command, svc.args, {
61
+ cwd: svc.cwd,
62
+ stdio: 'pipe',
63
+ detached: false,
64
+ shell: true,
65
+ });
66
+
67
+ svc.process.stdout?.on('data', (data: Buffer) => {
68
+ const lines = data.toString().trim();
69
+ if (lines) this.logger.debug(`[${svc.name}] ${lines}`);
70
+ });
71
+
72
+ svc.process.stderr?.on('data', (data: Buffer) => {
73
+ const lines = data.toString().trim();
74
+ if (lines) this.logger.debug(`[${svc.name}:err] ${lines}`);
75
+ });
76
+
77
+ svc.process.on('exit', (code) => {
78
+ this.logger.warn(`${svc.name} exited with code ${code}`);
79
+ svc.process = null;
80
+ });
81
+
82
+ svc.process.on('error', (err) => {
83
+ this.logger.error(`${svc.name} spawn error: ${err.message}`);
84
+ svc.process = null;
85
+ });
86
+ }
87
+
88
+ // Wait for each service to become reachable
89
+ for (const svc of this.services) {
90
+ await this.waitForPort(svc.name, svc.port, 60_000);
91
+ }
92
+
93
+ this.logger.info('All services started successfully');
94
+ }
95
+
96
+ /**
97
+ * Stop all managed services.
98
+ */
99
+ stopAll(): void {
100
+ for (const svc of this.services) {
101
+ if (svc.process) {
102
+ this.logger.info(`Stopping ${svc.name} (pid ${svc.process.pid})...`);
103
+ try {
104
+ if (process.platform === 'win32') {
105
+ spawn('taskkill', ['/pid', String(svc.process.pid), '/T', '/F'], {
106
+ stdio: 'ignore',
107
+ shell: true,
108
+ });
109
+ } else {
110
+ svc.process.kill('SIGTERM');
111
+ }
112
+ } catch {
113
+ // already dead
114
+ }
115
+ svc.process = null;
116
+ }
117
+ }
118
+ }
119
+
120
+ isRunning(name: string): boolean {
121
+ const svc = this.services.find((s) => s.name === name);
122
+ return svc?.process !== null && svc?.process?.exitCode === null;
123
+ }
124
+
125
+ private async killProcessOnPort(port: number): Promise<void> {
126
+ try {
127
+ if (process.platform === 'win32') {
128
+ // Find PID using the port on Windows
129
+ const result = execSync(
130
+ `netstat -ano | findstr :${port} | findstr LISTENING`,
131
+ { encoding: 'utf-8', timeout: 5_000 }
132
+ ).trim();
133
+ const lines = result.split('\n');
134
+ const pids = new Set<string>();
135
+ for (const line of lines) {
136
+ const parts = line.trim().split(/\s+/);
137
+ const pid = parts[parts.length - 1];
138
+ if (pid && pid !== '0') pids.add(pid);
139
+ }
140
+ for (const pid of pids) {
141
+ this.logger.info(`Killing existing process on port ${port} (pid ${pid})`);
142
+ try {
143
+ execSync(`taskkill /pid ${pid} /T /F`, { timeout: 5_000 });
144
+ } catch {
145
+ // process may already be gone
146
+ }
147
+ }
148
+ } else {
149
+ // Unix: use fuser or lsof
150
+ try {
151
+ execSync(`fuser -k ${port}/tcp`, { timeout: 5_000 });
152
+ this.logger.info(`Killed existing process on port ${port}`);
153
+ } catch {
154
+ // no process on port, that's fine
155
+ }
156
+ }
157
+ } catch {
158
+ // No process found on port — that's fine
159
+ }
160
+ }
161
+
162
+ private async waitForPort(name: string, port: number, timeoutMs: number): Promise<void> {
163
+ const start = Date.now();
164
+ const interval = 1_500;
165
+
166
+ while (Date.now() - start < timeoutMs) {
167
+ try {
168
+ const res = await fetch(`http://localhost:${port}/`, {
169
+ signal: AbortSignal.timeout(2_000),
170
+ });
171
+ if (res.status > 0) {
172
+ this.logger.info(`${name} is ready on port ${port}`);
173
+ return;
174
+ }
175
+ } catch {
176
+ // not ready yet
177
+ }
178
+ await new Promise((r) => setTimeout(r, interval));
179
+ }
180
+
181
+ this.logger.warn(`${name} did not become ready on port ${port} within ${timeoutMs}ms, continuing anyway`);
182
+ }
183
+ }
@@ -0,0 +1,226 @@
1
+ import { createServer, request as httpRequest, type IncomingMessage, type ServerResponse } from 'http';
2
+ import { createReadStream, statSync, existsSync } from 'fs';
3
+ import { resolve, extname, join } from 'path';
4
+ import { WebSocket, WebSocketServer } from 'ws';
5
+ import type { Logger } from '../lib/logger.js';
6
+
7
+ const MIME: Record<string, string> = {
8
+ '.html': 'text/html; charset=utf-8',
9
+ '.js': 'application/javascript',
10
+ '.css': 'text/css',
11
+ '.json': 'application/json',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.svg': 'image/svg+xml',
15
+ '.ico': 'image/x-icon',
16
+ '.woff': 'font/woff',
17
+ '.woff2': 'font/woff2',
18
+ '.ttf': 'font/ttf',
19
+ '.mp4': 'video/mp4',
20
+ '.webm': 'video/webm',
21
+ };
22
+
23
+ export class StaticServer {
24
+ private port: number;
25
+ private distPath: string;
26
+ private serverUrl: string;
27
+ private logger: Logger;
28
+ private server: ReturnType<typeof createServer> | null = null;
29
+
30
+ constructor(port: number, distPath: string, serverUrl: string, logger: Logger) {
31
+ this.port = port;
32
+ this.distPath = distPath;
33
+ this.serverUrl = serverUrl;
34
+ this.logger = logger;
35
+ }
36
+
37
+ start(): void {
38
+ if (!existsSync(this.distPath)) {
39
+ this.logger.warn(`StaticServer: dist path not found: ${this.distPath}`);
40
+ return;
41
+ }
42
+
43
+ this.server = createServer((req: IncomingMessage, res: ServerResponse) => {
44
+ this.handle(req, res);
45
+ });
46
+
47
+ // Proxy WebSocket upgrades (/ws/*) to the real server
48
+ this.server.on('upgrade', (req, socket, head) => {
49
+ const upstream = new URL(req.url || '/', this.serverUrl);
50
+ const wsUrl = upstream.toString().replace(/^http/, 'ws');
51
+ this.logger.debug(`WS proxy: ${req.url} → ${wsUrl}`);
52
+
53
+ const upstreamWs = new WebSocket(wsUrl, {
54
+ headers: { ...req.headers, host: new URL(this.serverUrl).host },
55
+ });
56
+
57
+ upstreamWs.on('open', () => {
58
+ const wss = new WebSocketServer({ noServer: true });
59
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
60
+ // Pipe client ↔ upstream
61
+ clientWs.on('message', (data, isBinary) => {
62
+ if (upstreamWs.readyState === WebSocket.OPEN) {
63
+ upstreamWs.send(data, { binary: isBinary });
64
+ }
65
+ });
66
+ upstreamWs.on('message', (data, isBinary) => {
67
+ if (clientWs.readyState === WebSocket.OPEN) {
68
+ clientWs.send(data, { binary: isBinary });
69
+ }
70
+ });
71
+ clientWs.on('close', () => upstreamWs.close());
72
+ upstreamWs.on('close', () => clientWs.close());
73
+ clientWs.on('error', () => upstreamWs.close());
74
+ upstreamWs.on('error', () => clientWs.close());
75
+ });
76
+ });
77
+
78
+ upstreamWs.on('error', (err) => {
79
+ this.logger.warn(`WS proxy error: ${err.message}`);
80
+ socket.destroy();
81
+ });
82
+ });
83
+
84
+ this.server.listen(this.port, '0.0.0.0', () => {
85
+ this.logger.info(`Display static server listening on port ${this.port} (proxy → ${this.serverUrl})`);
86
+ });
87
+
88
+ this.server.on('error', (err) => {
89
+ this.logger.error(`StaticServer error: ${err.message}`);
90
+ });
91
+ }
92
+
93
+ stop(): void {
94
+ if (this.server) {
95
+ this.server.close();
96
+ this.server = null;
97
+ this.logger.info('Display static server stopped');
98
+ }
99
+ }
100
+
101
+ private handle(req: IncomingMessage, res: ServerResponse): void {
102
+ const rawUrl = req.url || '/';
103
+ const path = rawUrl.split('?')[0];
104
+
105
+ // Proxy /api/* and /storage/* to the real server
106
+ if (path.startsWith('/api/') || path.startsWith('/storage/')) {
107
+ this.proxyHttp(req, res);
108
+ return;
109
+ }
110
+
111
+ // Proxy /display/* to the server first (always get latest build).
112
+ // Only fall back to local files if server is unreachable.
113
+ if (path.startsWith('/display')) {
114
+ this.proxyWithFallback(req, res);
115
+ return;
116
+ }
117
+
118
+ // Serve static files for non-display paths
119
+ this.serveStatic(req, res);
120
+ }
121
+
122
+ private proxyHttp(req: IncomingMessage, res: ServerResponse): void {
123
+ const target = new URL(this.serverUrl);
124
+ const options = {
125
+ hostname: target.hostname,
126
+ port: target.port || 80,
127
+ path: req.url,
128
+ method: req.method,
129
+ headers: { ...req.headers, host: target.host },
130
+ };
131
+
132
+ const proxy = httpRequest(options, (upstreamRes) => {
133
+ res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
134
+ upstreamRes.pipe(res);
135
+ });
136
+
137
+ proxy.on('error', (err) => {
138
+ this.logger.warn(`HTTP proxy error: ${err.message}`);
139
+ if (!res.headersSent) {
140
+ res.writeHead(502);
141
+ res.end('Bad Gateway');
142
+ }
143
+ });
144
+
145
+ req.pipe(proxy);
146
+ }
147
+
148
+ /**
149
+ * Try to proxy the request to the real server (latest display build).
150
+ * If the server is unreachable, fall back to local static files.
151
+ */
152
+ private proxyWithFallback(req: IncomingMessage, res: ServerResponse): void {
153
+ const target = new URL(this.serverUrl);
154
+ let fell = false;
155
+ const fallback = () => {
156
+ if (fell || res.headersSent) return;
157
+ fell = true;
158
+ this.logger.debug(`Proxy failed for ${req.url}, serving from local files`);
159
+ this.serveStatic(req, res);
160
+ };
161
+
162
+ const options = {
163
+ hostname: target.hostname,
164
+ port: target.port || 80,
165
+ path: req.url,
166
+ method: req.method,
167
+ headers: { ...req.headers, host: target.host },
168
+ timeout: 3000,
169
+ };
170
+
171
+ const proxy = httpRequest(options, (upstreamRes) => {
172
+ const statusCode = upstreamRes.statusCode || 502;
173
+
174
+ // If upstream display route is unavailable/misconfigured,
175
+ // serve the local bundled display instead of surfacing server errors.
176
+ if (statusCode >= 400) {
177
+ upstreamRes.resume();
178
+ fallback();
179
+ return;
180
+ }
181
+
182
+ res.writeHead(statusCode, upstreamRes.headers);
183
+ upstreamRes.pipe(res);
184
+ });
185
+
186
+ proxy.on('error', fallback);
187
+ proxy.on('timeout', () => { proxy.destroy(); fallback(); });
188
+
189
+ req.pipe(proxy);
190
+ }
191
+
192
+ private serveStatic(req: IncomingMessage, res: ServerResponse): void {
193
+ const rawUrl = (req.url || '/').split('?')[0];
194
+
195
+ // Strip /display prefix to map to dist files
196
+ let filePath = rawUrl.startsWith('/display')
197
+ ? rawUrl.slice('/display'.length) || '/'
198
+ : rawUrl;
199
+
200
+ let absPath = resolve(this.distPath, filePath.replace(/^\//, ''));
201
+
202
+ // Security: stay inside distPath
203
+ if (!absPath.startsWith(this.distPath)) {
204
+ res.writeHead(403);
205
+ res.end('Forbidden');
206
+ return;
207
+ }
208
+
209
+ // SPA fallback
210
+ if (!existsSync(absPath) || statSync(absPath).isDirectory()) {
211
+ absPath = join(this.distPath, 'index.html');
212
+ }
213
+
214
+ if (!existsSync(absPath)) {
215
+ res.writeHead(404);
216
+ res.end('Not Found');
217
+ return;
218
+ }
219
+
220
+ const ext = extname(absPath).toLowerCase();
221
+ const mime = MIME[ext] || 'application/octet-stream';
222
+
223
+ res.writeHead(200, { 'Content-Type': mime });
224
+ createReadStream(absPath).pipe(res);
225
+ }
226
+ }
@@ -0,0 +1,249 @@
1
+ import { createWriteStream, createReadStream, existsSync, mkdirSync, renameSync, rmSync, readdirSync, statSync } from 'fs';
2
+ import { resolve, join } from 'path';
3
+ import { createHash } from 'crypto';
4
+ import { pipeline } from 'stream/promises';
5
+ import http from 'http';
6
+ import https from 'https';
7
+ import { execFileSync } from 'child_process';
8
+ import type { Logger } from '../lib/logger.js';
9
+
10
+ interface UpdatePaths {
11
+ current: string; // /opt/lightman/agent/
12
+ staging: string; // /opt/lightman/agent-staging/
13
+ backup: string; // /opt/lightman/agent-backup/
14
+ downloads: string; // /opt/lightman/agent-downloads/
15
+ }
16
+
17
+ interface UpdateStatus {
18
+ phase: 'idle' | 'downloading' | 'verifying' | 'installing' | 'restarting' | 'error';
19
+ version?: string;
20
+ progress?: number;
21
+ error?: string;
22
+ }
23
+
24
+ export class Updater {
25
+ private readonly logger: Logger;
26
+ private readonly paths: UpdatePaths;
27
+ private status: UpdateStatus = { phase: 'idle' };
28
+
29
+ constructor(logger: Logger, basePath?: string) {
30
+ this.logger = logger;
31
+ const base = basePath || '/opt/lightman';
32
+ this.paths = {
33
+ current: resolve(base, 'agent'),
34
+ staging: resolve(base, 'agent-staging'),
35
+ backup: resolve(base, 'agent-backup'),
36
+ downloads: resolve(base, 'agent-downloads'),
37
+ };
38
+ }
39
+
40
+ getStatus(): UpdateStatus {
41
+ return { ...this.status };
42
+ }
43
+
44
+ /**
45
+ * Returns true if the updater is in the middle of an install or download.
46
+ */
47
+ isBusy(): boolean {
48
+ return this.status.phase === 'downloading' ||
49
+ this.status.phase === 'verifying' ||
50
+ this.status.phase === 'installing';
51
+ }
52
+
53
+ /**
54
+ * Download a file from URL to a local temp path.
55
+ * Returns the local file path.
56
+ */
57
+ async download(url: string): Promise<string> {
58
+ // Validate URL protocol
59
+ const parsed = new URL(url);
60
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
61
+ throw new Error('Only http and https URLs are supported');
62
+ }
63
+
64
+ this.status = { ...this.status, phase: 'downloading' };
65
+ this.logger.info(`Downloading update from: ${url}`);
66
+
67
+ // Ensure downloads dir exists
68
+ if (!existsSync(this.paths.downloads)) {
69
+ mkdirSync(this.paths.downloads, { recursive: true });
70
+ }
71
+
72
+ const filename = `update-${Date.now()}.tar.gz`;
73
+ const filePath = join(this.paths.downloads, filename);
74
+
75
+ return new Promise<string>((resolvePromise, reject) => {
76
+ const client = parsed.protocol === 'https:' ? https : http;
77
+ const req = client.get(url, (res) => {
78
+ if (res.statusCode !== 200) {
79
+ reject(new Error(`Download failed with status ${res.statusCode}`));
80
+ return;
81
+ }
82
+
83
+ const ws = createWriteStream(filePath);
84
+ pipeline(res, ws)
85
+ .then(() => {
86
+ this.logger.info(`Download complete: ${filePath}`);
87
+ resolvePromise(filePath);
88
+ })
89
+ .catch(reject);
90
+ });
91
+
92
+ req.on('error', reject);
93
+ req.setTimeout(5 * 60 * 1000, () => {
94
+ req.destroy(new Error('Download timeout (5 minutes)'));
95
+ });
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Verify SHA256 checksum of a file.
101
+ */
102
+ async verify(filePath: string, expectedChecksum: string): Promise<boolean> {
103
+ this.status = { ...this.status, phase: 'verifying' };
104
+ this.logger.info(`Verifying checksum for: ${filePath}`);
105
+
106
+ const hash = createHash('sha256');
107
+ const stream = createReadStream(filePath);
108
+
109
+ await pipeline(stream, hash);
110
+ const actual = hash.digest('hex');
111
+
112
+ const match = actual === expectedChecksum.toLowerCase();
113
+ if (!match) {
114
+ this.logger.error(`Checksum mismatch: expected ${expectedChecksum}, got ${actual}`);
115
+ } else {
116
+ this.logger.info('Checksum verified OK');
117
+ }
118
+ return match;
119
+ }
120
+
121
+ /**
122
+ * Install update: extract tarball to staging, swap current -> backup, staging -> current, restart.
123
+ */
124
+ async install(tarballPath: string, version: string): Promise<void> {
125
+ this.status = { phase: 'installing', version };
126
+ this.logger.info(`Installing update v${version}...`);
127
+
128
+ // Clean staging dir
129
+ if (existsSync(this.paths.staging)) {
130
+ rmSync(this.paths.staging, { recursive: true, force: true });
131
+ }
132
+ mkdirSync(this.paths.staging, { recursive: true });
133
+
134
+ // Extract tarball to staging
135
+ try {
136
+ execFileSync('tar', ['-xzf', tarballPath, '-C', this.paths.staging], {
137
+ timeout: 60_000,
138
+ });
139
+ } catch (err) {
140
+ this.status = { phase: 'error', version, error: 'Extraction failed' };
141
+ // Clean up staging directory on extraction failure
142
+ try {
143
+ if (existsSync(this.paths.staging)) {
144
+ rmSync(this.paths.staging, { recursive: true, force: true });
145
+ }
146
+ } catch {
147
+ // Non-critical cleanup error
148
+ }
149
+ throw new Error(`Failed to extract tarball: ${err instanceof Error ? err.message : String(err)}`);
150
+ }
151
+
152
+ // Atomic swap: current -> backup, staging -> current
153
+ try {
154
+ // Remove old backup if it exists
155
+ if (existsSync(this.paths.backup)) {
156
+ rmSync(this.paths.backup, { recursive: true, force: true });
157
+ }
158
+
159
+ // Move current -> backup (preserve for rollback)
160
+ if (existsSync(this.paths.current)) {
161
+ renameSync(this.paths.current, this.paths.backup);
162
+ }
163
+
164
+ // Move staging -> current
165
+ renameSync(this.paths.staging, this.paths.current);
166
+
167
+ this.logger.info(`Update v${version} installed successfully`);
168
+ } catch (err) {
169
+ this.status = { phase: 'error', version, error: 'Swap failed' };
170
+ // Attempt recovery: if backup exists and current doesn't, restore backup
171
+ if (!existsSync(this.paths.current) && existsSync(this.paths.backup)) {
172
+ try {
173
+ renameSync(this.paths.backup, this.paths.current);
174
+ this.logger.warn('Recovered from failed swap using backup');
175
+ } catch {
176
+ this.logger.error('CRITICAL: Failed to recover from swap failure');
177
+ }
178
+ }
179
+ throw new Error(`Install swap failed: ${err instanceof Error ? err.message : String(err)}`);
180
+ }
181
+
182
+ // Clean up downloaded tarball
183
+ try {
184
+ rmSync(tarballPath, { force: true });
185
+ } catch {
186
+ // Non-critical
187
+ }
188
+
189
+ this.status = { phase: 'restarting', version };
190
+ }
191
+
192
+ /**
193
+ * Rollback to backup version.
194
+ */
195
+ async rollback(): Promise<void> {
196
+ this.logger.info('Rolling back to backup version...');
197
+
198
+ if (!existsSync(this.paths.backup)) {
199
+ throw new Error('No backup version available for rollback');
200
+ }
201
+
202
+ // Move current -> staging (temporary)
203
+ if (existsSync(this.paths.staging)) {
204
+ rmSync(this.paths.staging, { recursive: true, force: true });
205
+ }
206
+ if (existsSync(this.paths.current)) {
207
+ renameSync(this.paths.current, this.paths.staging);
208
+ }
209
+
210
+ // Move backup -> current
211
+ renameSync(this.paths.backup, this.paths.current);
212
+
213
+ // Clean up old current (now in staging)
214
+ if (existsSync(this.paths.staging)) {
215
+ rmSync(this.paths.staging, { recursive: true, force: true });
216
+ }
217
+
218
+ this.logger.info('Rollback complete');
219
+ }
220
+
221
+ /**
222
+ * Clean old downloads, keeping only the most recent 3.
223
+ */
224
+ cleanDownloads(): void {
225
+ try {
226
+ if (!existsSync(this.paths.downloads)) return;
227
+
228
+ const files = readdirSync(this.paths.downloads)
229
+ .filter(f => f.endsWith('.tar.gz'))
230
+ .map(f => ({
231
+ name: f,
232
+ path: join(this.paths.downloads, f),
233
+ mtime: statSync(join(this.paths.downloads, f)).mtimeMs,
234
+ }))
235
+ .sort((a, b) => b.mtime - a.mtime);
236
+
237
+ // Keep only 3 most recent
238
+ for (const file of files.slice(3)) {
239
+ try {
240
+ rmSync(file.path, { force: true });
241
+ } catch {
242
+ // Ignore cleanup errors
243
+ }
244
+ }
245
+ } catch {
246
+ // Non-critical
247
+ }
248
+ }
249
+ }