palmier 0.2.0 → 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.
Files changed (79) hide show
  1. package/CLAUDE.md +5 -1
  2. package/README.md +135 -45
  3. package/dist/agents/agent.d.ts +26 -0
  4. package/dist/agents/agent.js +32 -0
  5. package/dist/agents/claude.d.ts +8 -0
  6. package/dist/agents/claude.js +35 -0
  7. package/dist/agents/codex.d.ts +8 -0
  8. package/dist/agents/codex.js +41 -0
  9. package/dist/agents/gemini.d.ts +8 -0
  10. package/dist/agents/gemini.js +39 -0
  11. package/dist/agents/openclaw.d.ts +8 -0
  12. package/dist/agents/openclaw.js +25 -0
  13. package/dist/agents/shared-prompt.d.ts +11 -0
  14. package/dist/agents/shared-prompt.js +26 -0
  15. package/dist/commands/agents.d.ts +2 -0
  16. package/dist/commands/agents.js +19 -0
  17. package/dist/commands/info.d.ts +5 -0
  18. package/dist/commands/info.js +40 -0
  19. package/dist/commands/init.d.ts +7 -2
  20. package/dist/commands/init.js +139 -49
  21. package/dist/commands/mcpserver.d.ts +2 -0
  22. package/dist/commands/mcpserver.js +75 -0
  23. package/dist/commands/pair.d.ts +6 -0
  24. package/dist/commands/pair.js +140 -0
  25. package/dist/commands/plan-generation.md +32 -0
  26. package/dist/commands/run.d.ts +0 -1
  27. package/dist/commands/run.js +258 -114
  28. package/dist/commands/serve.d.ts +1 -1
  29. package/dist/commands/serve.js +16 -228
  30. package/dist/commands/sessions.d.ts +4 -0
  31. package/dist/commands/sessions.js +30 -0
  32. package/dist/commands/task-generation.md +1 -1
  33. package/dist/config.d.ts +5 -5
  34. package/dist/config.js +24 -6
  35. package/dist/index.js +58 -5
  36. package/dist/nats-client.d.ts +3 -3
  37. package/dist/nats-client.js +2 -2
  38. package/dist/rpc-handler.d.ts +6 -0
  39. package/dist/rpc-handler.js +367 -0
  40. package/dist/session-store.d.ts +12 -0
  41. package/dist/session-store.js +57 -0
  42. package/dist/spawn-command.d.ts +26 -0
  43. package/dist/spawn-command.js +48 -0
  44. package/dist/systemd.d.ts +2 -2
  45. package/dist/task.d.ts +45 -2
  46. package/dist/task.js +155 -14
  47. package/dist/transports/http-transport.d.ts +6 -0
  48. package/dist/transports/http-transport.js +243 -0
  49. package/dist/transports/nats-transport.d.ts +6 -0
  50. package/dist/transports/nats-transport.js +69 -0
  51. package/dist/types.d.ts +30 -13
  52. package/package.json +4 -3
  53. package/src/agents/agent.ts +62 -0
  54. package/src/agents/claude.ts +39 -0
  55. package/src/agents/codex.ts +46 -0
  56. package/src/agents/gemini.ts +43 -0
  57. package/src/agents/openclaw.ts +29 -0
  58. package/src/agents/shared-prompt.ts +26 -0
  59. package/src/commands/agents.ts +20 -0
  60. package/src/commands/info.ts +44 -0
  61. package/src/commands/init.ts +229 -121
  62. package/src/commands/mcpserver.ts +92 -0
  63. package/src/commands/pair.ts +163 -0
  64. package/src/commands/plan-generation.md +32 -0
  65. package/src/commands/run.ts +323 -129
  66. package/src/commands/serve.ts +26 -287
  67. package/src/commands/sessions.ts +32 -0
  68. package/src/config.ts +30 -10
  69. package/src/index.ts +67 -6
  70. package/src/nats-client.ts +4 -4
  71. package/src/rpc-handler.ts +421 -0
  72. package/src/session-store.ts +68 -0
  73. package/src/spawn-command.ts +78 -0
  74. package/src/systemd.ts +2 -2
  75. package/src/task.ts +166 -16
  76. package/src/transports/http-transport.ts +290 -0
  77. package/src/transports/nats-transport.ts +82 -0
  78. package/src/types.ts +36 -13
  79. package/src/commands/task-generation.md +0 -28
