palmier 0.2.6 → 0.2.7

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
@@ -8,17 +8,16 @@ A Node.js CLI that runs on your machine as a persistent daemon. It manages tasks
8
8
 
9
9
  ## Connection Modes
10
10
 
11
- The host supports three connection modes:
11
+ The host supports two independent connection modes, enabled during `palmier init`. Both can be active at the same time.
12
12
 
13
- | Mode | Transport | Setup | Features |
14
- |------|-----------|-------|----------|
15
- | **`nats`** | NATS only | `palmier init` Full access, skip LAN | All features |
16
- | **`auto`** | Both NATS + HTTP | `palmier init` → Full access + LAN | All features. PWA auto-detects best route. |
17
- | **`lan`** | HTTP only | `palmier init` → LAN only | No push notifications. No server needed. |
13
+ | Mode | Transport | PWA URL | Features |
14
+ |------|-----------|---------|----------|
15
+ | **Server** | NATS (cloud relay) | `https://app.palmier.me` | Push notifications, remote access |
16
+ | **LAN** | HTTP (direct, on-demand) | `http://<host-ip>:7400` | Low-latency, no external server needed |
18
17
 
19
- In **auto** mode, the PWA connects directly to the host via HTTP when on the same LAN (lower latency), and falls back to NATS when the host is unreachable. Push notifications always flow through NATS/server, so no features are lost.
18
+ **Server mode** relays communication through the Palmier server via NATS. All features including push notifications are available. The PWA is served over HTTPS.
20
19
 
21
- In **lan** mode, the host runs a standalone HTTP server. Multiple PWA clients can connect using session tokens. No Palmier web server or NATS broker is needed.
20
+ **LAN mode** is started on-demand via `palmier lan`. It runs a local HTTP server that reverse-proxies PWA assets from `app.palmier.me` and serves API endpoints locally. The browser accesses everything at `http://<host-ip>:<port>` (same-origin). Push notifications are not available in LAN mode.
22
21
 
23
22
  ## Prerequisites
24
23
 
@@ -36,8 +35,9 @@ npm install -g palmier
36
35
 
37
36
  | Command | Description |
38
37
  |---|---|
39
- | `palmier init` | Interactive setup wizard (full access or LAN only) |
40
- | `palmier pair` | Generate an OTP code to pair a new device |
38
+ | `palmier init` | Interactive setup wizard |
39
+ | `palmier pair` | Generate an OTP code to pair a new device (server mode) |
40
+ | `palmier lan` | Start an on-demand LAN server with built-in pairing |
41
41
  | `palmier sessions list` | List active session tokens |
42
42
  | `palmier sessions revoke <token>` | Revoke a specific session token |
43
43
  | `palmier sessions revoke-all` | Revoke all session tokens |
@@ -54,16 +54,14 @@ npm install -g palmier
54
54
 
55
55
  1. Install the host: `npm install -g palmier`
56
56
  2. Run `palmier init` in your project directory.
57
- 3. The wizard walks you through:
58
- - **Full access** or **LAN only** mode
59
- - Server URL (for full access, used for registration only — not stored in config)
60
- - Optional LAN access (for full access — auto-detects best route when enabled)
61
- 4. The host saves config, installs a background daemon (systemd on Linux, Registry Run key on Windows), and generates a pairing code.
62
- 5. Enter the pairing code in the Palmier PWA to connect your device.
57
+ 3. The wizard detects installed agents, registers with the Palmier server, installs a background daemon, and generates a pairing code.
58
+ 4. Enter the pairing code in the Palmier PWA to connect your device.
63
59
 
64
60
  ### Pairing additional devices
65
61
 
66
- Run `palmier pair` on the host to generate a new OTP code. Each paired device gets its own session token.
62
+ **Server mode:** Run `palmier pair` on the host to generate a new OTP code. Enter it in the PWA at `https://app.palmier.me`.
63
+
64
+ **LAN mode:** Run `palmier lan` — it displays both the URL and a pairing code. Open the URL on your device and enter the code.
67
65
 
68
66
  ### Managing sessions
69
67
 
@@ -119,15 +117,15 @@ palmier restart
119
117
  ## How It Works
120
118
 
