lightman-agent 1.0.12 → 1.0.14

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/src/lib/config.ts CHANGED
@@ -1,90 +1,69 @@
1
- import { readFileSync, existsSync } from 'fs';
2
- import { resolve } from 'path';
3
- import { z } from 'zod';
4
- import type { AgentConfig } from './types.js';
5
-
6
- export function getDefaultKioskBrowserPath(): string {
7
- if (process.platform === 'win32') {
8
- const candidates = [
9
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
10
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
11
- 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
12
- 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
13
- ];
14
-
15
- for (const candidate of candidates) {
16
- if (existsSync(candidate)) {
17
- return candidate;
18
- }
19
- }
20
-
21
- return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
22
- }
23
-
24
- return 'chromium-browser';
25
- }
26
-
27
- const kioskSchema = z.object({
28
- browserPath: z.string().default(getDefaultKioskBrowserPath()),
29
- defaultUrl: z.string().url().default('http://localhost:3401/display'),
30
- extraArgs: z.array(z.string()).default([]),
31
- pollIntervalMs: z.number().int().min(1000).default(10_000),
32
- maxCrashesInWindow: z.number().int().min(1).default(10),
33
- crashWindowMs: z.number().int().min(10_000).default(300_000),
34
- shellMode: z.boolean().default(false),
35
- });
36
-
37
- const screenshotSchema = z.object({
38
- captureCommand: z.string().default('scrot'),
39
- quality: z.number().int().min(1).max(100).default(80),
40
- uploadEndpoint: z.string().default('/api/devices/{deviceId}/screenshot'),
41
- });
42
-
43
- const powerScheduleSchema = z.object({
44
- shutdownCron: z.string().optional(),
45
- startupCron: z.string().optional(),
46
- timezone: z.string().default(Intl.DateTimeFormat().resolvedOptions().timeZone),
47
- shutdownWarningSeconds: z.number().int().min(0).max(600).default(60),
48
- });
49
-
50
- const configSchema = z.object({
51
- serverUrl: z.string().url(),
52
- deviceSlug: z.string().min(1),
53
- healthIntervalMs: z.number().int().min(5000).default(60000),
54
- logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
55
- logFile: z.string().default('agent.log'),
56
- identityFile: z.string().default('.lightman-identity.json'),
57
- localServices: z.boolean().default(true),
58
- kiosk: kioskSchema.optional(),
59
- screenshot: screenshotSchema.optional(),
60
- powerSchedule: powerScheduleSchema.optional(),
61
- });
62
-
63
- export function loadConfig(configPath?: string): AgentConfig {
64
- const filePath = configPath || resolve(process.cwd(), 'agent.config.json');
65
-
66
- if (!existsSync(filePath)) {
67
- throw new Error(`Config file not found: ${filePath}`);
68
- }
69
-
70
- let raw: Record<string, unknown>;
71
- try {
72
- raw = JSON.parse(readFileSync(filePath, 'utf-8'));
73
- } catch (err) {
74
- throw new Error(`Invalid JSON in config file ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
75
- }
76
-
77
- // Apply environment overrides
78
- const merged = {
79
- ...raw,
80
- ...(process.env.LIGHTMAN_SERVER_URL && { serverUrl: process.env.LIGHTMAN_SERVER_URL }),
81
- ...(process.env.LIGHTMAN_DEVICE_SLUG && { deviceSlug: process.env.LIGHTMAN_DEVICE_SLUG }),
82
- ...(process.env.LIGHTMAN_HEALTH_INTERVAL && { healthIntervalMs: parseInt(process.env.LIGHTMAN_HEALTH_INTERVAL, 10) }),
83
- ...(process.env.LIGHTMAN_LOG_LEVEL && { logLevel: process.env.LIGHTMAN_LOG_LEVEL }),
84
- ...(process.env.LIGHTMAN_LOG_FILE && { logFile: process.env.LIGHTMAN_LOG_FILE }),
85
- ...(process.env.LIGHTMAN_IDENTITY_FILE && { identityFile: process.env.LIGHTMAN_IDENTITY_FILE }),
86
- };
87
-
88
- const result = configSchema.parse(merged);
89
- return result as AgentConfig;
90
- }
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { z } from 'zod';
4
+ import type { AgentConfig } from './types.js';
5
+
6
+ const kioskSchema = z.object({
7
+ browserPath: z.string().default('chromium-browser'),
8
+ defaultUrl: z.string().url().default('http://localhost:3401/display'),
9
+ extraArgs: z.array(z.string()).default([]),
10
+ pollIntervalMs: z.number().int().min(1000).default(10_000),
11
+ maxCrashesInWindow: z.number().int().min(1).default(10),
12
+ crashWindowMs: z.number().int().min(10_000).default(300_000),
13
+ shellMode: z.boolean().default(false),
14
+ });
15
+
16
+ const screenshotSchema = z.object({
17
+ captureCommand: z.string().default('scrot'),
18
+ quality: z.number().int().min(1).max(100).default(80),
19
+ uploadEndpoint: z.string().default('/api/devices/{deviceId}/screenshot'),
20
+ });
21
+
22
+ const powerScheduleSchema = z.object({
23
+ shutdownCron: z.string().optional(),
24
+ startupCron: z.string().optional(),
25
+ timezone: z.string().default(Intl.DateTimeFormat().resolvedOptions().timeZone),
26
+ shutdownWarningSeconds: z.number().int().min(0).max(600).default(60),
27
+ });
28
+
29
+ const configSchema = z.object({
30
+ serverUrl: z.string().url(),
31
+ deviceSlug: z.string().min(1),
32
+ healthIntervalMs: z.number().int().min(5000).default(60000),
33
+ logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
34
+ logFile: z.string().default('agent.log'),
35
+ identityFile: z.string().default('.lightman-identity.json'),
36
+ localServices: z.boolean().default(true),
37
+ kiosk: kioskSchema.optional(),
38
+ screenshot: screenshotSchema.optional(),
39
+ powerSchedule: powerScheduleSchema.optional(),
40
+ });
41
+
42
+ export function loadConfig(configPath?: string): AgentConfig {
43
+ const filePath = configPath || resolve(process.cwd(), 'agent.config.json');
44
+
45
+ if (!existsSync(filePath)) {
46
+ throw new Error(`Config file not found: ${filePath}`);
47
+ }
48
+
49
+ let raw: Record<string, unknown>;
50
+ try {
51
+ raw = JSON.parse(readFileSync(filePath, 'utf-8'));
52
+ } catch (err) {
53
+ throw new Error(`Invalid JSON in config file ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
54
+ }
55
+
56
+ // Apply environment overrides
57
+ const merged = {
58
+ ...raw,
59
+ ...(process.env.LIGHTMAN_SERVER_URL && { serverUrl: process.env.LIGHTMAN_SERVER_URL }),
60
+ ...(process.env.LIGHTMAN_DEVICE_SLUG && { deviceSlug: process.env.LIGHTMAN_DEVICE_SLUG }),
61
+ ...(process.env.LIGHTMAN_HEALTH_INTERVAL && { healthIntervalMs: parseInt(process.env.LIGHTMAN_HEALTH_INTERVAL, 10) }),
62
+ ...(process.env.LIGHTMAN_LOG_LEVEL && { logLevel: process.env.LIGHTMAN_LOG_LEVEL }),
63
+ ...(process.env.LIGHTMAN_LOG_FILE && { logFile: process.env.LIGHTMAN_LOG_FILE }),
64
+ ...(process.env.LIGHTMAN_IDENTITY_FILE && { identityFile: process.env.LIGHTMAN_IDENTITY_FILE }),
65
+ };
66
+
67
+ const result = configSchema.parse(merged);
68
+ return result as AgentConfig;
69
+ }
@@ -0,0 +1,155 @@
1
+ import { platform } from 'os';
2
+ import type { Logger } from '../lib/logger.js';
3
+ import type { WsClient } from './websocket.js';
4
+ import type { Updater } from './updater.js';
5
+ import type { Identity } from '../lib/types.js';
6
+
7
+ /**
8
+ * AutoUpdater — periodically polls the server to check if a newer agent version
9
+ * is available. If found, downloads, verifies, installs, and restarts.
10
+ *
11
+ * Check interval: 5 minutes (default).
12
+ * Uses device API key auth so it works without JWT.
13
+ */
14
+
15
+ const DEFAULT_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
16
+
17
+ export class AutoUpdater {
18
+ private readonly logger: Logger;
19
+ private readonly updater: Updater;
20
+ private readonly wsClient: WsClient;
21
+ private readonly serverUrl: string;
22
+ private readonly identity: Identity;
23
+ private readonly currentVersion: string;
24
+ private readonly checkIntervalMs: number;
25
+ private timer: ReturnType<typeof setInterval> | null = null;
26
+ private checking = false;
27
+
28
+ constructor(opts: {
29
+ logger: Logger;
30
+ updater: Updater;
31
+ wsClient: WsClient;
32
+ serverUrl: string;
33
+ identity: Identity;
34
+ currentVersion: string;
35
+ checkIntervalMs?: number;
36
+ }) {
37
+ this.logger = opts.logger;
38
+ this.updater = opts.updater;
39
+ this.wsClient = opts.wsClient;
40
+ this.serverUrl = opts.serverUrl;
41
+ this.identity = opts.identity;
42
+ this.currentVersion = opts.currentVersion;
43
+ this.checkIntervalMs = opts.checkIntervalMs || DEFAULT_CHECK_INTERVAL_MS;
44
+ }
45
+
46
+ start(): void {
47
+ this.logger.info(`[AutoUpdate] Started — checking every ${Math.round(this.checkIntervalMs / 1000)}s (current: v${this.currentVersion})`);
48
+
49
+ // Initial check after 30s (let everything else boot first)
50
+ setTimeout(() => this.check(), 30_000);
51
+
52
+ this.timer = setInterval(() => this.check(), this.checkIntervalMs);
53
+ }
54
+
55
+ stop(): void {
56
+ if (this.timer) {
57
+ clearInterval(this.timer);
58
+ this.timer = null;
59
+ }
60
+ }
61
+
62
+ private async check(): Promise<void> {
63
+ if (this.checking || this.updater.isBusy()) return;
64
+ this.checking = true;
65
+
66
+ try {
67
+ const plat = platform() === 'win32' ? 'windows' : 'linux';
68
+ const url = `${this.serverUrl}/api/agent/check-update?current_version=${encodeURIComponent(this.currentVersion)}&platform=${plat}`;
69
+
70
+ const res = await fetch(url, {
71
+ headers: { 'Authorization': `Bearer ${this.identity.apiKey}` },
72
+ });
73
+
74
+ if (!res.ok) {
75
+ this.logger.debug(`[AutoUpdate] Check failed: HTTP ${res.status}`);
76
+ return;
77
+ }
78
+
79
+ const json = await res.json() as { success: boolean; data: {
80
+ update_available: boolean;
81
+ version?: string;
82
+ checksum?: string;
83
+ download_url?: string;
84
+ }};
85
+
86
+ if (!json.success || !json.data.update_available) {
87
+ this.logger.debug('[AutoUpdate] No update available');
88
+ return;
89
+ }
90
+
91
+ const { version, checksum, download_url } = json.data;
92
+ if (!version || !checksum || !download_url) {
93
+ this.logger.warn('[AutoUpdate] Server returned incomplete update info');
94
+ return;
95
+ }
96
+
97
+ this.logger.info(`[AutoUpdate] New version available: v${version} (current: v${this.currentVersion})`);
98
+
99
+ // Notify server
100
+ this.wsClient.send({
101
+ type: 'agent:update_status',
102
+ payload: { phase: 'downloading', version },
103
+ timestamp: Date.now(),
104
+ });
105
+
106
+ // Build full download URL
107
+ const fullUrl = download_url.startsWith('http')
108
+ ? download_url
109
+ : `${this.serverUrl}${download_url}`;
110
+
111
+ // Download
112
+ const filePath = await this.updater.download(fullUrl);
113
+
114
+ // Verify
115
+ this.wsClient.send({
116
+ type: 'agent:update_status',
117
+ payload: { phase: 'verifying', version },
118
+ timestamp: Date.now(),
119
+ });
120
+ const valid = await this.updater.verify(filePath, checksum);
121
+ if (!valid) {
122
+ this.wsClient.send({
123
+ type: 'agent:update_status',
124
+ payload: { phase: 'error', version, error: 'Checksum mismatch' },
125
+ timestamp: Date.now(),
126
+ });
127
+ this.logger.error('[AutoUpdate] Checksum mismatch — aborting');
128
+ return;
129
+ }
130
+
131
+ // Install
132
+ this.wsClient.send({
133
+ type: 'agent:update_status',
134
+ payload: { phase: 'installing', version },
135
+ timestamp: Date.now(),
136
+ });
137
+ await this.updater.install(filePath, version);
138
+ this.updater.cleanDownloads();
139
+
140
+ // Restart
141
+ this.wsClient.send({
142
+ type: 'agent:update_status',
143
+ payload: { phase: 'restarting', version },
144
+ timestamp: Date.now(),
145
+ });
146
+ this.logger.info(`[AutoUpdate] v${version} installed. Restarting in 2s...`);
147
+ setTimeout(() => process.exit(0), 2000);
148
+ } catch (err) {
149
+ const msg = err instanceof Error ? err.message : String(err);
150
+ this.logger.error(`[AutoUpdate] Error: ${msg}`);
151
+ } finally {
152
+ this.checking = false;
153
+ }
154
+ }
155
+ }