@@ -0,0 +1,39 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import { execSync } from "child_process";
3
+ import type { AgentTool, CommandLine } from "./agent.js";
4
+ import { TASK_OUTCOME_SUFFIX } from "./shared-prompt.js";
5
+
6
+ export class ClaudeAgent implements AgentTool {
7
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
8
+ return {
9
+ command: "claude",
10
+ args: ["-p", prompt],
11
+ };
12
+ }
13
+
14
+ getTaskRunCommandLine(task: ParsedTask, prompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
15
+ prompt = (prompt ?? (task.body || task.frontmatter.user_prompt)) + TASK_OUTCOME_SUFFIX;
16
+ const args = ["-c", "--permission-mode", "acceptEdits", "-p", prompt];
17
+
18
+ const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
19
+ for (const p of allPerms) {
20
+ args.push("--allowedTools", p.name);
21
+ }
22
+
23
+ return { command: "claude", args };
24
+ }
25
+
26
+ async init(): Promise<boolean> {
27
+ try {
28
+ execSync("claude --version");
29
+ } catch {
30
+ return false;
31
+ }
32
+ try {
33
+ execSync("claude mcp add --transport stdio palmier --scope user -- palmier mcpserver");
34
+ } catch (err) {
35
+ console.warn("Warning: failed to install MCP for Claude:", err instanceof Error ? err.message : err);
36
+ }
37
+ return true;
38
+ }
39
+ }
@@ -0,0 +1,46 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import { execSync } from "child_process";
3
+ import type { AgentTool, CommandLine } from "./agent.js";
4
+ import { TASK_OUTCOME_SUFFIX } from "./shared-prompt.js";
5
+
6
+ export class CodexAgent implements AgentTool {
7
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
8
+ // TODO: fill in
9
+ return {
10
+ command: "codex",
11
+ args: ["exec", "--skip-git-repo-check",prompt],
12
+ };
13
+ }
14
+
15
+ getTaskRunCommandLine(task: ParsedTask, prompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ prompt = (prompt ?? (task.body || task.frontmatter.user_prompt)) + TASK_OUTCOME_SUFFIX;
17
+ // TODO: Update sandbox to workspace-write once https://github.com/openai/codex/issues/12572
18
+ // is fixed.
19
+ const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
20
+
21
+ const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
22
+ for (const p of allPerms) {
23
+ args.push("--config");
24
+ args.push(`apps.${p.name}.default_tools_approval_mode="approve"`);
25
+ }
26
+
27
+ args.push(prompt);
28
+ args.push("resume", "--last");
29
+
30
+ return { command: "codex", args };
31
+ }
32
+
33
+ async init(): Promise<boolean> {
34
+ try {
35
+ execSync("codex --version");
36
+ } catch {
37
+ return false;
38
+ }
39
+ try {
40
+ execSync("codex mcp add palmier palmier mcpserver");
41
+ } catch (err) {
42
+ console.warn("Warning: failed to install MCP for Codex:", err instanceof Error ? err.message : err);
43
+ }
44
+ return true;
45
+ }
46
+ }
@@ -0,0 +1,43 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import { execSync } from "child_process";
3
+ import type { AgentTool, CommandLine } from "./agent.js";
4
+ import { TASK_OUTCOME_SUFFIX } from "./shared-prompt.js";
5
+
6
+ export class GeminiAgent implements AgentTool {
7
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
8
+ // TODO: fill in
9
+ return {
10
+ command: "gemini",
11
+ args: ["--approval-mode", "auto_edit","--prompt", prompt],
12
+ };
13
+ }
14
+
15
+ getTaskRunCommandLine(task: ParsedTask, prompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ prompt = prompt ?? (task.body || task.frontmatter.user_prompt);
17
+ const args = ["--resume", "--prompt", prompt + TASK_OUTCOME_SUFFIX];
18
+
19
+ const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
20
+ if (allPerms.length > 0) {
21
+ args.push("--allowed-tools");
22
+ for (const p of allPerms) {
23
+ args.push(p.name);
24
+ }
25
+ }
26
+
27
+ return { command: "gemini", args };
28
+ }
29
+
30
+ async init(): Promise<boolean> {
31
+ try {
32
+ execSync("gemini --version");
33
+ } catch {
34
+ return false;
35
+ }
36
+ try {
37
+ execSync("gemini mcp add --scope user palmier palmier mcpserver");
38
+ } catch (err) {
39
+ console.warn("Warning: failed to install MCP for Gemini:", err instanceof Error ? err.message : err);
40
+ }
41
+ return true;
42
+ }
43
+ }
@@ -0,0 +1,29 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import { execSync } from "child_process";
3
+ import type { AgentTool, CommandLine } from "./agent.js";
4
+ import { TASK_OUTCOME_SUFFIX } from "./shared-prompt.js";
5
+
6
+ export class ClaudeAgent implements AgentTool {
7
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
8
+ return {
9
+ command: "openclaw",
10
+ args: ["agent", "--message", prompt],
11
+ };
12
+ }
13
+
14
+ getTaskRunCommandLine(task: ParsedTask, prompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
15
+ prompt = (prompt ?? (task.body || task.frontmatter.user_prompt)) + TASK_OUTCOME_SUFFIX;
16
+ const args = ["agent", "--message", prompt];
17
+
18
+ return { command: "openclaw", args };
19
+ }
20
+
21
+ async init(): Promise<boolean> {
22
+ try {
23
+ execSync("openclaw --version");
24
+ } catch {
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Appended to the end of every task prompt.
3
+ * Instructs the agent to output a clear success/failure marker
4
+ * so that palmier can determine the task outcome.
5
+ */
6
+ export const TASK_OUTCOME_SUFFIX = `
7
+
8
+ ---
9
+ If you generate report or output files, print each file name on its own line prefixed with [PALMIER_REPORT]: e.g.
10
+ [PALMIER_REPORT] report.md
11
+ [PALMIER_REPORT] summary.md
12
+
13
+ When you are done, output exactly one of these markers as the very last line:
14
+ - Success: [PALMIER_TASK_SUCCESS]
15
+ - Failure: [PALMIER_TASK_FAILURE]
16
+ Do not wrap them in code blocks or add text on the same line.
17
+
18
+ If the task fails because a tool was denied or you lack the required permissions, print each required permission on its own line prefixed with [PALMIER_PERMISSION]: e.g.
19
+ [PALMIER_PERMISSION] Read | Read file contents from the repository
20
+ [PALMIER_PERMISSION] Bash(npm test) | Run the test suite via npm
21
+ [PALMIER_PERMISSION] Write | Write generated output files`;
22
+
23
+ export const TASK_SUCCESS_MARKER = "[PALMIER_TASK_SUCCESS]";
24
+ export const TASK_FAILURE_MARKER = "[PALMIER_TASK_FAILURE]";
25
+ export const TASK_REPORT_PREFIX = "[PALMIER_REPORT]";
26
+ export const TASK_PERMISSION_PREFIX = "[PALMIER_PERMISSION]";
@@ -0,0 +1,20 @@
1
+ import { loadConfig, saveConfig } from "../config.js";
2
+ import { detectAgents } from "../agents/agent.js";
3
+
4
+ export async function agentsCommand(): Promise<void> {
5
+ const config = loadConfig();
6
+
7
+ console.log("Detecting installed agents...");
8
+ const agents = await detectAgents();
9
+ config.agents = agents;
10
+ saveConfig(config);
11
+
12
+ if (agents.length === 0) {
13
+ console.log("No agent CLIs detected.");
14
+ } else {
15
+ console.log("Detected agents:");
16
+ for (const a of agents) {
17
+ console.log(` ${a.key} — ${a.label}`);
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,44 @@
1
+ import * as os from "os";
2
+ import { loadConfig } from "../config.js";
3
+
4
+ /**
5
+ * Detect the first non-internal IPv4 address.
6
+ */
7
+ function detectLanIp(): string {
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
+
19
+ /**
20
+ * Print host connection info for setting up clients.
21
+ */
22
+ export async function infoCommand(): Promise<void> {
23
+ const config = loadConfig();
24
+ const mode = config.mode ?? "nats";
25
+
26
+ console.log(`Mode: ${mode}`);
27
+ console.log(`Host ID: ${config.hostId}`);
28
+
29
+ if (mode === "lan" || mode === "auto") {
30
+ const lanIp = detectLanIp();
31
+ const port = config.directPort ?? 7400;
32
+ console.log("");
33
+ console.log("Direct connection info:");
34
+ console.log(` Address: ${lanIp}:${port}`);
35
+ console.log(` Token: ${config.directToken}`);
36
+ }
37
+
38
+ if (mode === "nats" || mode === "auto") {
39
+ console.log("");
40
+ console.log("NATS connection info:");
41
+ console.log(` URL: ${config.natsUrl}`);
42
+ console.log(` WS: ${config.natsWsUrl}`);
43
+ }
44
+ }
@@ -1,121 +1,229 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import { execSync } from "child_process";
4
- import { homedir } from "os";
5
- import { saveConfig } from "../config.js";
6
- import type { AgentConfig } from "../types.js";
7
-
8
- export interface InitOptions {
9
- token: string;
10
- }
11
-
12
- /**
13
- * Provision this agent by exchanging a base64 provisioning token for permanent credentials.
14
- */
15
- export async function initCommand(options: InitOptions): Promise<void> {
16
- // 1. Decode base64 provisioning token
17
- let decoded: { server: string; token: string };
18
- try {
19
- const jsonStr = Buffer.from(options.token, "base64").toString("utf-8");
20
- decoded = JSON.parse(jsonStr) as { server: string; token: string };
21
- } catch {
22
- console.error("Failed to decode provisioning token. Ensure it is a valid base64-encoded JSON string.");
23
- process.exit(1);
24
- }
25
-
26
- if (!decoded.server || !decoded.token) {
27
- console.error("Invalid provisioning token: missing 'server' or 'token' field.");
28
- process.exit(1);
29
- }
30
-
31
- console.log(`Claiming agent at ${decoded.server}...`);
32
-
33
- // 2. POST to server to claim agent
34
- let claimResponse: {
35
- agentId: string;
36
- userId: string;
37
- natsUrl: string;
38
- natsWsUrl: string;
39
- natsToken: string;
40
- };
41
-
42
- try {
43
- const res = await fetch(`${decoded.server}/api/agents/claim`, {
44
- method: "POST",
45
- headers: { "Content-Type": "application/json" },
46
- body: JSON.stringify({ provisioning_token: options.token }),
47
- });
48
-
49
- if (!res.ok) {
50
- const body = await res.text();
51
- console.error(`Failed to claim agent: ${res.status} ${res.statusText}\n${body}`);
52
- process.exit(1);
53
- }
54
-
55
- claimResponse = (await res.json()) as typeof claimResponse;
56
- } catch (err) {
57
- console.error(`Failed to reach server: ${err}`);
58
- process.exit(1);
59
- }
60
-
61
- // 3. Save config
62
- const config: AgentConfig = {
63
- agentId: claimResponse.agentId,
64
- userId: claimResponse.userId,
65
- natsUrl: claimResponse.natsUrl,
66
- natsWsUrl: claimResponse.natsWsUrl,
67
- natsToken: claimResponse.natsToken,
68
- projectRoot: process.cwd(),
69
- };
70
-
71
- saveConfig(config);
72
- console.log(`Agent provisioned. ID: ${config.agentId}`);
73
- console.log("Config saved to ~/.config/palmier/agent.json");
74
-
75
- // 4. Install systemd user service for palmier serve
76
- const unitDir = path.join(homedir(), ".config", "systemd", "user");
77
- fs.mkdirSync(unitDir, { recursive: true });
78
-
79
- const palmierBin = process.argv[1] || "palmier";
80
-
81
- const serviceContent = `[Unit]
82
- Description=Palmier Agent
83
- After=network-online.target
84
- Wants=network-online.target
85
-
86
- [Service]
87
- Type=simple
88
- ExecStart=${palmierBin} serve
89
- WorkingDirectory=${config.projectRoot}
90
- Restart=on-failure
91
- RestartSec=5
92
- Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
93
-
94
- [Install]
95
- WantedBy=default.target
96
- `;
97
-
98
- const servicePath = path.join(unitDir, "palmier-agent.service");
99
- fs.writeFileSync(servicePath, serviceContent, "utf-8");
100
- console.log("Systemd service installed at:", servicePath);
101
-
102
- // 6. Enable and start the service
103
- try {
104
- execSync("systemctl --user daemon-reload", { stdio: "inherit" });
105
- execSync("systemctl --user enable --now palmier-agent.service", { stdio: "inherit" });
106
- console.log("Palmier agent service enabled and started.");
107
- } catch (err) {
108
- console.error(`Warning: failed to enable systemd service: ${err}`);
109
- console.error("You may need to start it manually: systemctl --user enable --now palmier-agent.service");
110
- }
111
-
112
- // 7. Enable lingering so service runs without active login session
113
- try {
114
- execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
115
- console.log("Login lingering enabled.");
116
- } catch (err) {
117
- console.error(`Warning: failed to enable linger: ${err}`);
118
- }
119
-
120
- console.log("\nAgent initialization complete!");
121
- }
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import * as readline from "readline";
5
+ import { randomUUID, randomBytes } from "crypto";
6
+ import { execSync } from "child_process";
7
+ import { homedir } from "os";
8
+ import { saveConfig } from "../config.js";
9
+ import { detectAgents } from "../agents/agent.js";
10
+ import { pairCommand } from "./pair.js";
11
+ import type { HostConfig } from "../types.js";
12
+
13
+ export interface InitOptions {
14
+ server?: string;
15
+ lan?: boolean;
16
+ host?: string;
17
+ port?: number;
18
+ }
19
+
20
+ /**
21
+ * Provision this host. Two flows:
22
+ * - palmier init --lan → standalone LAN mode, no server needed
23
+ * - palmier init --server <url> → register with server, prompts for nats/auto mode
24
+ */
25
+ export async function initCommand(options: InitOptions): Promise<void> {
26
+ if (options.lan) {
27
+ await initLan(options);
28
+ } else if (options.server) {
29
+ await initWithServer(options);
30
+ } else {
31
+ console.error("Either --lan or --server <url> is required.");
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Flow A: Standalone LAN mode. No web server needed.
38
+ */
39
+ async function initLan(options: InitOptions): Promise<void> {
40
+ const hostId = randomUUID();
41
+ const directToken = randomBytes(32).toString("hex");
42
+ const directPort = options.port ?? 7400;
43
+ const lanIp = options.host ?? detectLanIp();
44
+
45
+ const config: HostConfig = {
46
+ hostId,
47
+ mode: "lan",
48
+ directPort,
49
+ directToken,
50
+ projectRoot: process.cwd(),
51
+ };
52
+
53
+ console.log("Detecting installed agents...");
54
+ config.agents = await detectAgents();
55
+ saveConfig(config);
56
+
57
+ console.log(`Host provisioned (LAN mode). ID: ${hostId}`);
58
+ console.log("Config saved to ~/.config/palmier/host.json");
59
+
60
+ installSystemdService(config);
61
+
62
+ // Auto-enter pair mode so user can connect their PWA immediately
63
+ console.log("");
64
+ console.log("Starting pairing...");
65
+ await pairCommand();
66
+ }
67
+
68
+ /**
69
+ * Flow B: Register directly with server. No provisioning token needed.
70
+ */
71
+ async function initWithServer(options: InitOptions): Promise<void> {
72
+ const serverUrl = options.server!;
73
+
74
+ console.log(`Registering host at ${serverUrl}...`);
75
+
76
+ let registerResponse: {
77
+ hostId: string;
78
+ natsUrl: string;
79
+ natsWsUrl: string;
80
+ natsToken: string;
81
+ };
82
+
83
+ try {
84
+ const res = await fetch(`${serverUrl}/api/hosts/register`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({}),
88
+ });
89
+
90
+ if (!res.ok) {
91
+ const body = await res.text();
92
+ console.error(`Failed to register host: ${res.status} ${res.statusText}\n${body}`);
93
+ process.exit(1);
94
+ }
95
+
96
+ registerResponse = (await res.json()) as typeof registerResponse;
97
+ } catch (err) {
98
+ console.error(`Failed to reach server: ${err}`);
99
+ process.exit(1);
100
+ }
101
+
102
+ // Prompt for connection mode
103
+ const mode = await promptMode();
104
+
105
+ // Build config
106
+ const config: HostConfig = {
107
+ hostId: registerResponse.hostId,
108
+ projectRoot: process.cwd(),
109
+ mode,
110
+ natsUrl: registerResponse.natsUrl,
111
+ natsWsUrl: registerResponse.natsWsUrl,
112
+ natsToken: registerResponse.natsToken,
113
+ };
114
+
115
+ if (mode === "auto") {
116
+ const directToken = randomBytes(32).toString("hex");
117
+ const directPort = options.port ?? 7400;
118
+ const lanIp = options.host ?? detectLanIp();
119
+
120
+ config.directPort = directPort;
121
+ config.directToken = directToken;
122
+
123
+ console.log("");
124
+ console.log("Direct connection info (for LAN clients):");
125
+ console.log(` Address: ${lanIp}:${directPort}`);
126
+ console.log(` Token: ${directToken}`);
127
+ }
128
+
129
+ console.log("Detecting installed agents...");
130
+ config.agents = await detectAgents();
131
+ saveConfig(config);
132
+ console.log(`Host provisioned (${mode} mode). ID: ${config.hostId}`);
133
+ console.log("Config saved to ~/.config/palmier/host.json");
134
+
135
+ installSystemdService(config);
136
+
137
+ // Auto-enter pair mode so user can connect their PWA immediately
138
+ console.log("");
139
+ console.log("Starting pairing...");
140
+ await pairCommand();
141
+ }
142
+
143
+ /**
144
+ * Prompt user to select connection mode.
145
+ */
146
+ async function promptMode(): Promise<"nats" | "auto"> {
147
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
148
+ const question = (q: string) => new Promise<string>((resolve) => rl.question(q, resolve));
149
+
150
+ console.log("");
151
+ console.log("Connection mode:");
152
+ console.log(" 1) auto — Both LAN and NATS (recommended)");
153
+ console.log(" 2) nats — NATS only");
154
+ console.log("");
155
+
156
+ const answer = await question("Select [1]: ");
157
+ rl.close();
158
+
159
+ if (answer.trim() === "2" || answer.trim().toLowerCase() === "nats") {
160
+ return "nats";
161
+ }
162
+ return "auto";
163
+ }
164
+
165
+ /**
166
+ * Detect the first non-internal IPv4 address.
167
+ */
168
+ function detectLanIp(): string {
169
+ const interfaces = os.networkInterfaces();
170
+ for (const name of Object.keys(interfaces)) {
171
+ for (const iface of interfaces[name] ?? []) {
172
+ if (iface.family === "IPv4" && !iface.internal) {
173
+ return iface.address;
174
+ }
175
+ }
176
+ }
177
+ return "127.0.0.1";
178
+ }
179
+
180
+ /**
181
+ * Install systemd user service for palmier serve.
182
+ */
183
+ function installSystemdService(config: HostConfig): void {
184
+ const unitDir = path.join(homedir(), ".config", "systemd", "user");
185
+ fs.mkdirSync(unitDir, { recursive: true });
186
+
187
+ const palmierBin = process.argv[1] || "palmier";
188
+
189
+ const serviceContent = `[Unit]
190
+ Description=Palmier Host
191
+ After=network-online.target
192
+ Wants=network-online.target
193
+
194
+ [Service]
195
+ Type=simple
196
+ ExecStart=${palmierBin} serve
197
+ WorkingDirectory=${config.projectRoot}
198
+ Restart=on-failure
199
+ RestartSec=5
200
+ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
201
+
202
+ [Install]
203
+ WantedBy=default.target
204
+ `;
205
+
206
+ const servicePath = path.join(unitDir, "palmier.service");
207
+ fs.writeFileSync(servicePath, serviceContent, "utf-8");
208
+ console.log("Systemd service installed at:", servicePath);
209
+
210
+ // Enable and start the service
211
+ try {
212
+ execSync("systemctl --user daemon-reload", { stdio: "inherit" });
213
+ execSync("systemctl --user enable --now palmier.service", { stdio: "inherit" });
214
+ console.log("Palmier host service enabled and started.");
215
+ } catch (err) {
216
+ console.error(`Warning: failed to enable systemd service: ${err}`);
217
+ console.error("You may need to start it manually: systemctl --user enable --now palmier.service");
218
+ }
219
+
220
+ // Enable lingering so service runs without active login session
221
+ try {
222
+ execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
223
+ console.log("Login lingering enabled.");
224
+ } catch (err) {
225
+ console.error(`Warning: failed to enable linger: ${err}`);
226
+ }
227
+
228
+ console.log("\nHost initialization complete!");
229
+ }