121
119
  - The host runs as a **background daemon** (systemd user service on Linux, Registry Run key on Windows), staying alive via `palmier serve`.
122
- - **Paired devices** communicate with the host via NATS (cloud-routed) and/or direct HTTP (LAN), depending on the connection mode. Each paired device gets a session token that authenticates all requests.
123
- - **Tasks** are stored locally as Markdown files in a `tasks/` directory. Each task has a name, prompt, execution plan, and optional triggers (cron schedules or one-time dates).
120
+ - **Paired devices** communicate with the host via NATS (server mode) and/or direct HTTP (LAN mode). Each paired device gets a session token that authenticates all requests.
121
+ - **Tasks** are stored locally as Markdown files in a `tasks/` directory. Each task has a name, prompt, execution plan, and optional schedules (cron schedules or one-time dates).
124
122
  - **Plan generation** is automatic — when you create or update a task, the host invokes your chosen agent CLI to generate an execution plan and name.
125
- - **Triggers** are backed by systemd timers (Linux) or Task Scheduler (Windows). You can enable/disable them without deleting the task, and any task can still be run manually at any time.
123
+ - **Schedules** are backed by systemd timers (Linux) or Task Scheduler (Windows). You can enable/disable them without deleting the task, and any task can still be run manually at any time.
126
124
  - **Task execution** uses the system scheduler on both platforms — `systemctl --user start` on Linux, `schtasks /run` on Windows. The daemon polls every 30 seconds to detect crashed tasks (processes that exited without updating status) and marks them as failed, broadcasting the failure to connected clients.
127
125
  - **Command-triggered tasks** — optionally specify a shell command (e.g., `tail -f /var/log/app.log`). Palmier runs the command continuously and invokes the agent for each line of stdout, passing it alongside your prompt. Useful for log monitoring, event-driven automation, and reactive workflows.
128
- - **Task confirmation** — tasks can optionally require your approval before running. You'll get a push notification (NATS mode) or a prompt in the PWA to confirm or abort.
126
+ - **Task confirmation** — tasks can optionally require your approval before running. You'll get a push notification (server mode) or a prompt in the PWA to confirm or abort.
129
127
  - **Run history** — each run produces a timestamped result file. You can view results and reports from the PWA.
130
- - **Real-time updates** — task status changes (started, finished, failed) are pushed to connected PWA clients via NATS pub/sub or SSE. A shared events module handles broadcasting across both transports.
128
+ - **Real-time updates** — task status changes (started, finished, failed) are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (LAN mode).
131
129
  - **MCP server** (`palmier mcpserver`) exposes platform tools (e.g., `send-push-notification`) to AI agents like Claude Code over stdio.
132
130
 
133
131
  ## Project Structure
@@ -148,7 +146,7 @@ src/
148
146
  gemini.ts # Gemini CLI agent implementation
149
147
  codex.ts # Codex CLI agent implementation
150
148
  openclaw.ts # OpenClaw agent implementation
151
- events.ts # Shared event broadcasting (NATS pub/sub and HTTP SSE)
149
+ events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
152
150
  commands/
153
151
  init.ts # Interactive setup wizard (auto-pair)
154
152
  pair.ts # OTP code generation and pairing handler
@@ -166,7 +164,7 @@ src/
166
164
  windows.ts # Windows: Registry Run key, Task Scheduler, schtasks-based task control
167
165
  transports/
168
166
  nats-transport.ts # NATS subscription loop (host.<hostId>.rpc.>)
169
- http-transport.ts # HTTP server with RPC, SSE, and internal event endpoints
167
+ http-transport.ts # HTTP server with RPC, SSE, PWA reverse proxy, and internal event endpoints
170
168
  ```
171
169
 
172
170
  ## MCP Server
@@ -188,7 +186,7 @@ Add to your Claude Code MCP settings:
188
186
  }
189
187
  ```
190
188
 
191
- Requires a provisioned host (`palmier init`). In NATS/auto mode, uses NATS relay. Not available in LAN-only mode.
189
+ Requires a provisioned host (`palmier init`) with server mode enabled.
192
190
 
193
191
  ### Available Tools
194
192
 
@@ -273,7 +271,7 @@ Tasks can be configured to run on schedules (cron) or in response to events with
273
271
 
