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.
- package/agent.config.template.json +30 -30
- package/package.json +52 -52
- package/public/assets/index-CcBNCz6h.css +1 -1
- package/public/assets/index-D9QHMG8k.js +1 -0
- package/public/assets/index-H-8HDl46.js +1 -1
- package/public/assets/index-YodeiCia.css +1 -0
- package/public/assets/index-legacy-DWtNM8y7.js +41 -0
- package/public/assets/museum-map-CwVDA2z1.svg +4182 -0
- package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -0
- package/public/index.html +7 -2
- package/public/templates/custom08/elements/back-button.svg +20 -0
- package/public/templates/custom08/elements/base-map-background.svg +37 -0
- package/public/templates/custom08/elements/base-map.svg +1191 -0
- package/public/templates/custom08/elements/gallery-1-2-3-info-panel.svg +236 -0
- package/public/templates/custom08/elements/gallery-4-5-6-7-info-panel.svg +266 -0
- package/public/templates/custom08/elements/gallery-8-9-info-panel.svg +274 -0
- package/public/templates/custom08/elements/gallery-labels/_nav-map-styles.css +554 -0
- package/public/templates/custom08/elements/gallery-labels/_styles.css +556 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-1.svg +35 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-2.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-3.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-4.svg +37 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-5.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-6.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-7.svg +34 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-8.svg +37 -0
- package/public/templates/custom08/elements/gallery-labels/gallery-9.svg +34 -0
- package/public/templates/custom08/elements/hand-hint.png +0 -0
- package/public/templates/custom08/elements/idle-screen-bg.svg +5 -0
- package/public/templates/custom08/elements/idle-screen-map.svg +627 -0
- package/public/templates/custom08/elements/idle-screen-text.svg +350 -0
- package/public/templates/custom08/elements/key-map-1.svg +986 -0
- package/public/templates/custom08/elements/key-map-2.svg +1018 -0
- package/public/templates/custom08/elements/key-map-3.svg +1019 -0
- package/public/templates/custom08/elements/key-map-combined.svg +1001 -0
- package/public/templates/custom08/elements/map-highlight-marker.svg +11 -0
- package/public/templates/custom08/elements/map-pin-marker.svg +15 -0
- package/public/templates/custom08/elements/map-teardrop-star-marker.svg +13 -0
- package/public/templates/custom08/elements/nav-circle-galleries-1-3.svg +21 -0
- package/public/templates/custom08/elements/nav-circle-galleries-4-7.svg +24 -0
- package/public/templates/custom08/elements/nav-circle-galleries-8-9.svg +20 -0
- package/public/templates/custom08/elements/section1-map.svg +1435 -0
- package/public/templates/custom08/elements/section2-map.svg +1724 -0
- package/public/templates/custom08/elements/section3-map.svg +1295 -0
- package/public/templates/custom08/fonts/CabinetGrotesk-Variable.ttf +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.12_PM.png +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.56_PM.png +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.24.24_PM.png +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.31.58_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.11_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.36_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.48_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.59_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.15_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.27_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.34_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.42_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.50_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.58_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.04_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.11_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.20_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.57_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.03_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.16_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.23_PM.jpg +0 -0
- package/public/templates/custom08/images/highlights/prologue-highlight.png +0 -0
- package/scripts/guardian.ps1 +75 -75
- package/scripts/install-linux.sh +134 -134
- package/scripts/install-rpi.sh +117 -117
- package/scripts/install-windows.ps1 +513 -512
- package/scripts/launch-kiosk.vbs +101 -101
- package/scripts/lightman-agent.logrotate +12 -12
- package/scripts/lightman-agent.service +38 -38
- package/scripts/lightman-shell.bat +107 -107
- package/scripts/reinstall-windows.ps1 +26 -26
- package/scripts/restore-desktop.ps1 +32 -32
- package/scripts/setup.ps1 +116 -116
- package/scripts/setup.sh +115 -115
- package/scripts/sync-display.mjs +20 -0
- package/scripts/uninstall-linux.sh +50 -50
- package/scripts/uninstall-windows.ps1 +54 -54
- package/src/commands/display.ts +177 -177
- package/src/commands/kiosk.ts +113 -113
- package/src/commands/maintenance.ts +106 -106
- package/src/commands/network.ts +129 -129
- package/src/commands/power.ts +163 -163
- package/src/commands/rpi.ts +45 -45
- package/src/commands/screenshot.ts +166 -166
- package/src/commands/serial.ts +17 -17
- package/src/commands/update.ts +124 -124
- package/src/index.ts +652 -652
- package/src/lib/config.ts +69 -69
- package/src/lib/identity.ts +40 -40
- package/src/lib/logger.ts +137 -137
- package/src/lib/platform.ts +10 -10
- package/src/lib/rpi.ts +180 -180
- package/src/lib/screens.ts +128 -128
- package/src/lib/types.ts +176 -176
- package/src/services/commands.ts +107 -107
- package/src/services/health.ts +161 -161
- package/src/services/kiosk.ts +384 -384
- package/src/services/localEvents.ts +60 -60
- package/src/services/logForwarder.ts +72 -72
- package/src/services/multiScreenKiosk.ts +324 -324
- package/src/services/oscBridge.ts +186 -186
- package/src/services/powerScheduler.ts +260 -260
- package/src/services/provisioning.ts +120 -120
- package/src/services/serialBridge.ts +230 -230
- package/src/services/serviceLauncher.ts +183 -183
- package/src/services/staticServer.ts +226 -226
- package/src/services/updater.ts +249 -249
- package/src/services/watchdog.ts +310 -310
- package/src/services/websocket.ts +152 -152
- package/tsconfig.json +28 -28
package/src/services/updater.ts
CHANGED
|
@@ -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
|
+
}
|