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,176 @@
1
+ // --- Agent Configuration ---
2
+ export interface AgentConfig {
3
+ serverUrl: string;
4
+ deviceSlug: string;
5
+ healthIntervalMs: number;
6
+ logLevel: 'debug' | 'info' | 'warn' | 'error';
7
+ logFile: string;
8
+ identityFile: string;
9
+ /** When false, agent runs in kiosk-only mode — no local server/display processes. Default: true */
10
+ localServices: boolean;
11
+ kiosk?: KioskConfig;
12
+ screenshot?: ScreenshotConfig;
13
+ powerSchedule?: PowerScheduleConfig;
14
+ /** Port for the local hardware event WebSocket server (default: 3402) */
15
+ localEventsPort?: number;
16
+ }
17
+
18
+ // --- Device Identity (persisted locally) ---
19
+ export interface Identity {
20
+ deviceId: string;
21
+ apiKey: string;
22
+ }
23
+
24
+ // --- WebSocket Messages ---
25
+ export interface WsMessage {
26
+ type: string;
27
+ payload?: Record<string, unknown>;
28
+ timestamp: number;
29
+ }
30
+
31
+ // --- Health Report ---
32
+ export interface HealthReport {
33
+ cpuUsage: number;
34
+ memTotal: number;
35
+ memUsed: number;
36
+ memPercent: number;
37
+ diskTotal: number;
38
+ diskUsed: number;
39
+ diskPercent: number;
40
+ cpuTemp: number | null;
41
+ uptime: number;
42
+ agentVersion: string;
43
+ // RPi-specific fields (optional, only present on Raspberry Pi)
44
+ gpuTemp?: number | null;
45
+ throttled?: number | null;
46
+ sdCardReadOnly?: boolean;
47
+ // Network info (optional)
48
+ network?: {
49
+ interface: string;
50
+ ip: string;
51
+ mac: string;
52
+ serverLatencyMs: number | null;
53
+ };
54
+ }
55
+
56
+ // --- Command Execution ---
57
+ export interface CommandRequest {
58
+ id: string;
59
+ command: string;
60
+ args?: Record<string, unknown>;
61
+ timeout?: number;
62
+ }
63
+
64
+ export interface CommandResult {
65
+ id: string;
66
+ command: string;
67
+ success: boolean;
68
+ data?: Record<string, unknown>;
69
+ error?: string;
70
+ durationMs: number;
71
+ }
72
+
73
+ // --- Kiosk Configuration ---
74
+ export interface KioskConfig {
75
+ browserPath: string;
76
+ defaultUrl: string;
77
+ extraArgs: string[];
78
+ pollIntervalMs: number;
79
+ maxCrashesInWindow: number;
80
+ crashWindowMs: number;
81
+ /** Shell replacement mode: Chrome is launched by the Windows shell (lightman-shell.bat),
82
+ * not by the agent. Agent only manages URL changes and monitors Chrome via process list. */
83
+ shellMode?: boolean;
84
+ }
85
+
86
+ // --- Multi-Screen Configuration ---
87
+
88
+ /** Mapping of a physical screen to a URL (stored in device config, pushed from admin) */
89
+ export interface ScreenMapping {
90
+ /** Hardware display ID, e.g. "\\\\.\\DISPLAY1" or "HDMI-1" */
91
+ hardwareId: string;
92
+ /** URL to open on this screen */
93
+ url: string;
94
+ /** Optional label for admin display */
95
+ label?: string;
96
+ }
97
+
98
+ // --- Kiosk Status ---
99
+ export interface KioskStatus {
100
+ running: boolean;
101
+ pid: number | null;
102
+ url: string | null;
103
+ crashCount: number;
104
+ crashLoopDetected: boolean;
105
+ uptimeMs: number | null;
106
+ }
107
+
108
+ /** Status for multi-screen kiosk */
109
+ export interface MultiScreenKioskStatus {
110
+ screens: SingleScreenStatus[];
111
+ }
112
+
113
+ export interface SingleScreenStatus {
114
+ hardwareId: string;
115
+ url: string | null;
116
+ running: boolean;
117
+ pid: number | null;
118
+ uptimeMs: number | null;
119
+ }
120
+
121
+ // --- Screenshot Configuration ---
122
+ export interface ScreenshotConfig {
123
+ captureCommand: string;
124
+ quality: number;
125
+ uploadEndpoint: string;
126
+ }
127
+
128
+ // --- Command Handler Function ---
129
+ export type CommandHandler = (
130
+ args?: Record<string, unknown>
131
+ ) => Promise<Record<string, unknown> | void>;
132
+
133
+ // --- Log Forwarding ---
134
+ export interface LogEntry {
135
+ timestamp: string;
136
+ level: 'debug' | 'info' | 'warn' | 'error';
137
+ message: string;
138
+ source: string;
139
+ }
140
+
141
+ // --- Power Schedule ---
142
+ export interface PowerScheduleConfig {
143
+ /** Cron expression for shutdown (e.g., "0 19 * * *" = 7 PM daily) */
144
+ shutdownCron?: string;
145
+ /** Cron expression for startup prep — agent uses this only for logging; actual wake is via WOL */
146
+ startupCron?: string;
147
+ /** Timezone for cron expressions (e.g., "Asia/Kolkata"). Defaults to system timezone. */
148
+ timezone?: string;
149
+ /** Seconds before shutdown to warn via WebSocket (default: 60) */
150
+ shutdownWarningSeconds?: number;
151
+ }
152
+
153
+ // --- Watchdog & Self-Healing ---
154
+ export interface WatchdogConfig {
155
+ checkIntervalMs: number;
156
+ kioskCrashCooldownMs: number;
157
+ highMemoryThresholdMb: number;
158
+ highMemoryCooldownMs: number;
159
+ highDiskThresholdPercent: number;
160
+ highDiskCooldownMs: number;
161
+ wsDisconnectedThresholdMs: number;
162
+ wsDisconnectedCooldownMs: number;
163
+ }
164
+
165
+ export interface CrashReport {
166
+ process: string;
167
+ exitCode: number | null;
168
+ signal: string | null;
169
+ timestamp: string;
170
+ system: {
171
+ memPercent: number;
172
+ diskPercent: number;
173
+ cpuUsage: number;
174
+ uptime: number;
175
+ };
176
+ }
@@ -0,0 +1,107 @@
1
+ import type {
2
+ CommandRequest,
3
+ CommandResult,
4
+ CommandHandler,
5
+ WsMessage,
6
+ } from '../lib/types.js';
7
+ import type { WsClient } from './websocket.js';
8
+ import type { Logger } from '../lib/logger.js';
9
+
10
+ export class CommandExecutor {
11
+ private registry = new Map<string, CommandHandler>();
12
+ private wsClient: WsClient;
13
+ private logger: Logger;
14
+
15
+ constructor(wsClient: WsClient, logger: Logger) {
16
+ this.wsClient = wsClient;
17
+ this.logger = logger;
18
+ }
19
+
20
+ register(command: string, handler: CommandHandler): void {
21
+ this.registry.set(command, handler);
22
+ this.logger.debug(`Command registered: ${command}`);
23
+ }
24
+
25
+ getRegisteredCommands(): string[] {
26
+ return Array.from(this.registry.keys());
27
+ }
28
+
29
+ /**
30
+ * Handle an incoming command message from the server.
31
+ * Lifecycle: validate → ack → execute → result
32
+ */
33
+ async handleCommand(msg: WsMessage): Promise<void> {
34
+ const request = msg.payload as unknown as CommandRequest;
35
+
36
+ if (!request || !request.id || !request.command) {
37
+ this.logger.warn('Invalid command request:', msg);
38
+ return;
39
+ }
40
+
41
+ this.logger.info(`Command received: ${request.command} (${request.id})`);
42
+
43
+ // Check if command is registered
44
+ const handler = this.registry.get(request.command);
45
+ if (!handler) {
46
+ this.sendResult({
47
+ id: request.id,
48
+ command: request.command,
49
+ success: false,
50
+ error: `Unknown command: ${request.command}`,
51
+ durationMs: 0,
52
+ });
53
+ return;
54
+ }
55
+
56
+ // Send ack
57
+ this.wsClient.send({
58
+ type: 'agent:command_ack',
59
+ payload: { id: request.id, command: request.command },
60
+ timestamp: Date.now(),
61
+ });
62
+
63
+ // Execute with timeout
64
+ const start = Date.now();
65
+ const timeout = request.timeout || 30_000;
66
+ let timeoutId: NodeJS.Timeout | undefined;
67
+
68
+ try {
69
+ const data = await Promise.race([
70
+ handler(request.args),
71
+ new Promise<never>((_, reject) => {
72
+ timeoutId = setTimeout(() => reject(new Error('Command timed out')), timeout);
73
+ }),
74
+ ]);
75
+ clearTimeout(timeoutId);
76
+
77
+ this.sendResult({
78
+ id: request.id,
79
+ command: request.command,
80
+ success: true,
81
+ data: (data as Record<string, unknown>) || {},
82
+ durationMs: Date.now() - start,
83
+ });
84
+ } catch (err) {
85
+ clearTimeout(timeoutId);
86
+ this.sendResult({
87
+ id: request.id,
88
+ command: request.command,
89
+ success: false,
90
+ error: err instanceof Error ? err.message : String(err),
91
+ durationMs: Date.now() - start,
92
+ });
93
+ }
94
+ }
95
+
96
+ private sendResult(result: CommandResult): void {
97
+ this.logger.info(
98
+ `Command result: ${result.command} (${result.id}) → ${result.success ? 'OK' : 'FAIL'} in ${result.durationMs}ms`
99
+ );
100
+
101
+ this.wsClient.send({
102
+ type: 'agent:command_result',
103
+ payload: result as unknown as Record<string, unknown>,
104
+ timestamp: Date.now(),
105
+ });
106
+ }
107
+ }
@@ -0,0 +1,161 @@
1
+ import si from 'systeminformation';
2
+ import net from 'net';
3
+ import { URL } from 'url';
4
+ import type { HealthReport, WsMessage } from '../lib/types.js';
5
+ import type { WsClient } from './websocket.js';
6
+ import type { Logger } from '../lib/logger.js';
7
+ import { readFileSync } from 'fs';
8
+ import { resolve } from 'path';
9
+ import { isRaspberryPi, getGpuTemp, getThrottled, isSdCardReadOnly } from '../lib/rpi.js';
10
+
11
+ export class HealthMonitor {
12
+ private wsClient: WsClient;
13
+ private logger: Logger;
14
+ private intervalMs: number;
15
+ private timer: NodeJS.Timeout | null = null;
16
+ private agentVersion: string;
17
+ private serverUrl: string;
18
+
19
+ constructor(wsClient: WsClient, logger: Logger, intervalMs: number, serverUrl?: string) {
20
+ this.wsClient = wsClient;
21
+ this.logger = logger;
22
+ this.intervalMs = intervalMs;
23
+ this.serverUrl = serverUrl || '';
24
+
25
+ // Read version from package.json
26
+ try {
27
+ const pkg = JSON.parse(
28
+ readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8')
29
+ );
30
+ this.agentVersion = pkg.version || '0.0.0';
31
+ } catch {
32
+ this.agentVersion = '0.0.0';
33
+ }
34
+ }
35
+
36
+ start(): void {
37
+ this.logger.info(`Health monitor started (interval: ${this.intervalMs}ms)`);
38
+
39
+ // Send initial report after a short delay
40
+ setTimeout(() => {
41
+ this.collectAndSend();
42
+ }, 5_000);
43
+
44
+ this.timer = setInterval(() => {
45
+ this.collectAndSend();
46
+ }, this.intervalMs);
47
+ }
48
+
49
+ stop(): void {
50
+ if (this.timer) {
51
+ clearInterval(this.timer);
52
+ this.timer = null;
53
+ }
54
+ this.logger.info('Health monitor stopped');
55
+ }
56
+
57
+ async collect(): Promise<HealthReport> {
58
+ const [cpu, mem, disk, temp] = await Promise.all([
59
+ si.currentLoad(),
60
+ si.mem(),
61
+ si.fsSize(),
62
+ si.cpuTemperature(),
63
+ ]);
64
+
65
+ // Use the first/main disk
66
+ const mainDisk = disk[0] || { size: 0, used: 0, use: 0 };
67
+
68
+ const report: HealthReport = {
69
+ cpuUsage: Math.round(cpu.currentLoad * 100) / 100,
70
+ memTotal: mem.total,
71
+ memUsed: mem.used,
72
+ memPercent: mem.total > 0 ? Math.round((mem.used / mem.total) * 10000) / 100 : 0,
73
+ diskTotal: mainDisk.size,
74
+ diskUsed: mainDisk.used,
75
+ diskPercent: Math.round(mainDisk.use * 100) / 100,
76
+ cpuTemp: temp.main !== null ? Math.round(temp.main * 10) / 10 : null,
77
+ uptime: Math.round(process.uptime()),
78
+ agentVersion: this.agentVersion,
79
+ };
80
+
81
+ // Add RPi-specific fields when running on Raspberry Pi
82
+ if (isRaspberryPi()) {
83
+ report.gpuTemp = getGpuTemp();
84
+ report.throttled = getThrottled();
85
+ report.sdCardReadOnly = isSdCardReadOnly();
86
+ }
87
+
88
+ // Add network info
89
+ try {
90
+ const ifaces = await si.networkInterfaces();
91
+ const ifaceList = Array.isArray(ifaces) ? ifaces : [ifaces];
92
+ const primary = ifaceList.find((i) => !i.internal && i.ip4) || null;
93
+
94
+ if (primary) {
95
+ let serverLatencyMs: number | null = null;
96
+ if (this.serverUrl) {
97
+ try {
98
+ const url = new URL(this.serverUrl);
99
+ const host = url.hostname;
100
+ const port = parseInt(url.port, 10) || 3001;
101
+ const start = Date.now();
102
+ const reachable = await this.tcpPing(host, port, 5000);
103
+ serverLatencyMs = reachable ? Date.now() - start : null;
104
+ } catch {
105
+ // Ignore ping errors in health collection
106
+ }
107
+ }
108
+
109
+ report.network = {
110
+ interface: primary.iface,
111
+ ip: primary.ip4,
112
+ mac: primary.mac,
113
+ serverLatencyMs,
114
+ };
115
+ }
116
+ } catch {
117
+ // Network info is optional, don't fail health collection
118
+ }
119
+
120
+ return report;
121
+ }
122
+
123
+ private tcpPing(host: string, port: number, timeoutMs: number): Promise<boolean> {
124
+ return new Promise((resolve) => {
125
+ const socket = new net.Socket();
126
+ let resolved = false;
127
+
128
+ const done = (result: boolean) => {
129
+ if (resolved) return;
130
+ resolved = true;
131
+ socket.destroy();
132
+ resolve(result);
133
+ };
134
+
135
+ socket.setTimeout(timeoutMs);
136
+ socket.on('connect', () => done(true));
137
+ socket.on('timeout', () => done(false));
138
+ socket.on('error', () => done(false));
139
+ socket.connect(port, host);
140
+ });
141
+ }
142
+
143
+ private async collectAndSend(): Promise<void> {
144
+ try {
145
+ const report = await this.collect();
146
+ const msg: WsMessage = {
147
+ type: 'agent:health',
148
+ payload: report as unknown as Record<string, unknown>,
149
+ timestamp: Date.now(),
150
+ };
151
+ this.wsClient.send(msg);
152
+ this.logger.debug('Health report sent', {
153
+ cpu: report.cpuUsage,
154
+ mem: report.memPercent,
155
+ disk: report.diskPercent,
156
+ });
157
+ } catch (err) {
158
+ this.logger.error('Failed to collect health data:', err);
159
+ }
160
+ }
161
+ }