palmier 0.2.1 → 0.2.3
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 +59 -78
- package/dist/transports/http-transport.d.ts +1 -0
- package/dist/transports/http-transport.js +87 -1
- package/package.json +1 -1
- package/src/commands/pair.ts +70 -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,8 +1,8 @@
|
|
|
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";
|
|
5
4
|
import { connectNats } from "../nats-client.js";
|
|
5
|
+
import { detectLanIp } from "../transports/http-transport.js";
|
|
6
6
|
import { addSession } from "../session-store.js";
|
|
7
7
|
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
8
8
|
const CODE_LENGTH = 6;
|
|
@@ -12,30 +12,53 @@ function generateCode() {
|
|
|
12
12
|
crypto.getRandomValues(bytes);
|
|
13
13
|
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
14
14
|
}
|
|
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
15
|
function buildPairResponse(config, label) {
|
|
27
16
|
const session = addSession(label);
|
|
28
17
|
const response = {
|
|
29
18
|
hostId: config.hostId,
|
|
30
19
|
sessionToken: session.token,
|
|
31
20
|
};
|
|
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
21
|
return response;
|
|
38
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* POST to the running serve process and long-poll until paired or expired.
|
|
25
|
+
* Returns true if paired, false if expired/failed.
|
|
26
|
+
*/
|
|
27
|
+
function lanPairRegister(port, code) {
|
|
28
|
+
const body = JSON.stringify({ code, expiryMs: EXPIRY_MS });
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const req = http.request({
|
|
31
|
+
hostname: "127.0.0.1",
|
|
32
|
+
port,
|
|
33
|
+
path: "/internal/pair-register",
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
timeout: EXPIRY_MS + 5000, // slightly longer than expiry
|
|
37
|
+
}, (res) => {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
40
|
+
res.on("end", () => {
|
|
41
|
+
try {
|
|
42
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
43
|
+
resolve(result.paired);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
resolve(false);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
req.on("error", (err) => {
|
|
51
|
+
console.error(`Failed to reach palmier serve on port ${port}: ${err.message}`);
|
|
52
|
+
console.error("Make sure `palmier serve` is running first.");
|
|
53
|
+
resolve(false);
|
|
54
|
+
});
|
|
55
|
+
req.on("timeout", () => {
|
|
56
|
+
req.destroy();
|
|
57
|
+
resolve(false);
|
|
58
|
+
});
|
|
59
|
+
req.end(body);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
39
62
|
/**
|
|
40
63
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
41
64
|
* Listens on NATS (nats/auto modes) and/or HTTP (lan/auto modes).
|
|
@@ -50,6 +73,19 @@ export async function pairCommand() {
|
|
|
50
73
|
console.log("Paired successfully!");
|
|
51
74
|
}
|
|
52
75
|
const cleanups = [];
|
|
76
|
+
// Display pairing info
|
|
77
|
+
console.log("");
|
|
78
|
+
console.log("Enter this code in your Palmier app:");
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log(` ${code}`);
|
|
81
|
+
console.log("");
|
|
82
|
+
if (mode === "lan" || mode === "auto") {
|
|
83
|
+
const ip = detectLanIp();
|
|
84
|
+
const port = config.directPort ?? 7400;
|
|
85
|
+
console.log(` LAN Address: ${ip}:${port}`);
|
|
86
|
+
console.log("");
|
|
87
|
+
}
|
|
88
|
+
console.log("Code expires in 5 minutes.");
|
|
53
89
|
// NATS pairing (nats or auto mode)
|
|
54
90
|
if (mode === "nats" || mode === "auto") {
|
|
55
91
|
const nc = await connectNats(config);
|
|
@@ -80,70 +116,15 @@ export async function pairCommand() {
|
|
|
80
116
|
}
|
|
81
117
|
})();
|
|
82
118
|
}
|
|
83
|
-
//
|
|
84
|
-
if (mode === "lan" || mode === "auto") {
|
|
85
|
-
const port = config.directPort ?? 7400;
|
|
86
|
-
const server = http.createServer(async (req, res) => {
|
|
87
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
88
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
89
|
-
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
90
|
-
if (req.method === "OPTIONS") {
|
|
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("");
|
|
119
|
+
// LAN pairing — long-poll the running serve process
|
|
140
120
|
if (mode === "lan" || mode === "auto") {
|
|
141
|
-
const ip = detectLanIp();
|
|
142
121
|
const port = config.directPort ?? 7400;
|
|
143
|
-
|
|
144
|
-
|
|
122
|
+
(async () => {
|
|
123
|
+
const result = await lanPairRegister(port, code);
|
|
124
|
+
if (result)
|
|
125
|
+
onPaired();
|
|
126
|
+
})();
|
|
145
127
|
}
|
|
146
|
-
console.log("Code expires in 5 minutes.");
|
|
147
128
|
// Wait for pairing or timeout
|
|
148
129
|
const start = Date.now();
|
|
149
130
|
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
|
+
export 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,8 +1,8 @@
|
|
|
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";
|
|
5
4
|
import { connectNats } from "../nats-client.js";
|
|
5
|
+
import { detectLanIp } from "../transports/http-transport.js";
|
|
6
6
|
import { addSession } from "../session-store.js";
|
|
7
7
|
import type { HostConfig } from "../types.js";
|
|
8
8
|
|
|
@@ -16,18 +16,6 @@ function generateCode(): string {
|
|
|
16
16
|
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
17
17
|
}
|
|
18
18
|
|
|
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
19
|
function buildPairResponse(config: HostConfig, label?: string) {
|
|
32
20
|
const session = addSession(label);
|
|
33
21
|
const response: Record<string, unknown> = {
|
|
@@ -35,15 +23,57 @@ function buildPairResponse(config: HostConfig, label?: string) {
|
|
|
35
23
|
sessionToken: session.token,
|
|
36
24
|
};
|
|
37
25
|
|
|
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
26
|
return response;
|
|
45
27
|
}
|
|
46
28
|
|
|
29
|
+
/**
|
|
30
|
+
* POST to the running serve process and long-poll until paired or expired.
|
|
31
|
+
* Returns true if paired, false if expired/failed.
|
|
32
|
+
*/
|
|
33
|
+
function lanPairRegister(port: number, code: string): Promise<boolean> {
|
|
34
|
+
const body = JSON.stringify({ code, expiryMs: EXPIRY_MS });
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const req = http.request(
|
|
38
|
+
{
|
|
39
|
+
hostname: "127.0.0.1",
|
|
40
|
+
port,
|
|
41
|
+
path: "/internal/pair-register",
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
timeout: EXPIRY_MS + 5000, // slightly longer than expiry
|
|
45
|
+
},
|
|
46
|
+
(res) => {
|
|
47
|
+
const chunks: Buffer[] = [];
|
|
48
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
49
|
+
res.on("end", () => {
|
|
50
|
+
try {
|
|
51
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as {
|
|
52
|
+
paired: boolean;
|
|
53
|
+
};
|
|
54
|
+
resolve(result.paired);
|
|
55
|
+
} catch {
|
|
56
|
+
resolve(false);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
req.on("error", (err) => {
|
|
63
|
+
console.error(`Failed to reach palmier serve on port ${port}: ${err.message}`);
|
|
64
|
+
console.error("Make sure `palmier serve` is running first.");
|
|
65
|
+
resolve(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
req.on("timeout", () => {
|
|
69
|
+
req.destroy();
|
|
70
|
+
resolve(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
req.end(body);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
/**
|
|
48
78
|
* Generate an OTP code and wait for a PWA client to pair.
|
|
49
79
|
* Listens on NATS (nats/auto modes) and/or HTTP (lan/auto modes).
|
|
@@ -62,6 +92,22 @@ export async function pairCommand(): Promise<void> {
|
|
|
62
92
|
|
|
63
93
|
const cleanups: Array<() => void | Promise<void>> = [];
|
|
64
94
|
|
|
95
|
+
// Display pairing info
|
|
96
|
+
console.log("");
|
|
97
|
+
console.log("Enter this code in your Palmier app:");
|
|
98
|
+
console.log("");
|
|
99
|
+
console.log(` ${code}`);
|
|
100
|
+
console.log("");
|
|
101
|
+
|
|
102
|
+
if (mode === "lan" || mode === "auto") {
|
|
103
|
+
const ip = detectLanIp();
|
|
104
|
+
const port = config.directPort ?? 7400;
|
|
105
|
+
console.log(` LAN Address: ${ip}:${port}`);
|
|
106
|
+
console.log("");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log("Code expires in 5 minutes.");
|
|
110
|
+
|
|
65
111
|
// NATS pairing (nats or auto mode)
|
|
66
112
|
if (mode === "nats" || mode === "auto") {
|
|
67
113
|
const nc = await connectNats(config);
|
|
@@ -94,84 +140,15 @@ export async function pairCommand(): Promise<void> {
|
|
|
94
140
|
})();
|
|
95
141
|
}
|
|
96
142
|
|
|
97
|
-
//
|
|
143
|
+
// LAN pairing — long-poll the running serve process
|
|
98
144
|
if (mode === "lan" || mode === "auto") {
|
|
99
145
|
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("");
|
|
146
|
+
(async () => {
|
|
147
|
+
const result = await lanPairRegister(port, code);
|
|
148
|
+
if (result) onPaired();
|
|
149
|
+
})();
|
|
171
150
|
}
|
|
172
151
|
|
|
173
|
-
console.log("Code expires in 5 minutes.");
|
|
174
|
-
|
|
175
152
|
// Wait for pairing or timeout
|
|
176
153
|
const start = Date.now();
|
|
177
154
|
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
|
+
export 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" });
|