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 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 platform via NATS and/or direct HTTP, and executes CLI tools autonomously.
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
 
@@ -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
- // HTTP pairing (lan or auto mode)
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
- console.log(` Address: ${ip}:${port + 1}`);
144
- console.log("");
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,4 +1,5 @@
1
1
  import type { HostConfig, RpcMessage } from "../types.js";
2
+ export declare function detectLanIp(): string;
2
3
  /**
3
4
  * Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
4
5
  */
@@ -1,5 +1,18 @@
1
1
  import * as http from "node:http";
2
- import { validateSession, hasSessions } from "../session-store.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "ISC",
6
6
  "author": "Hongxu Cai",
@@ -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
- // HTTP pairing (lan or auto mode)
143
+ // LAN pairing long-poll the running serve process
98
144
  if (mode === "lan" || mode === "auto") {
99
145
  const port = config.directPort ?? 7400;
100
- const server = http.createServer(async (req, res) => {
101
- res.setHeader("Access-Control-Allow-Origin", "*");
102
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
103
- res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
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 { validateSession, hasSessions } from "../session-store.js";
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" });