rms-devremote 3.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 (68) hide show
  1. package/README.md +154 -0
  2. package/dist/commands/attach.d.ts +2 -0
  3. package/dist/commands/attach.js +10 -0
  4. package/dist/commands/check.d.ts +2 -0
  5. package/dist/commands/check.js +210 -0
  6. package/dist/commands/clean.d.ts +2 -0
  7. package/dist/commands/clean.js +177 -0
  8. package/dist/commands/dashboard.d.ts +2 -0
  9. package/dist/commands/dashboard.js +57 -0
  10. package/dist/commands/link.d.ts +2 -0
  11. package/dist/commands/link.js +112 -0
  12. package/dist/commands/ping.d.ts +2 -0
  13. package/dist/commands/ping.js +21 -0
  14. package/dist/commands/setup.d.ts +2 -0
  15. package/dist/commands/setup.js +54 -0
  16. package/dist/commands/status.d.ts +2 -0
  17. package/dist/commands/status.js +65 -0
  18. package/dist/commands/unlink.d.ts +2 -0
  19. package/dist/commands/unlink.js +53 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.js +55 -0
  22. package/dist/server/auth.d.ts +6 -0
  23. package/dist/server/auth.js +32 -0
  24. package/dist/server/frontend.d.ts +4 -0
  25. package/dist/server/frontend.js +886 -0
  26. package/dist/server/index.d.ts +1 -0
  27. package/dist/server/index.js +283 -0
  28. package/dist/server/terminal.d.ts +14 -0
  29. package/dist/server/terminal.js +43 -0
  30. package/dist/services/battery-worker.d.ts +1 -0
  31. package/dist/services/battery-worker.js +2 -0
  32. package/dist/services/battery.d.ts +27 -0
  33. package/dist/services/battery.js +152 -0
  34. package/dist/services/config.d.ts +63 -0
  35. package/dist/services/config.js +84 -0
  36. package/dist/services/docker.d.ts +25 -0
  37. package/dist/services/docker.js +75 -0
  38. package/dist/services/hooks.d.ts +15 -0
  39. package/dist/services/hooks.js +111 -0
  40. package/dist/services/ntfy.d.ts +19 -0
  41. package/dist/services/ntfy.js +63 -0
  42. package/dist/services/process.d.ts +30 -0
  43. package/dist/services/process.js +90 -0
  44. package/dist/services/proxy-worker.d.ts +1 -0
  45. package/dist/services/proxy-worker.js +12 -0
  46. package/dist/services/proxy.d.ts +4 -0
  47. package/dist/services/proxy.js +195 -0
  48. package/dist/services/shell.d.ts +22 -0
  49. package/dist/services/shell.js +47 -0
  50. package/dist/services/tmux.d.ts +30 -0
  51. package/dist/services/tmux.js +74 -0
  52. package/dist/services/ttyd.d.ts +28 -0
  53. package/dist/services/ttyd.js +71 -0
  54. package/dist/setup-server/routes.d.ts +4 -0
  55. package/dist/setup-server/routes.js +177 -0
  56. package/dist/setup-server/server.d.ts +4 -0
  57. package/dist/setup-server/server.js +32 -0
  58. package/docker/docker-compose.yml +24 -0
  59. package/docker/ntfy/server.yml +6 -0
  60. package/package.json +61 -0
  61. package/scripts/claude-remote.sh +583 -0
  62. package/scripts/hooks/notify.sh +68 -0
  63. package/scripts/notify.sh +54 -0
  64. package/scripts/startup.sh +29 -0
  65. package/scripts/update-check.sh +25 -0
  66. package/src/setup-server/public/index.html +21 -0
  67. package/src/setup-server/public/setup.css +475 -0
  68. package/src/setup-server/public/setup.js +687 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,283 @@
