lightman-agent 1.0.4 → 1.0.6

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 (115) hide show
  1. package/agent.config.template.json +30 -30
  2. package/package.json +52 -52
  3. package/public/assets/index-CcBNCz6h.css +1 -1
  4. package/public/assets/index-D9QHMG8k.js +1 -0
  5. package/public/assets/index-H-8HDl46.js +1 -1
  6. package/public/assets/index-YodeiCia.css +1 -0
  7. package/public/assets/index-legacy-DWtNM8y7.js +41 -0
  8. package/public/assets/museum-map-CwVDA2z1.svg +4182 -0
  9. package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -0
  10. package/public/index.html +7 -2
  11. package/public/templates/custom08/elements/back-button.svg +20 -0
  12. package/public/templates/custom08/elements/base-map-background.svg +37 -0
  13. package/public/templates/custom08/elements/base-map.svg +1191 -0
  14. package/public/templates/custom08/elements/gallery-1-2-3-info-panel.svg +236 -0
  15. package/public/templates/custom08/elements/gallery-4-5-6-7-info-panel.svg +266 -0
  16. package/public/templates/custom08/elements/gallery-8-9-info-panel.svg +274 -0
  17. package/public/templates/custom08/elements/gallery-labels/_nav-map-styles.css +554 -0
  18. package/public/templates/custom08/elements/gallery-labels/_styles.css +556 -0
  19. package/public/templates/custom08/elements/gallery-labels/gallery-1.svg +35 -0
  20. package/public/templates/custom08/elements/gallery-labels/gallery-2.svg +34 -0
  21. package/public/templates/custom08/elements/gallery-labels/gallery-3.svg +34 -0
  22. package/public/templates/custom08/elements/gallery-labels/gallery-4.svg +37 -0
  23. package/public/templates/custom08/elements/gallery-labels/gallery-5.svg +34 -0
  24. package/public/templates/custom08/elements/gallery-labels/gallery-6.svg +34 -0
  25. package/public/templates/custom08/elements/gallery-labels/gallery-7.svg +34 -0
  26. package/public/templates/custom08/elements/gallery-labels/gallery-8.svg +37 -0
  27. package/public/templates/custom08/elements/gallery-labels/gallery-9.svg +34 -0
  28. package/public/templates/custom08/elements/hand-hint.png +0 -0
  29. package/public/templates/custom08/elements/idle-screen-bg.svg +5 -0
  30. package/public/templates/custom08/elements/idle-screen-map.svg +627 -0
  31. package/public/templates/custom08/elements/idle-screen-text.svg +350 -0
  32. package/public/templates/custom08/elements/key-map-1.svg +986 -0
  33. package/public/templates/custom08/elements/key-map-2.svg +1018 -0
  34. package/public/templates/custom08/elements/key-map-3.svg +1019 -0
  35. package/public/templates/custom08/elements/key-map-combined.svg +1001 -0
  36. package/public/templates/custom08/elements/map-highlight-marker.svg +11 -0
  37. package/public/templates/custom08/elements/map-pin-marker.svg +15 -0
  38. package/public/templates/custom08/elements/map-teardrop-star-marker.svg +13 -0
  39. package/public/templates/custom08/elements/nav-circle-galleries-1-3.svg +21 -0
  40. package/public/templates/custom08/elements/nav-circle-galleries-4-7.svg +24 -0
  41. package/public/templates/custom08/elements/nav-circle-galleries-8-9.svg +20 -0
  42. package/public/templates/custom08/elements/section1-map.svg +1435 -0
  43. package/public/templates/custom08/elements/section2-map.svg +1724 -0
  44. package/public/templates/custom08/elements/section3-map.svg +1295 -0
  45. package/public/templates/custom08/fonts/CabinetGrotesk-Variable.ttf +0 -0
  46. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.12_PM.png +0 -0
  47. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.56_PM.png +0 -0
  48. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.24.24_PM.png +0 -0
  49. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.31.58_PM.jpg +0 -0
  50. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.11_PM.jpg +0 -0
  51. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.36_PM.jpg +0 -0
  52. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.48_PM.jpg +0 -0
  53. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.59_PM.jpg +0 -0
  54. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.15_PM.jpg +0 -0
  55. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.27_PM.jpg +0 -0
  56. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.34_PM.jpg +0 -0
  57. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.42_PM.jpg +0 -0
  58. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.50_PM.jpg +0 -0
  59. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.58_PM.jpg +0 -0
  60. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.04_PM.jpg +0 -0
  61. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.11_PM.jpg +0 -0
  62. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.20_PM.jpg +0 -0
  63. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.57_PM.jpg +0 -0
  64. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.03_PM.jpg +0 -0
  65. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.16_PM.jpg +0 -0
  66. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.23_PM.jpg +0 -0
  67. package/public/templates/custom08/images/highlights/prologue-highlight.png +0 -0
  68. package/scripts/guardian.ps1 +75 -75
  69. package/scripts/install-linux.sh +134 -134
  70. package/scripts/install-rpi.sh +117 -117
  71. package/scripts/install-windows.ps1 +513 -512
  72. package/scripts/launch-kiosk.vbs +101 -101
  73. package/scripts/lightman-agent.logrotate +12 -12
  74. package/scripts/lightman-agent.service +38 -38
  75. package/scripts/lightman-shell.bat +107 -107
  76. package/scripts/reinstall-windows.ps1 +26 -26
  77. package/scripts/restore-desktop.ps1 +32 -32
  78. package/scripts/setup.ps1 +116 -116
  79. package/scripts/setup.sh +115 -115
  80. package/scripts/sync-display.mjs +20 -0
  81. package/scripts/uninstall-linux.sh +50 -50
  82. package/scripts/uninstall-windows.ps1 +54 -54
  83. package/src/commands/display.ts +177 -177
  84. package/src/commands/kiosk.ts +113 -113
  85. package/src/commands/maintenance.ts +106 -106
  86. package/src/commands/network.ts +129 -129
  87. package/src/commands/power.ts +163 -163
  88. package/src/commands/rpi.ts +45 -45
  89. package/src/commands/screenshot.ts +166 -166
  90. package/src/commands/serial.ts +17 -17
  91. package/src/commands/update.ts +124 -124
  92. package/src/index.ts +652 -652
  93. package/src/lib/config.ts +69 -69
  94. package/src/lib/identity.ts +40 -40
  95. package/src/lib/logger.ts +137 -137
  96. package/src/lib/platform.ts +10 -10
  97. package/src/lib/rpi.ts +180 -180
  98. package/src/lib/screens.ts +128 -128
  99. package/src/lib/types.ts +176 -176
  100. package/src/services/commands.ts +107 -107
  101. package/src/services/health.ts +161 -161
  102. package/src/services/kiosk.ts +384 -384
  103. package/src/services/localEvents.ts +60 -60
  104. package/src/services/logForwarder.ts +72 -72
  105. package/src/services/multiScreenKiosk.ts +324 -324
  106. package/src/services/oscBridge.ts +186 -186
  107. package/src/services/powerScheduler.ts +260 -260
  108. package/src/services/provisioning.ts +120 -120
  109. package/src/services/serialBridge.ts +230 -230
  110. package/src/services/serviceLauncher.ts +183 -183
  111. package/src/services/staticServer.ts +226 -226
  112. package/src/services/updater.ts +249 -249
  113. package/src/services/watchdog.ts +310 -310
  114. package/src/services/websocket.ts +152 -152
  115. package/tsconfig.json +28 -28
@@ -1,249 +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
- }
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
+ }