palmier 0.2.1 → 0.2.2
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/README.md +1 -1
- package/dist/commands/pair.js +52 -78
- package/dist/transports/http-transport.js +87 -1
- package/package.json +1 -1
- package/src/commands/pair.ts +61 -93
- package/src/transports/http-transport.ts +111 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Palmier
|
|
2
2
|
|
|
3
|
-
A Node.js CLI that runs on your machine as a persistent daemon. It manages tasks, communicates with the Palmier
|
|
3
|
+
A Node.js CLI that runs on your machine as a persistent daemon. It manages tasks, communicates with the Palmier app via NATS and/or direct HTTP, and executes tasks on schedule or demand using CLI tools.
|
|
4
4
|
|
|
5
5
|
## Connection Modes
|
|
6
6
|
|
package/dist/commands/pair.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as os from "os";
|
|
2
1
|
import * as http from "node:http";
|
|
3
2
|
import { StringCodec } from "nats";
|
|
4
3
|
import { loadConfig } from "../config.js";
|
|
@@ -12,30 +11,53 @@ function generateCode() {
|
|
|
12
11
|
crypto.getRandomValues(bytes);
|
|
13
12
|
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
14
13
|
}
|
|
15
|
-
function detectLanIp() {
|
|
16
|
-
const interfaces = os.networkInterfaces();
|
|
17
|
-
for (const name of Object.keys(interfaces)) {
|
|
18
|
-
for (const iface of interfaces[name] ?? []) {
|
|
19
|
-
if (iface.family === "IPv4" && !iface.internal) {
|
|
20
|
-
return iface.address;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return "127.0.0.1";
|
|
25
|
-
}
|
|
26
14
|
function buildPairResponse(config, label) {
|
|
27
15
|
const session = addSession(label);
|
|
28
16
|
const response = {
|
|
29
17
|
hostId: config.hostId,
|
|
30
18
|
sessionToken: session.token,
|
|
31
19
|
};
|
|
32
|
-
if (config.mode === "lan" || config.mode === "auto") {
|
|
33
|
-
const ip = detectLanIp();
|
|
34
|
-
response.directUrl = `http://${ip}:${config.directPort ?? 7400}`;
|
|
35
|
-
response.directToken = config.directToken;
|
|
36
|
-
}
|
|
37
20
|
return response;
|
|
38
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* POST to the running serve process and long-poll until paired or expired.
|
|
24
|
+
* Returns true if paired, false if expired/failed.
|
|
25
|
+
*/
|
|
26
|
+
function lanPairRegister(port, code) {
|
|
27
|
+
const body = JSON.stringify({ code, expiryMs: EXPIRY_MS });
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const req = http.request({
|
|
30
|
+
hostname: "127.0.0.1",
|
|
31
|
+
port,
|
|
32
|
+
path: "/internal/pair-register",
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
timeout: EXPIRY_MS + 5000, // slightly longer than expiry
|
|
36
|
+
}, (res) => {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
39
|
+
res.on("end", () => {
|
|
40
|
+
try {
|
|
41
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
42
|
+
resolve(result.paired);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
resolve(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
req.on("error", (err) => {
|
|
50
|
+
console.error(`Failed to reach palmier serve on port ${port}: ${err.message}`);
|
|
51
|
+
console.error("Make sure `palmier serve` is running first.");
|
|
52
|
+
resolve(false);
|
|
53
|
+
});
|
|
54
|
+
req.on("timeout", () => {
|
|
55
|
+
req.destroy();
|
|
56
|
+
resolve(false);
|
|
57
|
+
});
|
|
58
|
+
req.end(body);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
39
61
|
/**
|
|
40
62
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
41
63
|
* Listens on NATS (nats/auto modes) and/or HTTP (lan/auto modes).
|
|
@@ -50,6 +72,13 @@ export async function pairCommand() {
|
|
|
50
72
|
console.log("Paired successfully!");
|
|
51
73
|
}
|
|
52
74
|
const cleanups = [];
|
|
75
|
+
// Display pairing info
|
|
76
|
+
console.log("");
|
|
77
|
+
console.log("Enter this code in your Palmier app:");
|
|
78
|
+
console.log("");
|
|
79
|
+
console.log(` ${code}`);
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log("Code expires in 5 minutes.");
|
|
53
82
|
// NATS pairing (nats or auto mode)
|
|
54
83
|
if (mode === "nats" || mode === "auto") {
|
|
55
84
|
const nc = await connectNats(config);
|
|
@@ -80,70 +109,15 @@ export async function pairCommand() {
|
|
|
80
109
|
}
|
|
81
110
|
})();
|
|
82
111
|
}
|
|
83
|
-
//
|
|
112
|
+
// LAN pairing — long-poll the running serve process
|
|
84
113
|
if (mode === "lan" || mode === "auto") {
|
|
85
114
|
const port = config.directPort ?? 7400;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
res.writeHead(204);
|
|
92
|
-
res.end();
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (req.method === "POST" && req.url === "/pair") {
|
|
96
|
-
const chunks = [];
|
|
97
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
98
|
-
req.on("end", () => {
|
|
99
|
-
try {
|
|
100
|
-
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
101
|
-
if (body.code !== code) {
|
|
102
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
103
|
-
res.end(JSON.stringify({ error: "Invalid code" }));
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (paired) {
|
|
107
|
-
res.writeHead(410, { "Content-Type": "application/json" });
|
|
108
|
-
res.end(JSON.stringify({ error: "Code already used" }));
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
const response = buildPairResponse(config, body.label);
|
|
112
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
113
|
-
res.end(JSON.stringify(response));
|
|
114
|
-
onPaired();
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
118
|
-
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
124
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
125
|
-
});
|
|
126
|
-
await new Promise((resolve, reject) => {
|
|
127
|
-
server.listen(port + 1, () => resolve());
|
|
128
|
-
server.on("error", reject);
|
|
129
|
-
});
|
|
130
|
-
cleanups.push(() => {
|
|
131
|
-
server.close();
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
// Display pairing info
|
|
135
|
-
console.log("");
|
|
136
|
-
console.log("Enter this code in your Palmier app:");
|
|
137
|
-
console.log("");
|
|
138
|
-
console.log(` ${code}`);
|
|
139
|
-
console.log("");
|
|
140
|
-
if (mode === "lan" || mode === "auto") {
|
|
141
|
-
const ip = detectLanIp();
|
|
142
|
-
const port = config.directPort ?? 7400;
|
|
143
|
-
console.log(` Address: ${ip}:${port + 1}`);
|
|
144
|
-
console.log("");
|
|
115
|
+
(async () => {
|
|
116
|
+
const result = await lanPairRegister(port, code);
|
|
117
|
+
if (result)
|
|
118
|
+
onPaired();
|
|
119
|
+
})();
|
|
145
120
|
}
|
|
146
|
-
console.log("Code expires in 5 minutes.");
|
|
147
121
|
// Wait for pairing or timeout
|
|
148
122
|
const start = Date.now();
|
|
149
123
|
await new Promise((resolve) => {
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import * as http from "node:http";
|
|
2
|
-
import
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import { validateSession, hasSessions, addSession } from "../session-store.js";
|
|
4
|
+
const pendingPairs = new Map();
|
|
5
|
+
function detectLanIp() {
|
|
6
|
+
const interfaces = os.networkInterfaces();
|
|
7
|
+
for (const name of Object.keys(interfaces)) {
|
|
8
|
+
for (const iface of interfaces[name] ?? []) {
|
|
9
|
+
if (iface.family === "IPv4" && !iface.internal) {
|
|
10
|
+
return iface.address;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return "127.0.0.1";
|
|
15
|
+
}
|
|
3
16
|
/**
|
|
4
17
|
* Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
|
|
5
18
|
*/
|
|
@@ -78,6 +91,79 @@ export async function startHttpTransport(config, handleRpc) {
|
|
|
78
91
|
}
|
|
79
92
|
return;
|
|
80
93
|
}
|
|
94
|
+
// Internal pair-register endpoint — localhost only, long-poll
|
|
95
|
+
// The pair CLI posts here and blocks until paired or expired.
|
|
96
|
+
if (req.method === "POST" && pathname === "/internal/pair-register") {
|
|
97
|
+
if (!isLocalhost(req)) {
|
|
98
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const body = await readBody(req);
|
|
103
|
+
const { code, expiryMs } = JSON.parse(body);
|
|
104
|
+
if (!code) {
|
|
105
|
+
sendJson(res, 400, { error: "Missing code" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (pendingPairs.has(code)) {
|
|
109
|
+
sendJson(res, 409, { error: "Code already registered" });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const result = await new Promise((resolve) => {
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
pendingPairs.delete(code);
|
|
115
|
+
resolve({ paired: false });
|
|
116
|
+
}, expiryMs ?? 5 * 60 * 1000);
|
|
117
|
+
pendingPairs.set(code, { resolve, timer });
|
|
118
|
+
// Clean up if the CLI disconnects early
|
|
119
|
+
req.on("close", () => {
|
|
120
|
+
if (pendingPairs.has(code)) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
pendingPairs.delete(code);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
sendJson(res, 200, result);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Public pair endpoint — no auth required, PWA posts OTP code here
|
|
134
|
+
if (req.method === "POST" && pathname === "/pair") {
|
|
135
|
+
try {
|
|
136
|
+
const body = await readBody(req);
|
|
137
|
+
const { code, label } = JSON.parse(body);
|
|
138
|
+
if (!code) {
|
|
139
|
+
sendJson(res, 400, { error: "Missing code" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const pending = pendingPairs.get(code);
|
|
143
|
+
if (!pending) {
|
|
144
|
+
sendJson(res, 401, { error: "Invalid code" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Create session and build response
|
|
148
|
+
const session = addSession(label);
|
|
149
|
+
const ip = detectLanIp();
|
|
150
|
+
const response = {
|
|
151
|
+
hostId: config.hostId,
|
|
152
|
+
sessionToken: session.token,
|
|
153
|
+
directUrl: `http://${ip}:${port}`,
|
|
154
|
+
directToken: config.directToken,
|
|
155
|
+
};
|
|
156
|
+
// Resolve the long-poll and clean up
|
|
157
|
+
clearTimeout(pending.timer);
|
|
158
|
+
pendingPairs.delete(code);
|
|
159
|
+
pending.resolve({ paired: true });
|
|
160
|
+
sendJson(res, 200, response);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
81
167
|
// All other endpoints require auth
|
|
82
168
|
if (!checkAuth(req)) {
|
|
83
169
|
sendJson(res, 401, { error: "Unauthorized" });
|
package/package.json
CHANGED
package/src/commands/pair.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as os from "os";
|
|
2
1
|
import * as http from "node:http";
|
|
3
2
|
import { StringCodec } from "nats";
|
|
4
3
|
import { loadConfig } from "../config.js";
|
|
@@ -16,18 +15,6 @@ function generateCode(): string {
|
|
|
16
15
|
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
function detectLanIp(): string {
|
|
20
|
-
const interfaces = os.networkInterfaces();
|
|
21
|
-
for (const name of Object.keys(interfaces)) {
|
|
22
|
-
for (const iface of interfaces[name] ?? []) {
|
|
23
|
-
if (iface.family === "IPv4" && !iface.internal) {
|
|
24
|
-
return iface.address;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return "127.0.0.1";
|
|
29
|
-
}
|
|
30
|
-
|
|
31
18
|
function buildPairResponse(config: HostConfig, label?: string) {
|
|
32
19
|
const session = addSession(label);
|
|
33
20
|
const response: Record<string, unknown> = {
|
|
@@ -35,15 +22,57 @@ function buildPairResponse(config: HostConfig, label?: string) {
|
|
|
35
22
|
sessionToken: session.token,
|
|
36
23
|
};
|
|
37
24
|
|
|
38
|
-
if (config.mode === "lan" || config.mode === "auto") {
|
|
39
|
-
const ip = detectLanIp();
|
|
40
|
-
response.directUrl = `http://${ip}:${config.directPort ?? 7400}`;
|
|
41
|
-
response.directToken = config.directToken;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
25
|
return response;
|
|
45
26
|
}
|
|
46
27
|
|
|
28
|
+
/**
|
|
29
|
+
* POST to the running serve process and long-poll until paired or expired.
|
|
30
|
+
* Returns true if paired, false if expired/failed.
|
|
31
|
+
*/
|
|
32
|
+
function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
33
|
+
const body = JSON.stringify({ code, expiryMs: EXPIRY_MS });
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const req = http.request(
|
|
37
|
+
{
|
|
38
|
+
hostname: "127.0.0.1",
|
|
39
|
+
port,
|
|
40
|
+
path: "/internal/pair-register",
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
timeout: EXPIRY_MS + 5000, // slightly longer than expiry
|
|
44
|
+
},
|
|
45
|
+
(res) => {
|
|
46
|
+
const chunks: Buffer[] = [];
|
|
47
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
48
|
+
res.on("end", () => {
|
|
49
|
+
try {
|
|
50
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as {
|
|
51
|
+
paired: boolean;
|
|
52
|
+
};
|
|
53
|
+
resolve(result.paired);
|
|
54
|
+
} catch {
|
|
55
|
+
resolve(false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
req.on("error", (err) => {
|
|
62
|
+
console.error(`Failed to reach palmier serve on port ${port}: ${err.message}`);
|
|
63
|
+
console.error("Make sure `palmier serve` is running first.");
|
|
64
|
+
resolve(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
req.on("timeout", () => {
|
|
68
|
+
req.destroy();
|
|
69
|
+
resolve(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
req.end(body);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
/**
|
|
48
77
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
49
78
|
* Listens on NATS (nats/auto modes) and/or HTTP (lan/auto modes).
|
|
@@ -62,6 +91,14 @@ export async function pairCommand(): Promise<void> {
|
|
|
62
91
|
|
|
63
92
|
const cleanups: Array<() => void | Promise<void>> = [];
|
|
64
93
|
|
|
94
|
+
// Display pairing info
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log("Enter this code in your Palmier app:");
|
|
97
|
+
console.log("");
|
|
98
|
+
console.log(` ${code}`);
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log("Code expires in 5 minutes.");
|
|
101
|
+
|
|
65
102
|
// NATS pairing (nats or auto mode)
|
|
66
103
|
if (mode === "nats" || mode === "auto") {
|
|
67
104
|
const nc = await connectNats(config);
|
|
@@ -94,84 +131,15 @@ export async function pairCommand(): Promise<void> {
|
|
|
94
131
|
})();
|
|
95
132
|
}
|
|
96
133
|
|
|
97
|
-
//
|
|
134
|
+
// LAN pairing — long-poll the running serve process
|
|
98
135
|
if (mode === "lan" || mode === "auto") {
|
|
99
136
|
const port = config.directPort ?? 7400;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (req.method === "OPTIONS") {
|
|
106
|
-
res.writeHead(204);
|
|
107
|
-
res.end();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (req.method === "POST" && req.url === "/pair") {
|
|
112
|
-
const chunks: Buffer[] = [];
|
|
113
|
-
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
114
|
-
req.on("end", () => {
|
|
115
|
-
try {
|
|
116
|
-
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as {
|
|
117
|
-
code: string;
|
|
118
|
-
label?: string;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
if (body.code !== code) {
|
|
122
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
123
|
-
res.end(JSON.stringify({ error: "Invalid code" }));
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (paired) {
|
|
128
|
-
res.writeHead(410, { "Content-Type": "application/json" });
|
|
129
|
-
res.end(JSON.stringify({ error: "Code already used" }));
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const response = buildPairResponse(config, body.label);
|
|
134
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
135
|
-
res.end(JSON.stringify(response));
|
|
136
|
-
onPaired();
|
|
137
|
-
} catch {
|
|
138
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
139
|
-
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
146
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
await new Promise<void>((resolve, reject) => {
|
|
150
|
-
server.listen(port + 1, () => resolve());
|
|
151
|
-
server.on("error", reject);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
cleanups.push(() => {
|
|
155
|
-
server.close();
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Display pairing info
|
|
160
|
-
console.log("");
|
|
161
|
-
console.log("Enter this code in your Palmier app:");
|
|
162
|
-
console.log("");
|
|
163
|
-
console.log(` ${code}`);
|
|
164
|
-
console.log("");
|
|
165
|
-
|
|
166
|
-
if (mode === "lan" || mode === "auto") {
|
|
167
|
-
const ip = detectLanIp();
|
|
168
|
-
const port = config.directPort ?? 7400;
|
|
169
|
-
console.log(` Address: ${ip}:${port + 1}`);
|
|
170
|
-
console.log("");
|
|
137
|
+
(async () => {
|
|
138
|
+
const result = await lanPairRegister(port, code);
|
|
139
|
+
if (result) onPaired();
|
|
140
|
+
})();
|
|
171
141
|
}
|
|
172
142
|
|
|
173
|
-
console.log("Code expires in 5 minutes.");
|
|
174
|
-
|
|
175
143
|
// Wait for pairing or timeout
|
|
176
144
|
const start = Date.now();
|
|
177
145
|
await new Promise<void>((resolve) => {
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import * as http from "node:http";
|
|
2
|
-
import
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import { validateSession, hasSessions, addSession } from "../session-store.js";
|
|
3
4
|
import type { HostConfig, RpcMessage } from "../types.js";
|
|
4
5
|
|
|
5
6
|
type SseClient = http.ServerResponse;
|
|
6
7
|
|
|
8
|
+
interface PendingPair {
|
|
9
|
+
resolve: (result: { paired: boolean }) => void;
|
|
10
|
+
timer: ReturnType<typeof setTimeout>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const pendingPairs = new Map<string, PendingPair>();
|
|
14
|
+
|
|
15
|
+
function detectLanIp(): string {
|
|
16
|
+
const interfaces = os.networkInterfaces();
|
|
17
|
+
for (const name of Object.keys(interfaces)) {
|
|
18
|
+
for (const iface of interfaces[name] ?? []) {
|
|
19
|
+
if (iface.family === "IPv4" && !iface.internal) {
|
|
20
|
+
return iface.address;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return "127.0.0.1";
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
/**
|
|
8
28
|
* Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
|
|
9
29
|
*/
|
|
@@ -92,6 +112,96 @@ export async function startHttpTransport(
|
|
|
92
112
|
return;
|
|
93
113
|
}
|
|
94
114
|
|
|
115
|
+
// Internal pair-register endpoint — localhost only, long-poll
|
|
116
|
+
// The pair CLI posts here and blocks until paired or expired.
|
|
117
|
+
if (req.method === "POST" && pathname === "/internal/pair-register") {
|
|
118
|
+
if (!isLocalhost(req)) {
|
|
119
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const body = await readBody(req);
|
|
124
|
+
const { code, expiryMs } = JSON.parse(body) as {
|
|
125
|
+
code: string;
|
|
126
|
+
expiryMs: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (!code) {
|
|
130
|
+
sendJson(res, 400, { error: "Missing code" });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (pendingPairs.has(code)) {
|
|
135
|
+
sendJson(res, 409, { error: "Code already registered" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const result = await new Promise<{ paired: boolean }>((resolve) => {
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
pendingPairs.delete(code);
|
|
142
|
+
resolve({ paired: false });
|
|
143
|
+
}, expiryMs ?? 5 * 60 * 1000);
|
|
144
|
+
|
|
145
|
+
pendingPairs.set(code, { resolve, timer });
|
|
146
|
+
|
|
147
|
+
// Clean up if the CLI disconnects early
|
|
148
|
+
req.on("close", () => {
|
|
149
|
+
if (pendingPairs.has(code)) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
pendingPairs.delete(code);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
sendJson(res, 200, result);
|
|
157
|
+
} catch {
|
|
158
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Public pair endpoint — no auth required, PWA posts OTP code here
|
|
164
|
+
if (req.method === "POST" && pathname === "/pair") {
|
|
165
|
+
try {
|
|
166
|
+
const body = await readBody(req);
|
|
167
|
+
const { code, label } = JSON.parse(body) as {
|
|
168
|
+
code: string;
|
|
169
|
+
label?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (!code) {
|
|
173
|
+
sendJson(res, 400, { error: "Missing code" });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const pending = pendingPairs.get(code);
|
|
178
|
+
if (!pending) {
|
|
179
|
+
sendJson(res, 401, { error: "Invalid code" });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Create session and build response
|
|
184
|
+
const session = addSession(label);
|
|
185
|
+
const ip = detectLanIp();
|
|
186
|
+
const response: Record<string, unknown> = {
|
|
187
|
+
hostId: config.hostId,
|
|
188
|
+
sessionToken: session.token,
|
|
189
|
+
directUrl: `http://${ip}:${port}`,
|
|
190
|
+
directToken: config.directToken,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Resolve the long-poll and clean up
|
|
194
|
+
clearTimeout(pending.timer);
|
|
195
|
+
pendingPairs.delete(code);
|
|
196
|
+
pending.resolve({ paired: true });
|
|
197
|
+
|
|
198
|
+
sendJson(res, 200, response);
|
|
199
|
+
} catch {
|
|
200
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
95
205
|
// All other endpoints require auth
|
|
96
206
|
if (!checkAuth(req)) {
|
|
97
207
|
sendJson(res, 401, { error: "Unauthorized" });
|