1
+ import { createServer } from 'http';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { execFileSync } from 'child_process';
5
+ import express from 'express';
6
+ import { WebSocketServer, WebSocket } from 'ws';
7
+ import { basicAuth } from './auth.js';
8
+ import { createTerminalSession } from './terminal.js';
9
+ import { readConfig, readEnv } from '../services/config.js';
10
+ import { buildFrontendHTML } from './frontend.js';
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ // ---------------------------------------------------------------------------
14
+ // Server setup
15
+ // ---------------------------------------------------------------------------
16
+ const config = readConfig();
17
+ const PORT = config.ttyd.port;
18
+ const app = express();
19
+ const httpServer = createServer(app);
20
+ // Apply basic auth to all HTTP routes
21
+ app.use(basicAuth);
22
+ // ── Serve xterm.js assets from node_modules ────────────────────────────────
23
+ const pkgRoot = join(__dirname, '..', '..');
24
+ const xtermDir = join(pkgRoot, 'node_modules', '@xterm', 'xterm');
25
+ const fitDir = join(pkgRoot, 'node_modules', '@xterm', 'addon-fit');
26
+ const webLinksDir = join(pkgRoot, 'node_modules', '@xterm', 'addon-web-links');
27
+ app.get('/xterm.css', (_req, res) => {
28
+ res.setHeader('Content-Type', 'text/css');
29
+ res.setHeader('Cache-Control', 'public, max-age=86400');
30
+ res.sendFile(join(xtermDir, 'css', 'xterm.css'));
31
+ });
32
+ app.get('/xterm.js', (_req, res) => {
33
+ res.setHeader('Content-Type', 'application/javascript');
34
+ res.setHeader('Cache-Control', 'public, max-age=86400');
35
+ res.sendFile(join(xtermDir, 'lib', 'xterm.js'));
36
+ });
37
+ app.get('/xterm-addon-fit.js', (_req, res) => {
38
+ res.setHeader('Content-Type', 'application/javascript');
39
+ res.setHeader('Cache-Control', 'public, max-age=86400');
40
+ res.sendFile(join(fitDir, 'lib', 'addon-fit.js'));
41
+ });
42
+ app.get('/xterm-addon-web-links.js', (_req, res) => {
43
+ res.setHeader('Content-Type', 'application/javascript');
44
+ res.setHeader('Cache-Control', 'public, max-age=86400');
45
+ res.sendFile(join(webLinksDir, 'lib', 'addon-web-links.js'));
46
+ });
47
+ // ── PWA assets ─────────────────────────────────────────────────────────────
48
+ app.get('/manifest.json', (_req, res) => {
49
+ res.json({
50
+ name: 'devremote',
51
+ short_name: 'devremote',
52
+ description: 'Remote terminal access',
53
+ start_url: '/',
54
+ display: 'standalone',
55
+ orientation: 'portrait',
56
+ background_color: '#000000',
57
+ theme_color: '#000000',
58
+ icons: [
59
+ { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' },
60
+ ],
61
+ });
62
+ });
63
+ app.get('/icon.svg', (_req, res) => {
64
+ res.setHeader('Content-Type', 'image/svg+xml');
65
+ res.setHeader('Cache-Control', 'public, max-age=86400');
66
+ res.send(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
67
+ <rect width="512" height="512" rx="96" fill="#000000"/>
68
+ <text x="128" y="340" font-family="monospace" font-size="240" font-weight="bold" fill="#00ffaa">&gt;_</text>
69
+ </svg>`);
70
+ });
71
+ app.get('/sw.js', (_req, res) => {
72
+ res.setHeader('Content-Type', 'application/javascript');
73
+ res.send(`
74
+ var CACHE = 'devremote-v2';
75
+ var ASSETS = ['/', '/xterm.css', '/xterm.js', '/xterm-addon-fit.js', '/xterm-addon-web-links.js', '/manifest.json', '/icon.svg'];
76
+
77
+ self.addEventListener('install', function(e) {
78
+ e.waitUntil(caches.open(CACHE).then(function(c) { return c.addAll(ASSETS); }));
79
+ self.skipWaiting();
80
+ });
81
+
82
+ self.addEventListener('activate', function(e) {
83
+ e.waitUntil(caches.keys().then(function(names) {
84
+ return Promise.all(names.filter(function(n) { return n !== CACHE; }).map(function(n) { return caches.delete(n); }));
85
+ }));
86
+ self.clients.claim();
87
+ });
88
+
89
+ self.addEventListener('fetch', function(e) {
90
+ var url = new URL(e.request.url);
91
+ if (url.pathname === '/ws' || url.pathname === '/health' || url.pathname === '/status') return;
92
+ if (e.request.method !== 'GET') return;
93
+ if (url.pathname === '/') {
94
+ e.respondWith(fetch(e.request).catch(function() { return caches.match(e.request); }));
95
+ } else {
96
+ e.respondWith(caches.match(e.request).then(function(r) { return r || fetch(e.request); }));
97
+ }
98
+ });
99
+ `);
100
+ });
101
+ // ── Main page ──────────────────────────────────────────────────────────────
102
+ app.get('/', (_req, res) => {
103
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
104
+ res.send(buildFrontendHTML());
105
+ });
106
+ // ── Health / Status ────────────────────────────────────────────────────────
107
+ app.get('/health', (_req, res) => {
108
+ res.json({ status: 'ok', uptime: process.uptime() });
109
+ });
110
+ app.get('/status', (_req, res) => {
111
+ // Get tmux current pane path
112
+ let tmuxPath = '';
113
+ try {
114
+ tmuxPath = execFileSync('tmux', ['display-message', '-t', 'devremote', '-p', '#{pane_current_path}'], {
115
+ encoding: 'utf8', timeout: 3000,
116
+ }).trim();
117
+ }
118
+ catch { /* ignore */ }
119
+ res.json({
120
+ tmux: isTmuxAlive(),
121
+ clientConnected: activeClient !== null && activeClient.readyState === WebSocket.OPEN,
122
+ cwd: tmuxPath,
123
+ hostname: process.env.HOSTNAME || '',
124
+ });
125
+ });
126
+ // ---------------------------------------------------------------------------
127
+ // WebSocket — single terminal session
128
+ // ---------------------------------------------------------------------------
129
+ const wss = new WebSocketServer({ noServer: true });
130
+ let activeClient = null;
131
+ let activeSession = null;
132
+ // Verify basic auth on WebSocket upgrade
133
+ httpServer.on('upgrade', (req, socket, head) => {
134
+ const authHeader = req.headers.authorization;
135
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
136
+ socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="devremote"\r\n\r\n');
137
+ socket.destroy();
138
+ return;
139
+ }
140
+ const encoded = authHeader.slice(6);
141
+ const decoded = Buffer.from(encoded, 'base64').toString('utf8');
142
+ const colonIdx = decoded.indexOf(':');
143
+ if (colonIdx === -1) {
144
+ socket.destroy();
145
+ return;
146
+ }
147
+ const env = readEnv();
148
+ const user = decoded.slice(0, colonIdx);
149
+ const pass = decoded.slice(colonIdx + 1);
150
+ if (user !== (env.TTYD_USER ?? 'admin') || pass !== (env.TTYD_PASSWORD ?? 'changeme')) {
151
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
152
+ socket.destroy();
153
+ return;
154
+ }
155
+ if (req.url !== '/ws') {
156
+ socket.destroy();
157
+ return;
158
+ }
159
+ // Reject if another client is already connected
160
+ if (activeClient && activeClient.readyState === WebSocket.OPEN) {
161
+ socket.write('HTTP/1.1 409 Conflict\r\n\r\n');
162
+ socket.destroy();
163
+ return;
164
+ }
165
+ wss.handleUpgrade(req, socket, head, (ws) => {
166
+ wss.emit('connection', ws, req);
167
+ });
168
+ });
169
+ wss.on('connection', (ws) => {
170
+ activeClient = ws;
171
+ ws.on('message', (raw) => {
172
+ const msg = typeof raw === 'string' ? raw : raw.toString('utf8');
173
+ try {
174
+ const parsed = JSON.parse(msg);
175
+ if (parsed.type === 'init' && !activeSession) {
176
+ const cols = parsed.cols || 80;
177
+ const rows = parsed.rows || 24;
178
+ activeSession = createTerminalSession(cols, rows);
179
+ activeSession.onData((data) => {
180
+ if (ws.readyState === WebSocket.OPEN) {
181
+ ws.send(JSON.stringify({ type: 'output', data }));
182
+ }
183
+ });
184
+ activeSession.onExit((_code) => {
185
+ if (ws.readyState === WebSocket.OPEN) {
186
+ ws.send(JSON.stringify({ type: 'exit' }));
187
+ ws.close();
188
+ }
189
+ activeSession = null;
190
+ activeClient = null;
191
+ // Don't shutdown — server stays up for future connections.
192
+ // The tmux watcher handles shutdown if tmux truly dies.
193
+ });
194
+ return;
195
+ }
196
+ if (parsed.type === 'input' && activeSession) {
197
+ activeSession.write(parsed.data);
198
+ return;
199
+ }
200
+ if (parsed.type === 'resize' && activeSession) {
201
+ activeSession.resize(parsed.cols, parsed.rows);
202
+ return;
203
+ }
204
+ }
205
+ catch {
206
+ // Ignore malformed messages
207
+ }
208
+ });
209
+ ws.on('close', () => {
210
+ if (activeSession) {
211
+ activeSession.kill();
212
+ activeSession = null;
213
+ }
214
+ activeClient = null;
215
+ });
216
+ ws.on('error', () => {
217
+ if (activeSession) {
218
+ activeSession.kill();
219
+ activeSession = null;
220
+ }
221
+ activeClient = null;
222
+ });
223
+ });
224
+ // ---------------------------------------------------------------------------
225
+ // Watch tmux session — auto-unlink if it dies
226
+ // ---------------------------------------------------------------------------
227
+ function isTmuxAlive() {
228
+ try {
229
+ execFileSync('tmux', ['has-session', '-t', 'devremote'], {
230
+ stdio: 'ignore',
231
+ timeout: 5000,
232
+ env: { ...process.env, PATH: process.env.PATH || '/usr/bin:/usr/local/bin:/bin' },
233
+ });
234
+ return true;
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ }
240
+ // Only shut down after 3 consecutive failures (avoids false positives)
241
+ let tmuxFailCount = 0;
242
+ const tmuxWatcher = setInterval(() => {
243
+ if (isTmuxAlive()) {
244
+ tmuxFailCount = 0;
245
+ }
246
+ else {
247
+ tmuxFailCount++;
248
+ if (tmuxFailCount >= 3) {
249
+ console.log('tmux session "devremote" ended — shutting down server');
250
+ shutdown();
251
+ }
252
+ }
253
+ }, 5000);
254
+ // ---------------------------------------------------------------------------
255
+ // Graceful shutdown
256
+ // ---------------------------------------------------------------------------
257
+ function shutdown() {
258
+ clearInterval(tmuxWatcher);
259
+ // Close active WebSocket
260
+ if (activeClient && activeClient.readyState === WebSocket.OPEN) {
261
+ activeClient.send(JSON.stringify({ type: 'exit' }));
262
+ activeClient.close();
263
+ }
264
+ // Kill PTY
265
+ if (activeSession) {
266
+ activeSession.kill();
267
+ activeSession = null;
268
+ }
269
+ // Close server
270
+ httpServer.close(() => {
271
+ process.exit(0);
272
+ });
273
+ // Force exit after 3s if graceful close hangs
274
+ setTimeout(() => process.exit(0), 3000);
275
+ }
276
+ process.on('SIGTERM', shutdown);
277
+ process.on('SIGINT', shutdown);
278
+ // ---------------------------------------------------------------------------
279
+ // Start
280
+ // ---------------------------------------------------------------------------
281
+ httpServer.listen(PORT, '0.0.0.0', () => {
282
+ console.log(`devremote server listening on port ${PORT}`);
283
+ });
@@ -0,0 +1,14 @@
1
+ import type { IPty } from 'node-pty';
2
+ export interface TerminalSession {
3
+ pty: IPty;
4
+ onData: (cb: (data: string) => void) => void;
5
+ onExit: (cb: (code: number) => void) => void;
6
+ write: (data: string) => void;
7
+ resize: (cols: number, rows: number) => void;
8
+ kill: () => void;
9
+ }
10
+ /**
11
+ * Spawn a PTY that attaches to the devremote tmux session.
12
+ * If the session doesn't exist, falls back to a plain shell.
13
+ */
14
+ export declare function createTerminalSession(cols?: number, rows?: number): TerminalSession;
@@ -0,0 +1,43 @@
1
+ import * as nodePty from 'node-pty';
2
+ /**
3
+ * Spawn a PTY that attaches to the devremote tmux session.
4
+ * If the session doesn't exist, falls back to a plain shell.
5
+ */
6
+ export function createTerminalSession(cols = 80, rows = 24) {
7
+ const shell = process.env.SHELL || '/bin/bash';
8
+ const pty = nodePty.spawn('tmux', ['attach-session', '-t', 'devremote'], {
9
+ name: 'xterm-256color',
10
+ cols,
11
+ rows,
12
+ cwd: process.env.HOME || '/',
13
+ env: process.env,
14
+ });
15
+ return {
16
+ pty,
17
+ onData(cb) {
18
+ pty.onData(cb);
19
+ },
20
+ onExit(cb) {
21
+ pty.onExit(({ exitCode }) => cb(exitCode));
22
+ },
23
+ write(data) {
24
+ pty.write(data);
25
+ },
26
+ resize(c, r) {
27
+ try {
28
+ pty.resize(c, r);
29
+ }
30
+ catch {
31
+ // Ignore resize errors on dead PTY
32
+ }
33
+ },
34
+ kill() {
35
+ try {
36
+ pty.kill();
37
+ }
38
+ catch {
39
+ // Already dead
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { startBatteryWatcher } from './battery.js';
2
+ startBatteryWatcher();
@@ -0,0 +1,27 @@
1
+ export interface BatteryInfo {
2
+ percent: number;
3
+ charging: boolean;
4
+ present: boolean;
5
+ }
6
+ /**
7
+ * Read current battery state.
8
+ * On Linux: reads from /sys/class/power_supply/BAT0.
9
+ * On macOS: parses `pmset -g batt` output.
10
+ */
11
+ export declare function getBatteryInfo(): BatteryInfo;
12
+ /**
13
+ * Spawn a sleep-inhibiting process in the background.
14
+ * Linux: systemd-inhibit. macOS: caffeinate.
15
+ * Saves the spawned PID to INHIBIT_PID_PATH.
16
+ */
17
+ export declare function inhibitSleep(): void;
18
+ /**
19
+ * Release the sleep inhibitor by killing the process whose PID is in INHIBIT_PID_PATH.
20
+ */
21
+ export declare function releaseSleep(): void;
22
+ /**
23
+ * Start a battery watcher that periodically checks battery state and sends
24
+ * ntfy notifications on low battery, unplug, or replug events.
25
+ * Writes its own PID to BATTERY_PID_PATH.
26
+ */
27
+ export declare function startBatteryWatcher(): void;
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { platform } from 'os';
3
+ import { spawn } from 'child_process';
4
+ import { run } from './shell.js';
5
+ import { writePid, killByPidFile } from './process.js';
6
+ import { INHIBIT_PID_PATH, BATTERY_PID_PATH } from './config.js';
7
+ import { sendNotification } from './ntfy.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Battery info
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Read current battery state.
13
+ * On Linux: reads from /sys/class/power_supply/BAT0.
14
+ * On macOS: parses `pmset -g batt` output.
15
+ */
16
+ export function getBatteryInfo() {
17
+ const os = platform();
18
+ if (os === 'linux') {
19
+ return getLinuxBatteryInfo();
20
+ }
21
+ if (os === 'darwin') {
22
+ return getMacBatteryInfo();
23
+ }
24
+ // Unsupported platform — return a safe default
25
+ return { percent: 100, charging: true, present: false };
26
+ }
27
+ function getLinuxBatteryInfo() {
28
+ const basePath = '/sys/class/power_supply/BAT0';
29
+ if (!existsSync(basePath)) {
30
+ return { percent: 100, charging: true, present: false };
31
+ }
32
+ const readSys = (file) => {
33
+ try {
34
+ return readFileSync(`${basePath}/${file}`, 'utf8').trim();
35
+ }
36
+ catch {
37
+ return '';
38
+ }
39
+ };
40
+ const capacityRaw = readSys('capacity');
41
+ const statusRaw = readSys('status');
42
+ const percent = parseInt(capacityRaw, 10);
43
+ const charging = statusRaw === 'Charging' || statusRaw === 'Full';
44
+ return {
45
+ percent: isNaN(percent) ? 100 : percent,
46
+ charging,
47
+ present: true,
48
+ };
49
+ }
50
+ function getMacBatteryInfo() {
51
+ try {
52
+ const output = run('pmset', ['-g', 'batt']);
53
+ // Example line: "Now drawing from 'Battery Power'"
54
+ const charging = output.includes('AC Power') || output.includes('charging');
55
+ // Example: "100%;"
56
+ const percentMatch = output.match(/(\d+)%/);
57
+ const percent = percentMatch ? parseInt(percentMatch[1], 10) : 100;
58
+ return { percent, charging, present: true };
59
+ }
60
+ catch {
61
+ return { percent: 100, charging: true, present: false };
62
+ }
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Sleep inhibit
66
+ // ---------------------------------------------------------------------------
67
+ /**
68
+ * Spawn a sleep-inhibiting process in the background.
69
+ * Linux: systemd-inhibit. macOS: caffeinate.
70
+ * Saves the spawned PID to INHIBIT_PID_PATH.
71
+ */
72
+ export function inhibitSleep() {
73
+ const os = platform();
74
+ let cmd;
75
+ let args;
76
+ if (os === 'linux') {
77
+ cmd = 'systemd-inhibit';
78
+ args = ['--what=sleep:idle', '--who=rms-devremote', '--why=Remote session active', 'sleep', 'infinity'];
79
+ }
80
+ else if (os === 'darwin') {
81
+ cmd = 'caffeinate';
82
+ args = ['-i'];
83
+ }
84
+ else {
85
+ throw new Error(`Sleep inhibit not supported on platform: ${os}`);
86
+ }
87
+ const child = spawn(cmd, args, {
88
+ detached: true,
89
+ stdio: 'ignore',
90
+ });
91
+ child.unref();
92
+ if (child.pid === undefined) {
93
+ throw new Error('Failed to spawn sleep inhibitor: no PID returned');
94
+ }
95
+ writePid(INHIBIT_PID_PATH, child.pid);
96
+ }
97
+ /**
98
+ * Release the sleep inhibitor by killing the process whose PID is in INHIBIT_PID_PATH.
99
+ */
100
+ export function releaseSleep() {
101
+ killByPidFile(INHIBIT_PID_PATH);
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Battery watcher
105
+ // ---------------------------------------------------------------------------
106
+ const LOW_BATTERY_THRESHOLD = 20;
107
+ const CRITICAL_BATTERY_THRESHOLD = 10;
108
+ /**
109
+ * Start a battery watcher that periodically checks battery state and sends
110
+ * ntfy notifications on low battery, unplug, or replug events.
111
+ * Writes its own PID to BATTERY_PID_PATH.
112
+ */
113
+ export function startBatteryWatcher() {
114
+ writePid(BATTERY_PID_PATH, process.pid);
115
+ let previousCharging = null;
116
+ let lowNotified = false;
117
+ let criticalNotified = false;
118
+ const check = async () => {
119
+ const info = getBatteryInfo();
120
+ if (!info.present)
121
+ return;
122
+ // Detect charging state change
123
+ if (previousCharging !== null && previousCharging !== info.charging) {
124
+ if (info.charging) {
125
+ await sendNotification(`Laptop plugged in — battery at ${info.percent}%`, 'low');
126
+ // Reset low-battery notifications on replug
127
+ lowNotified = false;
128
+ criticalNotified = false;
129
+ }
130
+ else {
131
+ await sendNotification(`Laptop unplugged — battery at ${info.percent}%`, 'default');
132
+ }
133
+ }
134
+ previousCharging = info.charging;
135
+ // Low battery alerts (only when discharging)
136
+ if (!info.charging) {
137
+ if (info.percent <= CRITICAL_BATTERY_THRESHOLD && !criticalNotified) {
138
+ await sendNotification(`CRITICAL: Battery at ${info.percent}% — connect charger now!`, 'urgent');
139
+ criticalNotified = true;
140
+ }
141
+ else if (info.percent <= LOW_BATTERY_THRESHOLD &&
142
+ !lowNotified &&
143
+ !criticalNotified) {
144
+ await sendNotification(`Low battery: ${info.percent}% remaining`, 'high');
145
+ lowNotified = true;
146
+ }
147
+ }
148
+ };
149
+ // Run immediately then on interval
150
+ void check();
151
+ setInterval(() => { void check(); }, 60_000);
152
+ }
@@ -0,0 +1,63 @@
1
+ export declare const DATA_DIR: string;
2
+ export declare const CONFIG_PATH: string;
3
+ export declare const ENV_PATH: string;
4
+ export declare const HOOKS_PATH: string;
5
+ export declare const PIN_HASH_PATH: string;
6
+ export declare const DOCKER_COMPOSE_PATH: string;
7
+ export declare const NTFY_CONFIG_PATH: string;
8
+ export declare const NOTIFY_SCRIPT_PATH: string;
9
+ export declare const INHIBIT_PID_PATH: string;
10
+ export declare const BATTERY_PID_PATH: string;
11
+ export interface Config {
12
+ domains: {
13
+ terminal: string;
14
+ notify: string;
15
+ };
16
+ ttyd: {
17
+ port: number;
18
+ };
19
+ ntfy: {
20
+ port: number;
21
+ };
22
+ battery: {
23
+ lowThreshold: number;
24
+ checkInterval: number;
25
+ };
26
+ }
27
+ export interface EnvVars {
28
+ CLOUDFLARE_TUNNEL_TOKEN: string;
29
+ TERMINAL_DOMAIN: string;
30
+ NOTIFY_DOMAIN: string;
31
+ TTYD_USER: string;
32
+ TTYD_PASSWORD: string;
33
+ NTFY_TOPIC: string;
34
+ NTFY_ADMIN_PASSWORD: string;
35
+ NTFY_PORT: string;
36
+ }
37
+ /**
38
+ * Ensure the DATA_DIR (~/.rms-devremote/) exists.
39
+ * Creates it (and any parent directories) if it does not.
40
+ */
41
+ export declare function ensureDataDir(): void;
42
+ /**
43
+ * Returns true when setup has been completed (config.json and .env both exist).
44
+ */
45
+ export declare function isSetupDone(): boolean;
46
+ /**
47
+ * Read and parse config.json. Throws if the file does not exist.
48
+ */
49
+ export declare function readConfig(): Config;
50
+ /**
51
+ * Serialize and write config.json (pretty-printed, 2-space indent).
52
+ */
53
+ export declare function writeConfig(config: Config): void;
54
+ /**
55
+ * Read and parse the .env file into an EnvVars object.
56
+ * Lines that are empty or start with '#' are ignored.
57
+ */
58
+ export declare function readEnv(): Partial<EnvVars>;
59
+ /**
60
+ * Serialize an EnvVars object and write it to the .env file.
61
+ * Each entry is written as KEY="value".
62
+ */
63
+ export declare function writeEnv(env: Partial<EnvVars>): void;
@@ -0,0 +1,84 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
4
+ // ---------------------------------------------------------------------------
5
+ // Path constants
6
+ // ---------------------------------------------------------------------------
7
+ export const DATA_DIR = join(homedir(), '.rms-devremote');
8
+ export const CONFIG_PATH = join(DATA_DIR, 'config.json');
9
+ export const ENV_PATH = join(DATA_DIR, '.env');
10
+ export const HOOKS_PATH = join(DATA_DIR, 'hooks.json');
11
+ export const PIN_HASH_PATH = join(DATA_DIR, 'pin.hash');
12
+ export const DOCKER_COMPOSE_PATH = join(DATA_DIR, 'docker-compose.yml');
13
+ export const NTFY_CONFIG_PATH = join(DATA_DIR, 'ntfy', 'server.yml');
14
+ export const NOTIFY_SCRIPT_PATH = join(DATA_DIR, 'notify.sh');
15
+ export const INHIBIT_PID_PATH = join(DATA_DIR, 'inhibit.pid');
16
+ export const BATTERY_PID_PATH = join(DATA_DIR, 'battery.pid');
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Ensure the DATA_DIR (~/.rms-devremote/) exists.
22
+ * Creates it (and any parent directories) if it does not.
23
+ */
24
+ export function ensureDataDir() {
25
+ if (!existsSync(DATA_DIR)) {
26
+ mkdirSync(DATA_DIR, { recursive: true });
27
+ }
28
+ }
29
+ /**
30
+ * Returns true when setup has been completed (config.json and .env both exist).
31
+ */
32
+ export function isSetupDone() {
33
+ return existsSync(CONFIG_PATH) && existsSync(ENV_PATH);
34
+ }
35
+ /**
36
+ * Read and parse config.json. Throws if the file does not exist.
37
+ */
38
+ export function readConfig() {
39
+ const raw = readFileSync(CONFIG_PATH, 'utf8');
40
+ return JSON.parse(raw);
41
+ }
42
+ /**
43
+ * Serialize and write config.json (pretty-printed, 2-space indent).
44
+ */
45
+ export function writeConfig(config) {
46
+ ensureDataDir();
47
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
48
+ }
49
+ /**
50
+ * Read and parse the .env file into an EnvVars object.
51
+ * Lines that are empty or start with '#' are ignored.
52
+ */
53
+ export function readEnv() {
54
+ if (!existsSync(ENV_PATH))
55
+ return {};
56
+ const lines = readFileSync(ENV_PATH, 'utf8').split('\n');
57
+ const result = {};
58
+ for (const line of lines) {
59
+ const trimmed = line.trim();
60
+ if (!trimmed || trimmed.startsWith('#'))
61
+ continue;
62
+ const eqIndex = trimmed.indexOf('=');
63
+ if (eqIndex === -1)
64
+ continue;
65
+ const key = trimmed.slice(0, eqIndex).trim();
66
+ // Strip optional surrounding quotes from the value
67
+ let value = trimmed.slice(eqIndex + 1).trim();
68
+ if ((value.startsWith('"') && value.endsWith('"')) ||
69
+ (value.startsWith("'") && value.endsWith("'"))) {
70
+ value = value.slice(1, -1);
71
+ }
72
+ result[key] = value;
73
+ }
74
+ return result;
75
+ }
76
+ /**
77
+ * Serialize an EnvVars object and write it to the .env file.
78
+ * Each entry is written as KEY="value".
79
+ */
80
+ export function writeEnv(env) {
81
+ ensureDataDir();
82
+ const lines = Object.entries(env).map(([k, v]) => `${k}="${v ?? ''}"`);
83
+ writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
84
+ }