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 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,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
- // HTTP pairing (lan or auto mode)
112
+ // LAN pairing long-poll the running serve process
84
113
  if (mode === "lan" || mode === "auto") {
85
114
  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("");
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 { 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
+ 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.2",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "ISC",
6
6
  "author": "Hongxu Cai",
@@ -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
- // HTTP pairing (lan or auto mode)
134
+ // LAN pairing long-poll the running serve process
98
135
  if (mode === "lan" || mode === "auto") {
99
136
  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("");
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 { 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
+ 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" });