274
272
  Task prompts and execution data may be transmitted to third-party AI service providers (Anthropic, Google, OpenAI, etc.) according to their respective terms and privacy policies. Palmier does not control how these services process your data.
275
273
 
276
- When using NATS or auto mode, communication between your device and the host is relayed through the Palmier server. See the [Privacy Policy](https://www.palmier.me/privacy) for details on what data is collected.
274
+ When using server mode, communication between your device and the host is relayed through the Palmier server. See the [Privacy Policy](https://www.palmier.me/privacy) for details on what data is collected.
277
275
 
278
276
  ### Limitation of Liability
279
277
 
@@ -1,35 +1,13 @@
1
- import * as os from "os";
2
1
  import { loadConfig } from "../config.js";
3
2
  import { loadSessions } from "../session-store.js";
4
- /**
5
- * Detect the first non-internal IPv4 address.
6
- */
7
- function detectLanIp() {
8
- const interfaces = os.networkInterfaces();
9
- for (const name of Object.keys(interfaces)) {
10
- for (const iface of interfaces[name] ?? []) {
11
- if (iface.family === "IPv4" && !iface.internal) {
12
- return iface.address;
13
- }
14
- }
15
- }
16
- return "127.0.0.1";
17
- }
18
3
  /**
19
4
  * Print host connection info for setting up clients.
20
5
  */
21
6
  export async function infoCommand() {
22
7
  const config = loadConfig();
23
- const mode = config.mode ?? "nats";
24
8
  const sessions = loadSessions();
25
9
  console.log(`Host ID: ${config.hostId}`);
26
- console.log(`Mode: ${mode}`);
27
10
  console.log(`Project root: ${config.projectRoot}`);
28
- if (mode === "lan" || mode === "auto") {
29
- const lanIp = detectLanIp();
30
- const port = config.directPort ?? 7400;
31
- console.log(`LAN address: ${lanIp}:${port}`);
32
- }
33
11
  // Detected agents
34
12
  if (config.agents && config.agents.length > 0) {
35
13
  console.log(`Agents: ${config.agents.map((a) => a.label).join(", ")}`);
@@ -1,15 +1,12 @@
1
1
  import * as readline from "readline";
2
- import { randomUUID, randomBytes } from "crypto";
3
2
  import { loadConfig, saveConfig } from "../config.js";
4
3
  import { detectAgents } from "../agents/agent.js";
5
- import { detectLanIp } from "../transports/http-transport.js";
6
4
  import { getPlatform } from "../platform/index.js";
7
5
  import { pairCommand } from "./pair.js";
8
6
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
9
7
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
10
8
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
11
9
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
12
- const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
13
10
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
14
11
  /**
15
12
  * Interactive wizard to provision this host.
@@ -25,112 +22,50 @@ export async function initCommand() {
25
22
  console.log("Detecting installed agents...");
26
23
  const agents = await detectAgents();
27
24
  if (agents.length === 0) {
28
- console.log(`\n${red("No agent CLIs detected.")} Palmier requires at least one of the following:\n`);
29
- console.log(` - ${bold("Claude Code")} See https://code.claude.com`);
30
- console.log(` - ${bold("Gemini CLI")} npm install -g @google/gemini-cli`);
31
- console.log(` - ${bold("Codex CLI")} npm install -g @openai/codex`);
32
- console.log(` - ${bold("OpenClaw")} See https://github.com/openclaw/openclaw\n`);
25
+ console.log(`\n${red("No agent CLIs detected.")} Palmier requires at least one supported agent CLI.\n`);
26
+ console.log(`See supported agents: https://www.palmier.me/agents\n`);
33
27
  console.log(`Install at least one agent CLI, then run ${cyan("palmier init")} again.`);
34
28
  rl.close();
35
29
  process.exit(1);
36
30
  }
37
31
  console.log(` Found: ${green(agents.map((a) => a.label).join(", "))}\n`);
38
- let step = 1;
39
- // Step 1: Access mode
40
- const accessMode = await promptAccessMode(ask, step++);
41
- let serverUrl;
42
- if (accessMode === "full") {
43
- // Step 2: Server URL
44
- serverUrl = await promptServerUrl(ask, step++);
32
+ // Register with server
33
+ let existingHostId;
34
+ try {
35
+ existingHostId = loadConfig().hostId;
45
36
  }
46
- let enableLan = accessMode === "lan";
47
- if (accessMode === "full") {
48
- // Step N: Optional LAN
49
- enableLan = await promptEnableLan(ask, step++);
50
- }
51
- // Determine mode
52
- const mode = accessMode === "lan" ? "lan" : enableLan ? "auto" : "nats";
53
- // Prepare LAN details for summary
54
- let lanIp;
55
- const directPort = 7400;
56
- if (enableLan) {
57
- lanIp = detectLanIp();
58
- }
59
- // Summary before committing
60
- console.log(`\n${bold("--- Setup Summary ---")}\n`);
61
- console.log(` Mode: ${cyan(mode)}${dim(mode === "auto" ? " (NATS + LAN)" : mode === "nats" ? " (NATS only)" : " (LAN only)")}`);
62
- if (serverUrl) {
63
- console.log(` Server: ${cyan(serverUrl)}`);
64
- }
65
- if (enableLan && lanIp) {
66
- console.log(` LAN: ${cyan(`${lanIp}:${directPort}`)}`);
67
- }
68
- console.log(` Agents: ${green(agents.map((a) => a.label).join(", "))}`);
69
- console.log("");
70
- const confirm = await ask("Proceed? (Y/n): ");
71
- if (confirm.trim().toLowerCase() === "n") {
72
- console.log("\nSetup cancelled.");
73
- rl.close();
74
- return;
75
- }
76
- // Register with server (after confirmation)
37
+ catch { /* first init */ }
38
+ const serverUrl = "https://app.palmier.me";
77
39
  let registerResponse;
78
- if (accessMode === "full" && serverUrl) {
79
- let existingHostId;
40
+ while (true) {
41
+ console.log(`\nRegistering host...`);
80
42
  try {
81
- existingHostId = loadConfig().hostId;
43
+ registerResponse = await registerHost(serverUrl, existingHostId);
44
+ console.log(green("Host registered successfully."));
45
+ break;
82
46
  }
83
- catch { /* first init */ }
84
- while (true) {
85
- console.log(`\nRegistering host with ${cyan(serverUrl)}...`);
86
- try {
87
- registerResponse = await registerHost(serverUrl, existingHostId);
88
- console.log(green("Host registered successfully."));
89
- break;
90
- }
91
- catch (err) {
92
- console.error(`\n ${red(err instanceof Error ? err.message : String(err))}`);
93
- const retry = await ask("\nRetry? (Y/n): ");
94
- if (retry.trim().toLowerCase() === "n") {
95
- console.log("\nSetup cancelled.");
96
- rl.close();
97
- return;
98
- }
47
+ catch (err) {
48
+ console.error(`\n ${red(err instanceof Error ? err.message : String(err))}`);
49
+ const retry = await ask("\nRetry? (Y/n): ");
50
+ if (retry.trim().toLowerCase() === "n") {
51
+ console.log("\nSetup cancelled.");
52
+ rl.close();
53
+ return;
99
54
  }
100
55
  }
101
56
  }
102
57
  // Build config
103
58
  const config = {
104
- hostId: registerResponse?.hostId ?? randomUUID(),
59
+ hostId: registerResponse.hostId,
105
60
  projectRoot: process.cwd(),
106
- mode,
61
+ nats: true,
62
+ natsUrl: registerResponse.natsUrl,
63
+ natsWsUrl: registerResponse.natsWsUrl,
64
+ natsToken: registerResponse.natsToken,
65
+ agents,
107
66
  };
108
- if (registerResponse) {
109
- config.natsUrl = registerResponse.natsUrl;
110
- config.natsWsUrl = registerResponse.natsWsUrl;
111
- config.natsToken = registerResponse.natsToken;
112
- }
113
- if (enableLan) {
114
- const directToken = randomBytes(32).toString("hex");
115
- config.directPort = directPort;
116
- config.directToken = directToken;
117
- console.log(`\n LAN IP: ${cyan(lanIp)}`);
118
- console.log(` Port: ${cyan(String(directPort))}`);
119
- if (process.platform === "win32") {
120
- console.log(`\n ${yellow("Firewall:")} You may need to allow incoming connections on this port:`);
121
- console.log(dim(` netsh advfirewall firewall add rule name="Palmier" dir=in action=allow protocol=TCP localport=${directPort}`));
122
- }
123
- else if (process.platform === "darwin") {
124
- console.log(`\n ${yellow("Firewall:")} macOS will prompt you to allow incoming connections automatically.`);
125
- }
126
- else {
127
- console.log(`\n ${yellow("Firewall:")} You may need to allow incoming connections on this port:`);
128
- console.log(dim(` sudo ufw allow ${directPort}/tcp`));
129
- }
130
- }
131
- config.agents = agents;
132
67
  saveConfig(config);
133
- console.log(`\n${green("Host provisioned")} ${dim(`(${mode} mode)`)} ID: ${cyan(config.hostId)}`);
68
+ console.log(`\n${green("Host provisioned")} ID: ${cyan(config.hostId)}`);
134
69
  console.log(`Config saved to ${dim("~/.config/palmier/host.json")}`);
135
70
  getPlatform().installDaemon(config);
136
71
  console.log("\nStarting pairing...");
@@ -142,30 +77,6 @@ export async function initCommand() {
142
77
  throw err;
143
78
  }
144
79
  }
145
- async function promptAccessMode(ask, step) {
146
- console.log(`${bold(`Step ${step}:`)} Choose access mode\n`);
147
- console.log(` ${bold("1)")} Full access ${dim("— built-in email and push notification support")}`);
148
- console.log(` ${bold("2)")} LAN only ${dim("— no data relayed by palmier server, requires same network")}`);
149
- console.log("");
150
- const answer = await ask("Select [1]: ");
151
- const trimmed = answer.trim();
152
- if (trimmed === "2") {
153
- return "lan";
154
- }
155
- return "full";
156
- }
157
- async function promptServerUrl(ask, step) {
158
- const defaultUrl = "https://www.palmier.me";
159
- console.log(`\n${bold(`Step ${step}:`)} Enter your Palmier server URL\n`);
160
- while (true) {
161
- const answer = await ask(`Server URL [${defaultUrl}]: `);
162
- const trimmed = (answer.trim() || defaultUrl).replace(/\/+$/, "");
163
- if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
164
- return trimmed;
165
- }
166
- console.log(" URL must start with http:// or https://. Please try again.\n");
167
- }
168
- }
169
80
  async function registerHost(serverUrl, existingHostId) {
170
81
  try {
171
82
  const res = await fetch(`${serverUrl}/api/hosts/register`, {
@@ -187,11 +98,4 @@ async function registerHost(serverUrl, existingHostId) {
187
98
  throw new Error(`Failed to register host: ${message}`);
188
99
  }
189
100
  }
190
- async function promptEnableLan(ask, step) {
191
- console.log(`\n${bold(`Step ${step}:`)} Enable LAN access?\n`);
192
- console.log(` ${dim("When enabled, the app will automatically use a direct LAN connection")}`);
193
- console.log(` ${dim("when on the same network, and fall back to the server otherwise.")}\n`);
194
- const answer = await ask("Enable LAN access? (Y/n): ");
195
- return answer.trim().toLowerCase() !== "n";
196
- }
197
101
  //# sourceMappingURL=init.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Start an on-demand LAN server for direct HTTP connections.
3
+ * Generates a pairing code and displays it — no separate `palmier pair` needed.
4
+ */
5
+ export declare function lanCommand(opts: {
6
+ port: number;
7
+ }): Promise<void>;
8
+ //# sourceMappingURL=lan.d.ts.map
@@ -0,0 +1,51 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { loadConfig, CONFIG_DIR } from "../config.js";
4
+ import { createRpcHandler } from "../rpc-handler.js";
5
+ import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
6
+ const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
7
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
8
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
9
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
10
+ const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
11
+ const CODE_LENGTH = 6;
12
+ function generateCode() {
13
+ const bytes = new Uint8Array(CODE_LENGTH);
14
+ crypto.getRandomValues(bytes);
15
+ return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
16
+ }
17
+ function writeLockfile(port) {
18
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
19
+ fs.writeFileSync(LAN_LOCKFILE, JSON.stringify({ port, pid: process.pid }), "utf-8");
20
+ }
21
+ function removeLockfile() {
22
+ try {
23
+ fs.unlinkSync(LAN_LOCKFILE);
24
+ }
25
+ catch { /* ignore */ }
26
+ }
27
+ /**
28
+ * Start an on-demand LAN server for direct HTTP connections.
29
+ * Generates a pairing code and displays it — no separate `palmier pair` needed.
30
+ */
31
+ export async function lanCommand(opts) {
32
+ const config = loadConfig();
33
+ const port = opts.port;
34
+ const ip = detectLanIp();
35
+ const code = generateCode();
36
+ const handleRpc = createRpcHandler(config);
37
+ // Write lockfile so other palmier processes can discover us
38
+ writeLockfile(port);
39
+ // Clean up on exit
40
+ process.on("SIGINT", () => { removeLockfile(); process.exit(0); });
41
+ process.on("SIGTERM", () => { removeLockfile(); process.exit(0); });
42
+ process.on("exit", removeLockfile);
43
+ // Start the HTTP transport with the pre-generated pairing code
44
+ await startHttpTransport(config, handleRpc, port, code, () => {
45
+ console.log(`\n${bold("Palmier LAN Server")}\n`);
46
+ console.log(` ${cyan("Open the app at:")} ${bold(`http://${ip}:${port}`)}\n`);
47
+ console.log(` ${cyan("Pairing code:")} ${bold(code)}\n`);
48
+ console.log(` ${dim("Press Ctrl+C to stop.")}\n`);
49
+ });
50
+ }
51
+ //# sourceMappingURL=lan.js.map
@@ -6,15 +6,10 @@ import { loadConfig } from "../config.js";
6
6
  import { connectNats } from "../nats-client.js";
7
7
  export async function mcpserverCommand() {
8
8
  const config = loadConfig();
9
- const mode = config.mode ?? "nats";
10
- const useNats = mode === "nats" || mode === "auto";
11
- let nc;
12
- if (useNats) {
13
- nc = await connectNats(config);
14
- }
9
+ const nc = await connectNats(config);
15
10
  const sc = StringCodec();
16
11
  const server = new McpServer({ name: "palmier", version: "1.0.0" }, { capabilities: { tools: {} } });
17
- // send-push-notification requires NATS — only register in nats/auto mode
12
+ // send-push-notification requires NATS — only register when server mode is enabled
18
13
  if (nc) {
19
14
  server.registerTool("send-push-notification", {
20
15
  description: "Send a push notification to all paired devices via the Palmier platform",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Generate an OTP code and wait for a PWA client to pair.
3
- * Listens on NATS (nats/auto modes) and/or HTTP (lan/auto modes).
3
+ * Listens on NATS always, and also on the LAN server if `palmier lan` is running.
4
4
  */
5
5
  export declare function pairCommand(): Promise<void>;
6
6
  //# sourceMappingURL=pair.d.ts.map
@@ -1,12 +1,14 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
1
3
  import * as http from "node:http";
2
4
  import { StringCodec } from "nats";
3
- import { loadConfig } from "../config.js";
5
+ import { loadConfig, CONFIG_DIR } from "../config.js";
4
6
  import { connectNats } from "../nats-client.js";
5
- import { detectLanIp } from "../transports/http-transport.js";
6
7
  import { addSession } from "../session-store.js";
7
8
  const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
8
9
  const CODE_LENGTH = 6;
9
10
  const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
11
+ const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
10
12
  function generateCode() {
11
13
  const bytes = new Uint8Array(CODE_LENGTH);
12
14
  crypto.getRandomValues(bytes);
@@ -14,15 +16,13 @@ function generateCode() {
14
16
  }
15
17
  function buildPairResponse(config, label) {
16
18
  const session = addSession(label);
17
- const response = {
19
+ return {
18
20
  hostId: config.hostId,
19
21
  sessionToken: session.token,
20
22
  };
21
- return response;
22
23
  }
23
24
  /**
24
- * POST to the running serve process and long-poll until paired or expired.
25
- * Returns true if paired, false if expired/failed.
25
+ * POST to the running LAN server and long-poll until paired or expired.
26
26
  */
27
27
  function lanPairRegister(port, code) {
28
28
  const body = JSON.stringify({ code, expiryMs: EXPIRY_MS });
@@ -33,7 +33,7 @@ function lanPairRegister(port, code) {
33
33
  path: "/internal/pair-register",
34
34
  method: "POST",
35
35
  headers: { "Content-Type": "application/json" },
36
- timeout: EXPIRY_MS + 5000, // slightly longer than expiry
36
+ timeout: EXPIRY_MS + 5000,
37
37
  }, (res) => {
38
38
  const chunks = [];
39
39
  res.on("data", (chunk) => chunks.push(chunk));
@@ -47,26 +47,30 @@ function lanPairRegister(port, code) {
47
47
  }
48
48
  });
49
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
- });
50
+ req.on("error", () => resolve(false));
51
+ req.on("timeout", () => { req.destroy(); resolve(false); });
59
52
  req.end(body);
60
53
  });
61
54
  }
55
+ /**
56
+ * Read the LAN lockfile to check if `palmier lan` is running.
57
+ */
58
+ function getLanPort() {
59
+ try {
60
+ const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
61
+ return JSON.parse(raw).port;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
62
67
  /**
63
68
  * Generate an OTP code and wait for a PWA client to pair.
64
- * Listens on NATS (nats/auto modes) and/or HTTP (lan/auto modes).
69
+ * Listens on NATS always, and also on the LAN server if `palmier lan` is running.
65
70
  */
66
71
  export async function pairCommand() {
67
72
  const config = loadConfig();
68
73
  const code = generateCode();
69
- const mode = config.mode ?? "nats";
70
74
  let paired = false;
71
75
  function onPaired() {
72
76
  paired = true;
@@ -79,48 +83,40 @@ export async function pairCommand() {
79
83
  console.log("");
80
84
  console.log(` ${code}`);
81
85
  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
86
  console.log("Code expires in 5 minutes.");
89
- // NATS pairing (nats or auto mode)
90
- if (mode === "nats" || mode === "auto") {
91
- const nc = await connectNats(config);
92
- const sc = StringCodec();
93
- const subject = `pair.${code}`;
94
- const sub = nc.subscribe(subject, { max: 1 });
95
- cleanups.push(() => {
96
- sub.unsubscribe();
97
- nc.close();
98
- });
99
- (async () => {
100
- for await (const msg of sub) {
101
- if (paired)
102
- break;
103
- let label;
104
- try {
105
- if (msg.data && msg.data.length > 0) {
106
- const body = JSON.parse(sc.decode(msg.data));
107
- label = body.label;
108
- }
109
- }
110
- catch { /* empty body is fine */ }
111
- const response = buildPairResponse(config, label);
112
- if (msg.reply) {
113
- msg.respond(sc.encode(JSON.stringify(response)));
87
+ // NATS pairing (always active)
88
+ const nc = await connectNats(config);
89
+ const sc = StringCodec();
90
+ const subject = `pair.${code}`;
91
+ const sub = nc.subscribe(subject, { max: 1 });
92
+ cleanups.push(() => {
93
+ sub.unsubscribe();
94
+ nc.close();
95
+ });
96
+ (async () => {
97
+ for await (const msg of sub) {
98
+ if (paired)
99
+ break;
100
+ let label;
101
+ try {
102
+ if (msg.data && msg.data.length > 0) {
103
+ const body = JSON.parse(sc.decode(msg.data));
104
+ label = body.label;
114
105
  }
115
- onPaired();
116
106
  }
117
- })();
118
- }
119
- // LAN pairing — long-poll the running serve process
120
- if (mode === "lan" || mode === "auto") {
121
- const port = config.directPort ?? 7400;
107
+ catch { /* empty body is fine */ }
108
+ const response = buildPairResponse(config, label);
109
+ if (msg.reply) {
110
+ msg.respond(sc.encode(JSON.stringify(response)));
111
+ }
112
+ onPaired();
113
+ }
114
+ })();
115
+ // LAN pairing — if `palmier lan` is running, also register with it
116
+ const lanPort = getLanPort();
117
+ if (lanPort) {
122
118
  (async () => {
123
- const result = await lanPairRegister(port, code);
119
+ const result = await lanPairRegister(lanPort, code);
124
120
  if (result)
125
121
  onPaired();
126
122
  })();