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,25 @@
1
+ /**
2
+ * Check whether the Docker daemon is running.
3
+ */
4
+ export declare function isDockerRunning(): boolean;
5
+ /**
6
+ * Check whether the Compose stack is up (at least one service in 'running' state).
7
+ */
8
+ export declare function isComposeUp(): boolean;
9
+ /**
10
+ * Get the status of a named container.
11
+ */
12
+ export declare function getContainerStatus(name: string): 'running' | 'stopped' | 'missing';
13
+ /**
14
+ * Start the Compose stack in detached mode.
15
+ */
16
+ export declare function composeUp(): void;
17
+ /**
18
+ * Stop and remove the Compose stack.
19
+ */
20
+ export declare function composeDown(): void;
21
+ /**
22
+ * Execute a command inside a running container.
23
+ * Returns the stdout of the command.
24
+ */
25
+ export declare function execInContainer(container: string, cmd: string[]): string;
@@ -0,0 +1,75 @@
1
+ import { run } from './shell.js';
2
+ import { spawnSync } from 'child_process';
3
+ import { DOCKER_COMPOSE_PATH } from './config.js';
4
+ /**
5
+ * Check whether the Docker daemon is running.
6
+ */
7
+ export function isDockerRunning() {
8
+ try {
9
+ run('docker', ['info']);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Check whether the Compose stack is up (at least one service in 'running' state).
18
+ */
19
+ export function isComposeUp() {
20
+ try {
21
+ const output = run('docker', [
22
+ 'compose',
23
+ '-f', DOCKER_COMPOSE_PATH,
24
+ 'ps',
25
+ ]);
26
+ return output.toLowerCase().includes('running');
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /**
33
+ * Get the status of a named container.
34
+ */
35
+ export function getContainerStatus(name) {
36
+ try {
37
+ const output = run('docker', [
38
+ 'inspect',
39
+ '--format', '{{.State.Status}}',
40
+ name,
41
+ ]);
42
+ const status = output.trim().toLowerCase();
43
+ if (status === 'running')
44
+ return 'running';
45
+ return 'stopped';
46
+ }
47
+ catch {
48
+ return 'missing';
49
+ }
50
+ }
51
+ /**
52
+ * Start the Compose stack in detached mode.
53
+ */
54
+ export function composeUp() {
55
+ const result = spawnSync('docker', ['compose', '-f', DOCKER_COMPOSE_PATH, 'up', '-d'], { stdio: 'inherit' });
56
+ if (result.status !== 0) {
57
+ throw new Error('docker compose up failed');
58
+ }
59
+ }
60
+ /**
61
+ * Stop and remove the Compose stack.
62
+ */
63
+ export function composeDown() {
64
+ const result = spawnSync('docker', ['compose', '-f', DOCKER_COMPOSE_PATH, 'down'], { stdio: 'inherit' });
65
+ if (result.status !== 0) {
66
+ throw new Error('docker compose down failed');
67
+ }
68
+ }
69
+ /**
70
+ * Execute a command inside a running container.
71
+ * Returns the stdout of the command.
72
+ */
73
+ export function execInContainer(container, cmd) {
74
+ return run('docker', ['exec', container, ...cmd]);
75
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Merge devremote hooks into ~/.claude/settings.json.
3
+ * Adds PreToolUse, Notification and Stop hooks tagged with _source.
4
+ * Existing hooks from other sources are preserved.
5
+ */
6
+ export declare function mergeHooks(): void;
7
+ /**
8
+ * Remove all devremote hooks from ~/.claude/settings.json.
9
+ * Only entries tagged with _source: rms-devremote are removed.
10
+ */
11
+ export declare function removeHooks(): void;
12
+ /**
13
+ * Returns true if devremote hooks are present in ~/.claude/settings.json.
14
+ */
15
+ export declare function hasDevremoteHooks(): boolean;
@@ -0,0 +1,111 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { NOTIFY_SCRIPT_PATH } from './config.js';
5
+ const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
6
+ const SOURCE_TAG = 'rms-devremote';
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+ function readSettings() {
11
+ if (!existsSync(CLAUDE_SETTINGS_PATH)) {
12
+ return {};
13
+ }
14
+ const raw = readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
15
+ return JSON.parse(raw);
16
+ }
17
+ function writeSettings(settings) {
18
+ writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // Public API
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * Merge devremote hooks into ~/.claude/settings.json.
25
+ * Adds PreToolUse, Notification and Stop hooks tagged with _source.
26
+ * Existing hooks from other sources are preserved.
27
+ */
28
+ export function mergeHooks() {
29
+ const settings = readSettings();
30
+ if (!settings.hooks) {
31
+ settings.hooks = {};
32
+ }
33
+ const hookTypes = [
34
+ 'Notification',
35
+ 'Stop',
36
+ ];
37
+ // Map hook type to the event argument notify.sh expects
38
+ const eventMap = {
39
+ PreToolUse: 'tool-use',
40
+ Notification: 'notification',
41
+ Stop: 'stop',
42
+ };
43
+ for (const hookType of hookTypes) {
44
+ if (!settings.hooks[hookType]) {
45
+ settings.hooks[hookType] = [];
46
+ }
47
+ const entries = settings.hooks[hookType];
48
+ // Remove any pre-existing devremote entry for this hook type to avoid duplicates
49
+ const filtered = entries.map((entry) => ({
50
+ ...entry,
51
+ hooks: entry.hooks.filter((h) => h._source !== SOURCE_TAG),
52
+ }));
53
+ // Find or create the catch-all matcher entry
54
+ let catchAll = filtered.find((e) => !e.matcher || e.matcher === '*');
55
+ if (!catchAll) {
56
+ catchAll = { hooks: [] };
57
+ filtered.push(catchAll);
58
+ }
59
+ catchAll.hooks.push({
60
+ type: 'command',
61
+ command: `${NOTIFY_SCRIPT_PATH} ${eventMap[hookType]}`,
62
+ _source: SOURCE_TAG,
63
+ });
64
+ settings.hooks[hookType] = filtered;
65
+ }
66
+ writeSettings(settings);
67
+ }
68
+ /**
69
+ * Remove all devremote hooks from ~/.claude/settings.json.
70
+ * Only entries tagged with _source: rms-devremote are removed.
71
+ */
72
+ export function removeHooks() {
73
+ if (!existsSync(CLAUDE_SETTINGS_PATH))
74
+ return;
75
+ const settings = readSettings();
76
+ if (!settings.hooks)
77
+ return;
78
+ for (const hookType of Object.keys(settings.hooks)) {
79
+ const entries = settings.hooks[hookType];
80
+ if (!Array.isArray(entries))
81
+ continue;
82
+ settings.hooks[hookType] = entries
83
+ .map((entry) => ({
84
+ ...entry,
85
+ hooks: entry.hooks.filter((h) => h._source !== SOURCE_TAG),
86
+ }))
87
+ .filter((entry) => entry.hooks.length > 0);
88
+ }
89
+ writeSettings(settings);
90
+ }
91
+ /**
92
+ * Returns true if devremote hooks are present in ~/.claude/settings.json.
93
+ */
94
+ export function hasDevremoteHooks() {
95
+ if (!existsSync(CLAUDE_SETTINGS_PATH))
96
+ return false;
97
+ const settings = readSettings();
98
+ if (!settings.hooks)
99
+ return false;
100
+ for (const entries of Object.values(settings.hooks)) {
101
+ if (!Array.isArray(entries))
102
+ continue;
103
+ for (const entry of entries) {
104
+ for (const hook of entry.hooks) {
105
+ if (hook._source === SOURCE_TAG)
106
+ return true;
107
+ }
108
+ }
109
+ }
110
+ return false;
111
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Send a notification via the local ntfy instance.
3
+ * Uses HTTP basic auth with admin credentials from .env.
4
+ * Returns true on success, false on failure.
5
+ */
6
+ export declare function sendNotification(body: string, priority?: string): Promise<boolean>;
7
+ /**
8
+ * Check whether the ntfy container is healthy by calling its health endpoint.
9
+ * Returns true if the service responds with a 200-range status.
10
+ */
11
+ export declare function healthCheck(): Promise<boolean>;
12
+ /**
13
+ * Create the admin user inside the devremote-ntfy container.
14
+ */
15
+ export declare function createAdminUser(password: string): void;
16
+ /**
17
+ * Change the admin user password inside the devremote-ntfy container.
18
+ */
19
+ export declare function changeAdminPassword(password: string): void;
@@ -0,0 +1,63 @@
1
+ import { readConfig, readEnv } from './config.js';
2
+ import { execInContainer } from './docker.js';
3
+ /**
4
+ * Send a notification via the local ntfy instance.
5
+ * Uses HTTP basic auth with admin credentials from .env.
6
+ * Returns true on success, false on failure.
7
+ */
8
+ export async function sendNotification(body, priority = 'default') {
9
+ const config = readConfig();
10
+ const env = readEnv();
11
+ const port = config.ntfy.port;
12
+ const topic = env.NTFY_TOPIC ?? 'devremote';
13
+ const password = env.NTFY_ADMIN_PASSWORD ?? 'changeme';
14
+ const credentials = Buffer.from(`admin:${password}`).toString('base64');
15
+ try {
16
+ const response = await fetch(`http://127.0.0.1:${port}/${topic}`, {
17
+ method: 'POST',
18
+ headers: {
19
+ Authorization: `Basic ${credentials}`,
20
+ Priority: priority,
21
+ 'Content-Type': 'text/plain',
22
+ },
23
+ body,
24
+ });
25
+ return response.ok;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /**
32
+ * Check whether the ntfy container is healthy by calling its health endpoint.
33
+ * Returns true if the service responds with a 200-range status.
34
+ */
35
+ export async function healthCheck() {
36
+ const config = readConfig();
37
+ const port = config.ntfy.port;
38
+ try {
39
+ const response = await fetch(`http://127.0.0.1:${port}/v1/health`);
40
+ return response.ok;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * Create the admin user inside the devremote-ntfy container.
48
+ */
49
+ export function createAdminUser(password) {
50
+ execInContainer('devremote-ntfy', [
51
+ 'sh', '-c',
52
+ `NTFY_PASSWORD=${password} ntfy user add --role=admin admin`,
53
+ ]);
54
+ }
55
+ /**
56
+ * Change the admin user password inside the devremote-ntfy container.
57
+ */
58
+ export function changeAdminPassword(password) {
59
+ execInContainer('devremote-ntfy', [
60
+ 'sh', '-c',
61
+ `NTFY_PASSWORD=${password} ntfy user change-pass admin`,
62
+ ]);
63
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Write a PID to a file.
3
+ */
4
+ export declare function writePid(path: string, pid: number): void;
5
+ /**
6
+ * Read a PID from a file.
7
+ * Returns the PID as a number, or null if the file does not exist or is invalid.
8
+ */
9
+ export declare function readPid(path: string): number | null;
10
+ /**
11
+ * Check whether a process with the given PID is currently running.
12
+ * Uses `kill -0` which sends no signal but checks for process existence.
13
+ */
14
+ export declare function isProcessRunning(pid: number): boolean;
15
+ /**
16
+ * Kill the process whose PID is stored in the given PID file.
17
+ * Returns true if a process was found and a SIGTERM was sent, false otherwise.
18
+ * The PID file is NOT removed — call cleanupPidFile() separately if needed.
19
+ */
20
+ export declare function killByPidFile(path: string): boolean;
21
+ /**
22
+ * Remove a PID file if it exists. Safe to call even when the file is absent.
23
+ */
24
+ export declare function cleanupPidFile(path: string): void;
25
+ /**
26
+ * Find the PID of the process listening on the given TCP port.
27
+ * Uses `lsof` to inspect open sockets.
28
+ * Returns the PID as a number, or null if no matching process is found.
29
+ */
30
+ export declare function findProcessByPort(port: number): number | null;
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
2
+ import { run } from './shell.js';
3
+ // ---------------------------------------------------------------------------
4
+ // PID file management
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * Write a PID to a file.
8
+ */
9
+ export function writePid(path, pid) {
10
+ writeFileSync(path, String(pid), 'utf8');
11
+ }
12
+ /**
13
+ * Read a PID from a file.
14
+ * Returns the PID as a number, or null if the file does not exist or is invalid.
15
+ */
16
+ export function readPid(path) {
17
+ if (!existsSync(path))
18
+ return null;
19
+ const raw = readFileSync(path, 'utf8').trim();
20
+ const pid = parseInt(raw, 10);
21
+ return isNaN(pid) ? null : pid;
22
+ }
23
+ /**
24
+ * Check whether a process with the given PID is currently running.
25
+ * Uses `kill -0` which sends no signal but checks for process existence.
26
+ */
27
+ export function isProcessRunning(pid) {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * Kill the process whose PID is stored in the given PID file.
38
+ * Returns true if a process was found and a SIGTERM was sent, false otherwise.
39
+ * The PID file is NOT removed — call cleanupPidFile() separately if needed.
40
+ */
41
+ export function killByPidFile(path) {
42
+ const pid = readPid(path);
43
+ if (pid === null)
44
+ return false;
45
+ if (!isProcessRunning(pid))
46
+ return false;
47
+ try {
48
+ process.kill(pid, 'SIGTERM');
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /**
56
+ * Remove a PID file if it exists. Safe to call even when the file is absent.
57
+ */
58
+ export function cleanupPidFile(path) {
59
+ if (existsSync(path)) {
60
+ unlinkSync(path);
61
+ }
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // Port-based process lookup
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Find the PID of the process listening on the given TCP port.
68
+ * Uses `lsof` to inspect open sockets.
69
+ * Returns the PID as a number, or null if no matching process is found.
70
+ */
71
+ export function findProcessByPort(port) {
72
+ try {
73
+ const output = run('lsof', [
74
+ '-iTCP:' + port,
75
+ '-sTCP:LISTEN',
76
+ '-t',
77
+ '-n',
78
+ '-P',
79
+ ]);
80
+ // lsof -t may return multiple PIDs (one per line); take the first
81
+ const firstLine = output.split('\n')[0]?.trim();
82
+ if (!firstLine)
83
+ return null;
84
+ const pid = parseInt(firstLine, 10);
85
+ return isNaN(pid) ? null : pid;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ // Standalone worker: starts the reverse proxy in a long-running process.
2
+ // Forked from the link command so it survives after link exits.
3
+ import { startProxy } from './proxy.js';
4
+ import { readConfig } from './config.js';
5
+ const config = readConfig();
6
+ const server = startProxy(config.ttyd.port);
7
+ server.on('listening', () => {
8
+ // Signal parent that we're ready
9
+ if (process.send) {
10
+ process.send('ready');
11
+ }
12
+ });
@@ -0,0 +1,4 @@
1
+ import http from 'http';
2
+ declare const INTERNAL_TTYD_PORT = 7691;
3
+ export { INTERNAL_TTYD_PORT };
4
+ export declare function startProxy(proxyPort: number): http.Server;
@@ -0,0 +1,195 @@
1
+ import http from 'http';
2
+ import net from 'net';
3
+ // ---------------------------------------------------------------------------
4
+ // Toolbar HTML/CSS/JS injected before </body>
5
+ // ---------------------------------------------------------------------------
6
+ const TOOLBAR_INJECT = `
7
+ <!-- devremote mobile toolbar -->
8
+ <style>
9
+ :root { --tb-h: 96px; }
10
+ #devremote-toolbar {
11
+ position: fixed; bottom: 0; left: 0; right: 0;
12
+ height: var(--tb-h);
13
+ background: #1a1a2e;
14
+ border-top: 1px solid #2a2a4a;
15
+ display: flex; flex-direction: column;
16
+ justify-content: center; gap: 6px;
17
+ padding: 6px 8px; z-index: 9999;
18
+ }
19
+ #devremote-toolbar.hidden { display: none; }
20
+ #devremote-toolbar .tb-row { display: flex; gap: 6px; justify-content: center; }
21
+ #devremote-toolbar .tb-btn {
22
+ display: flex; align-items: center; justify-content: center;
23
+ flex: 1; max-width: 72px; height: 38px;
24
+ background: #16213e; border: 1px solid #2a2a4a;
25
+ border-radius: 8px; font-size: 14px; font-weight: 500;
26
+ color: #e0e0e0; cursor: pointer; user-select: none;
27
+ -webkit-tap-highlight-color: transparent;
28
+ }
29
+ #devremote-toolbar .tb-btn:active { background: #0f3460; transform: scale(0.93); }
30
+ #devremote-toolbar .tb-btn.accent {
31
+ background: #00d4aa; color: #0f0f23; font-weight: 600; border-color: #00d4aa;
32
+ }
33
+ #devremote-toolbar .tb-btn.accent:active { background: #00b894; }
34
+ #devremote-toolbar .tb-btn.quick { font-weight: 600; font-size: 16px; text-transform: uppercase; }
35
+ #devremote-toggle {
36
+ position: fixed; top: 6px; right: 6px; z-index: 10000;
37
+ background: #1a1a2e; border: 1px solid #2a2a4a; border-radius: 6px;
38
+ width: 32px; height: 32px;
39
+ display: flex; align-items: center; justify-content: center;
40
+ color: #888; font-size: 18px; cursor: pointer;
41
+ -webkit-tap-highlight-color: transparent;
42
+ }
43
+ #devremote-toggle:active { border-color: #00d4aa; color: #00d4aa; }
44
+ </style>
45
+
46
+ <button id="devremote-toggle" title="Toggle toolbar">&#x2699;</button>
47
+
48
+ <div id="devremote-toolbar" role="toolbar">
49
+ <div class="tb-row">
50
+ <button class="tb-btn" data-key="ArrowUp">&#x25B2;</button>
51
+ <button class="tb-btn" data-key="ArrowDown">&#x25BC;</button>
52
+ <button class="tb-btn accent" data-key="Enter">Enter</button>
53
+ <button class="tb-btn" data-key="Escape">Esc</button>
54
+ <button class="tb-btn" data-key="Tab">Tab</button>
55
+ </div>
56
+ <div class="tb-row">
57
+ <button class="tb-btn quick" data-input="y">Y</button>
58
+ <button class="tb-btn quick" data-input="n">N</button>
59
+ <button class="tb-btn" data-key="c" data-ctrl="true">^C</button>
60
+ <button class="tb-btn" data-key="d" data-ctrl="true">^D</button>
61
+ <button class="tb-btn" data-key="z" data-ctrl="true">^Z</button>
62
+ </div>
63
+ </div>
64
+
65
+ <script>
66
+ (function() {
67
+ var KEY_MAP = {
68
+ ArrowUp: '\\x1b[A', ArrowDown: '\\x1b[B',
69
+ ArrowLeft: '\\x1b[D', ArrowRight: '\\x1b[C',
70
+ Enter: '\\r', Escape: '\\x1b', Tab: '\\t'
71
+ };
72
+
73
+ function ctrlKey(k) { return String.fromCharCode(k.toUpperCase().charCodeAt(0) - 64); }
74
+
75
+ // Intercept WebSocket to capture ttyd connection
76
+ var ttydWs = null;
77
+ var Orig = window.WebSocket;
78
+ window.WebSocket = function() {
79
+ var ws = new (Function.prototype.bind.apply(Orig, [null].concat(Array.prototype.slice.call(arguments))))();
80
+ ttydWs = ws;
81
+ return ws;
82
+ };
83
+ window.WebSocket.prototype = Orig.prototype;
84
+ window.WebSocket.CONNECTING = 0;
85
+ window.WebSocket.OPEN = 1;
86
+ window.WebSocket.CLOSING = 2;
87
+ window.WebSocket.CLOSED = 3;
88
+
89
+ function sendInput(data) {
90
+ if (ttydWs && ttydWs.readyState === 1) {
91
+ var enc = new TextEncoder();
92
+ var payload = enc.encode(data);
93
+ var msg = new Uint8Array(payload.length + 1);
94
+ msg[0] = 0;
95
+ msg.set(payload, 1);
96
+ ttydWs.send(msg);
97
+ }
98
+ }
99
+
100
+ var btns = document.querySelectorAll('#devremote-toolbar .tb-btn');
101
+ for (var i = 0; i < btns.length; i++) {
102
+ (function(btn) {
103
+ btn.addEventListener('click', function(e) {
104
+ e.preventDefault();
105
+ var di = btn.getAttribute('data-input');
106
+ if (di) { sendInput(di + '\\r'); return; }
107
+ var key = btn.getAttribute('data-key');
108
+ var isCtrl = btn.getAttribute('data-ctrl') === 'true';
109
+ if (isCtrl && key) sendInput(ctrlKey(key));
110
+ else if (key && KEY_MAP[key]) sendInput(KEY_MAP[key]);
111
+ });
112
+ btn.addEventListener('contextmenu', function(e) { e.preventDefault(); });
113
+ })(btns[i]);
114
+ }
115
+
116
+ // Toggle
117
+ var toolbar = document.getElementById('devremote-toolbar');
118
+ document.getElementById('devremote-toggle').addEventListener('click', function() {
119
+ toolbar.classList.toggle('hidden');
120
+ });
121
+ })();
122
+ </script>
123
+ `;
124
+ // ---------------------------------------------------------------------------
125
+ // Reverse proxy: injects toolbar into ttyd HTML, proxies WebSocket via TCP
126
+ // ---------------------------------------------------------------------------
127
+ const INTERNAL_TTYD_PORT = 7691;
128
+ export { INTERNAL_TTYD_PORT };
129
+ export function startProxy(proxyPort) {
130
+ const ttydPort = INTERNAL_TTYD_PORT;
131
+ const server = http.createServer((req, res) => {
132
+ const proxyReq = http.request({
133
+ hostname: '127.0.0.1',
134
+ port: ttydPort,
135
+ path: req.url,
136
+ method: req.method,
137
+ headers: { ...req.headers, host: `127.0.0.1:${ttydPort}`, 'accept-encoding': 'identity' },
138
+ }, (proxyRes) => {
139
+ const ct = proxyRes.headers['content-type'] ?? '';
140
+ if (req.url === '/' && ct.includes('text/html')) {
141
+ // Collect full response as buffer, then inject toolbar
142
+ const chunks = [];
143
+ proxyRes.on('data', (chunk) => { chunks.push(chunk); });
144
+ proxyRes.on('end', () => {
145
+ let body = Buffer.concat(chunks).toString('utf8');
146
+ body = body.replace('</body>', TOOLBAR_INJECT + '</body>');
147
+ const headers = { ...proxyRes.headers };
148
+ headers['content-length'] = String(Buffer.byteLength(body, 'utf8'));
149
+ delete headers['content-encoding'];
150
+ res.writeHead(proxyRes.statusCode ?? 200, headers);
151
+ res.end(body, 'utf8');
152
+ });
153
+ }
154
+ else {
155
+ res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
156
+ proxyRes.pipe(res);
157
+ }
158
+ });
159
+ proxyReq.on('error', (err) => {
160
+ res.writeHead(502);
161
+ res.end('Proxy error');
162
+ });
163
+ req.pipe(proxyReq);
164
+ });
165
+ // WebSocket: raw TCP proxy for upgrade requests
166
+ server.on('upgrade', (req, clientSocket, head) => {
167
+ const ttydSocket = net.connect(ttydPort, '127.0.0.1', () => {
168
+ // Reconstruct the HTTP upgrade request to forward to ttyd
169
+ let rawReq = `${req.method} ${req.url} HTTP/1.1\r\n`;
170
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
171
+ const key = req.rawHeaders[i];
172
+ const val = req.rawHeaders[i + 1];
173
+ // Replace host header
174
+ if (key.toLowerCase() === 'host') {
175
+ rawReq += `${key}: 127.0.0.1:${ttydPort}\r\n`;
176
+ }
177
+ else {
178
+ rawReq += `${key}: ${val}\r\n`;
179
+ }
180
+ }
181
+ rawReq += '\r\n';
182
+ ttydSocket.write(rawReq);
183
+ if (head.length > 0) {
184
+ ttydSocket.write(head);
185
+ }
186
+ // Pipe in both directions
187
+ ttydSocket.pipe(clientSocket);
188
+ clientSocket.pipe(ttydSocket);
189
+ });
190
+ ttydSocket.on('error', () => clientSocket.destroy());
191
+ clientSocket.on('error', () => ttydSocket.destroy());
192
+ });
193
+ server.listen(proxyPort, '0.0.0.0');
194
+ return server;
195
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Synchronously execute a command with arguments.
3
+ * Uses execFileSync to prevent shell injection.
4
+ * Returns trimmed stdout as a string.
5
+ */
6
+ export declare function run(cmd: string, args: string[]): string;
7
+ /**
8
+ * Asynchronously execute a command with arguments.
9
+ * Uses execFile (promisified) to prevent shell injection.
10
+ * Returns trimmed stdout as a string.
11
+ */
12
+ export declare function runAsync(cmd: string, args: string[]): Promise<string>;
13
+ /**
14
+ * Check whether a command exists in PATH.
15
+ * Returns true if found, false otherwise.
16
+ */
17
+ export declare function which(cmd: string): boolean;
18
+ /**
19
+ * Check whether a given port is currently in use.
20
+ * Uses lsof to inspect open files/sockets.
21
+ */
22
+ export declare function isPortInUse(port: number): boolean;