palmier 0.8.1 → 0.8.4
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/CLAUDE.md +13 -0
- package/README.md +16 -14
- package/dist/agents/agent.d.ts +0 -4
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +2 -2
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/gemini.js +3 -2
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/shared-prompt.d.ts +0 -3
- package/dist/agents/shared-prompt.js +0 -3
- package/dist/commands/info.d.ts +0 -3
- package/dist/commands/info.js +0 -5
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.js +2 -11
- package/dist/commands/pair.d.ts +1 -4
- package/dist/commands/pair.js +3 -12
- package/dist/commands/restart.d.ts +0 -3
- package/dist/commands/restart.js +0 -3
- package/dist/commands/run.d.ts +1 -14
- package/dist/commands/run.js +18 -61
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +29 -27
- package/dist/config.d.ts +0 -8
- package/dist/config.js +0 -8
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +6 -21
- package/dist/event-queues.js +6 -21
- package/dist/events.d.ts +0 -6
- package/dist/events.js +1 -9
- package/dist/index.js +0 -1
- package/dist/mcp-handler.js +1 -2
- package/dist/mcp-tools.d.ts +0 -3
- package/dist/mcp-tools.js +12 -16
- package/dist/nats-client.d.ts +0 -3
- package/dist/nats-client.js +1 -4
- package/dist/pending-requests.d.ts +4 -18
- package/dist/pending-requests.js +4 -18
- package/dist/platform/index.d.ts +1 -4
- package/dist/platform/index.js +8 -7
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/macos.d.ts +32 -0
- package/dist/platform/macos.js +287 -0
- package/dist/platform/platform.d.ts +1 -4
- package/dist/platform/windows.d.ts +2 -5
- package/dist/platform/windows.js +19 -39
- package/dist/pwa/assets/index-499vYQvR.js +120 -0
- package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
- package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
- package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.d.ts +0 -6
- package/dist/rpc-handler.js +14 -47
- package/dist/spawn-command.d.ts +10 -25
- package/dist/spawn-command.js +7 -15
- package/dist/task.d.ts +6 -64
- package/dist/task.js +7 -70
- package/dist/transports/http-transport.d.ts +0 -4
- package/dist/transports/http-transport.js +7 -28
- package/dist/transports/nats-transport.d.ts +0 -4
- package/dist/transports/nats-transport.js +3 -9
- package/dist/types.d.ts +3 -7
- package/dist/update-checker.d.ts +1 -4
- package/dist/update-checker.js +2 -5
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +325 -22
- package/palmier-server/pwa/src/App.tsx +2 -0
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
- package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
- package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
- package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +18 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +38 -7
- package/src/agents/agent.ts +0 -4
- package/src/agents/claude.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/gemini.ts +3 -2
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/shared-prompt.ts +0 -3
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +3 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +28 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +3 -2
- package/src/event-queues.ts +6 -21
- package/src/events.ts +1 -9
- package/src/index.ts +0 -1
- package/src/mcp-handler.ts +1 -2
- package/src/mcp-tools.ts +12 -18
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +5 -7
- package/src/platform/linux.ts +9 -20
- package/src/platform/macos.ts +310 -0
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +14 -47
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +7 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/macos-plist.test.ts +112 -0
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- package/dist/pwa/assets/index-DQfOEB03.js +0 -120
package/src/pending-requests.ts
CHANGED
|
@@ -22,10 +22,9 @@ export interface PendingRequest {
|
|
|
22
22
|
const pending = new Map<string, PendingRequest>();
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* Only one pending request per key at a time.
|
|
25
|
+
* Key is sessionId for confirmation/input, taskId for permission. Only one
|
|
26
|
+
* pending request per key at a time. `meta` is surfaced via host.info so a
|
|
27
|
+
* freshly-connected PWA can render the modal without replaying events.
|
|
29
28
|
*/
|
|
30
29
|
export function registerPending(
|
|
31
30
|
key: string,
|
|
@@ -42,10 +41,6 @@ export function registerPending(
|
|
|
42
41
|
});
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
/**
|
|
46
|
-
* Resolve a pending request with the user's response.
|
|
47
|
-
* Returns true if a pending request was found and resolved.
|
|
48
|
-
*/
|
|
49
44
|
export function resolvePending(key: string, value: string[]): boolean {
|
|
50
45
|
const entry = pending.get(key);
|
|
51
46
|
if (!entry) return false;
|
|
@@ -54,24 +49,15 @@ export function resolvePending(key: string, value: string[]): boolean {
|
|
|
54
49
|
return true;
|
|
55
50
|
}
|
|
56
51
|
|
|
57
|
-
/**
|
|
58
|
-
* Get the current pending request for a key (if any).
|
|
59
|
-
*/
|
|
60
52
|
export function getPending(key: string): PendingRequest | undefined {
|
|
61
53
|
return pending.get(key);
|
|
62
54
|
}
|
|
63
55
|
|
|
64
|
-
/**
|
|
65
|
-
* Remove a pending request without resolving it.
|
|
66
|
-
*/
|
|
67
56
|
export function removePending(key: string): void {
|
|
68
57
|
pending.delete(key);
|
|
69
58
|
}
|
|
70
59
|
|
|
71
|
-
/**
|
|
72
|
-
* List all currently-pending requests, stripped of the unserializable `resolve`
|
|
73
|
-
* callback. Used by `host.info` so the PWA can seed its modal state on connect.
|
|
74
|
-
*/
|
|
60
|
+
/** Pending requests stripped of the unserializable `resolve` callback. */
|
|
75
61
|
export function listPending(): Array<{
|
|
76
62
|
key: string;
|
|
77
63
|
type: PendingRequest["type"];
|
package/src/platform/index.ts
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import type { PlatformService } from "./platform.js";
|
|
2
2
|
import { LinuxPlatform } from "./linux.js";
|
|
3
3
|
import { WindowsPlatform } from "./windows.js";
|
|
4
|
+
import { MacOsPlatform } from "./macos.js";
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
* On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
|
|
7
|
-
* On Unix, undefined lets Node use the default shell.
|
|
8
|
-
*/
|
|
6
|
+
/** Windows needs an explicit shell for execSync to resolve .cmd shims. */
|
|
9
7
|
export const SHELL: string | undefined = process.platform === "win32" ? "cmd.exe" : undefined;
|
|
10
8
|
|
|
11
9
|
let _instance: PlatformService | undefined;
|
|
12
10
|
|
|
13
11
|
export function getPlatform(): PlatformService {
|
|
14
12
|
if (!_instance) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
if (process.platform === "win32") _instance = new WindowsPlatform();
|
|
14
|
+
else if (process.platform === "darwin") _instance = new MacOsPlatform();
|
|
15
|
+
else _instance = new LinuxPlatform();
|
|
18
16
|
}
|
|
19
17
|
return _instance;
|
|
20
18
|
}
|
package/src/platform/linux.ts
CHANGED
|
@@ -22,15 +22,9 @@ function getServiceName(taskId: string): string {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* hourly: "0 * * * *"
|
|
29
|
-
* daily: "MM HH * * *"
|
|
30
|
-
* weekly: "MM HH * * D"
|
|
31
|
-
* monthly: "MM HH D * *"
|
|
32
|
-
* Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
|
|
33
|
-
* handled because the UI never generates them.
|
|
25
|
+
* Only the 4 cron patterns the PWA UI produces are supported:
|
|
26
|
+
* hourly "0 * * * *", daily "MM HH * * *", weekly "MM HH * * D", monthly "MM HH D * *".
|
|
27
|
+
* Arbitrary expressions (ranges, lists, sub-hour steps) are not handled.
|
|
34
28
|
*/
|
|
35
29
|
export function cronToOnCalendar(cron: string): string {
|
|
36
30
|
const parts = cron.trim().split(/\s+/);
|
|
@@ -40,7 +34,6 @@ export function cronToOnCalendar(cron: string): string {
|
|
|
40
34
|
|
|
41
35
|
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
|
42
36
|
|
|
43
|
-
// Map cron day-of-week numbers to systemd abbreviated names
|
|
44
37
|
const dowMap: Record<string, string> = {
|
|
45
38
|
"0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed",
|
|
46
39
|
"4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun",
|
|
@@ -73,8 +66,8 @@ export class LinuxPlatform implements PlatformService {
|
|
|
73
66
|
fs.mkdirSync(UNIT_DIR, { recursive: true });
|
|
74
67
|
|
|
75
68
|
const palmierBin = process.argv[1] || "palmier";
|
|
76
|
-
// Save the user's shell PATH so restartDaemon can
|
|
77
|
-
//
|
|
69
|
+
// Save the user's shell PATH so restartDaemon can reuse it later — under
|
|
70
|
+
// systemd the daemon itself runs with a limited PATH.
|
|
78
71
|
const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
|
|
79
72
|
fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
|
|
80
73
|
fs.writeFileSync(PATH_FILE, userPath, "utf-8");
|
|
@@ -110,7 +103,7 @@ WantedBy=default.target
|
|
|
110
103
|
console.error("You may need to start it manually: systemctl --user enable --now palmier.service");
|
|
111
104
|
}
|
|
112
105
|
|
|
113
|
-
//
|
|
106
|
+
// Lingering lets the service run without an active login session.
|
|
114
107
|
try {
|
|
115
108
|
execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
|
|
116
109
|
console.log("Login lingering enabled.");
|
|
@@ -127,11 +120,9 @@ WantedBy=default.target
|
|
|
127
120
|
execSync("systemctl --user disable palmier.service 2>/dev/null", { stdio: "pipe" });
|
|
128
121
|
} catch { /* service may not exist */ }
|
|
129
122
|
|
|
130
|
-
// Remove daemon service file
|
|
131
123
|
const servicePath = path.join(UNIT_DIR, "palmier.service");
|
|
132
124
|
try { fs.unlinkSync(servicePath); } catch { /* ignore */ }
|
|
133
125
|
|
|
134
|
-
// Remove all task timers and services
|
|
135
126
|
try {
|
|
136
127
|
const files = fs.readdirSync(UNIT_DIR).filter((f) => f.startsWith("palmier-task-"));
|
|
137
128
|
for (const f of files) {
|
|
@@ -148,8 +139,8 @@ WantedBy=default.target
|
|
|
148
139
|
}
|
|
149
140
|
|
|
150
141
|
async restartDaemon(): Promise<void> {
|
|
151
|
-
//
|
|
152
|
-
//
|
|
142
|
+
// From a TTY, snapshot the current PATH; from the daemon (auto-update),
|
|
143
|
+
// reuse whatever was last saved.
|
|
153
144
|
if (process.stdin.isTTY) {
|
|
154
145
|
fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
|
|
155
146
|
fs.writeFileSync(PATH_FILE, process.env.PATH || "", "utf-8");
|
|
@@ -196,7 +187,6 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
|
|
|
196
187
|
fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
|
|
197
188
|
daemonReload();
|
|
198
189
|
|
|
199
|
-
// Only create and enable a timer if the schedule exists and is enabled
|
|
200
190
|
if (!task.frontmatter.schedule_enabled) return;
|
|
201
191
|
const scheduleType = task.frontmatter.schedule_type;
|
|
202
192
|
const scheduleValues = task.frontmatter.schedule_values;
|
|
@@ -260,7 +250,6 @@ WantedBy=timers.target
|
|
|
260
250
|
}
|
|
261
251
|
|
|
262
252
|
isTaskRunning(taskId: string): boolean {
|
|
263
|
-
// Check systemd first (for scheduled/on-demand runs)
|
|
264
253
|
const serviceName = getServiceName(taskId);
|
|
265
254
|
try {
|
|
266
255
|
const out = execSync(
|
|
@@ -271,7 +260,7 @@ WantedBy=timers.target
|
|
|
271
260
|
if (state === "active" || state === "activating") return true;
|
|
272
261
|
} catch { /* service may not exist */ }
|
|
273
262
|
|
|
274
|
-
//
|
|
263
|
+
// Follow-up runs are spawned directly, so check PID too.
|
|
275
264
|
try {
|
|
276
265
|
const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
|
|
277
266
|
const status = readTaskStatus(taskDir);
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { execSync, exec } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import type { PlatformService } from "./platform.js";
|
|
7
|
+
import type { HostConfig, ParsedTask } from "../types.js";
|
|
8
|
+
import { CONFIG_DIR, loadConfig } from "../config.js";
|
|
9
|
+
import { getTaskDir, readTaskStatus } from "../task.js";
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
|
|
13
|
+
const AGENT_DIR = path.join(homedir(), "Library", "LaunchAgents");
|
|
14
|
+
const PATH_FILE = path.join(CONFIG_DIR, "user-path");
|
|
15
|
+
const DAEMON_LABEL = "me.palmier.host";
|
|
16
|
+
const TASK_LABEL_PREFIX = "me.palmier.task.";
|
|
17
|
+
|
|
18
|
+
function daemonPlistPath(): string {
|
|
19
|
+
return path.join(AGENT_DIR, `${DAEMON_LABEL}.plist`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function taskLabel(taskId: string): string {
|
|
23
|
+
return `${TASK_LABEL_PREFIX}${taskId}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function taskPlistPath(taskId: string): string {
|
|
27
|
+
return path.join(AGENT_DIR, `${taskLabel(taskId)}.plist`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function guiDomain(): string {
|
|
31
|
+
const uid = process.getuid?.();
|
|
32
|
+
if (uid === undefined) throw new Error("getuid() unavailable — macOS platform requires POSIX uid");
|
|
33
|
+
return `gui/${uid}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert one of the four PWA-produced cron patterns to a launchd
|
|
38
|
+
* `StartCalendarInterval` dict.
|
|
39
|
+
* hourly "0 * * * *" → { Minute: 0 }
|
|
40
|
+
* daily "MM HH * * *" → { Minute, Hour }
|
|
41
|
+
* weekly "MM HH * * D" → { Minute, Hour, Weekday }
|
|
42
|
+
* monthly "MM HH D * *" → { Minute, Hour, Day }
|
|
43
|
+
* launchd Weekday: Sunday is 0 (cron 7 → 0).
|
|
44
|
+
*/
|
|
45
|
+
export function cronToCalendarInterval(cron: string): Record<string, number> {
|
|
46
|
+
const parts = cron.trim().split(/\s+/);
|
|
47
|
+
if (parts.length !== 5) {
|
|
48
|
+
throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
|
|
49
|
+
}
|
|
50
|
+
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
|
51
|
+
const result: Record<string, number> = {};
|
|
52
|
+
|
|
53
|
+
if (minute !== "*") result.Minute = Number(minute);
|
|
54
|
+
if (hour !== "*") result.Hour = Number(hour);
|
|
55
|
+
if (dayOfMonth !== "*") result.Day = Number(dayOfMonth);
|
|
56
|
+
if (dayOfWeek !== "*") {
|
|
57
|
+
const dow = Number(dayOfWeek);
|
|
58
|
+
result.Weekday = dow === 7 ? 0 : dow;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [k, v] of Object.entries(result)) {
|
|
62
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
63
|
+
throw new Error(`Invalid cron field ${k}=${v} in ${cron}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convert a PWA `specific_times` value (ISO local datetime like "2026-04-20T09:00")
|
|
71
|
+
* to a `StartCalendarInterval` dict. launchd has no "one-shot at date X" trigger,
|
|
72
|
+
* so we omit Year — the task fires yearly on the same date and time. Sufficient
|
|
73
|
+
* because the PWA regenerates/removes one-off tasks after they run.
|
|
74
|
+
*/
|
|
75
|
+
export function specificTimeToCalendarInterval(iso: string): Record<string, number> {
|
|
76
|
+
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
77
|
+
if (!m) throw new Error(`Invalid specific_times value: ${iso}`);
|
|
78
|
+
return {
|
|
79
|
+
Month: Number(m[2]),
|
|
80
|
+
Day: Number(m[3]),
|
|
81
|
+
Hour: Number(m[4]),
|
|
82
|
+
Minute: Number(m[5]),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function escapeXml(s: string): string {
|
|
87
|
+
return s
|
|
88
|
+
.replace(/&/g, "&")
|
|
89
|
+
.replace(/</g, "<")
|
|
90
|
+
.replace(/>/g, ">");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Serialize a JS value to a plist XML fragment. Supports string/number/boolean/array/plain object. */
|
|
94
|
+
function plistValue(value: unknown, indent: string): string {
|
|
95
|
+
if (typeof value === "string") return `${indent}<string>${escapeXml(value)}</string>`;
|
|
96
|
+
if (typeof value === "boolean") return `${indent}<${value ? "true" : "false"}/>`;
|
|
97
|
+
if (typeof value === "number") {
|
|
98
|
+
return Number.isInteger(value)
|
|
99
|
+
? `${indent}<integer>${value}</integer>`
|
|
100
|
+
: `${indent}<real>${value}</real>`;
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
if (value.length === 0) return `${indent}<array/>`;
|
|
104
|
+
const inner = value.map((v) => plistValue(v, indent + " ")).join("\n");
|
|
105
|
+
return `${indent}<array>\n${inner}\n${indent}</array>`;
|
|
106
|
+
}
|
|
107
|
+
if (value && typeof value === "object") {
|
|
108
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
109
|
+
if (entries.length === 0) return `${indent}<dict/>`;
|
|
110
|
+
const inner = entries
|
|
111
|
+
.map(([k, v]) => `${indent} <key>${escapeXml(k)}</key>\n${plistValue(v, indent + " ")}`)
|
|
112
|
+
.join("\n");
|
|
113
|
+
return `${indent}<dict>\n${inner}\n${indent}</dict>`;
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`Unsupported plist value type: ${typeof value}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildPlist(dict: Record<string, unknown>): string {
|
|
119
|
+
return [
|
|
120
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
121
|
+
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
|
|
122
|
+
`<plist version="1.0">`,
|
|
123
|
+
plistValue(dict, ""),
|
|
124
|
+
`</plist>`,
|
|
125
|
+
``,
|
|
126
|
+
].join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function runLaunchctl(args: string[], opts: { ignoreFailure?: boolean } = {}): void {
|
|
130
|
+
try {
|
|
131
|
+
execSync(`launchctl ${args.join(" ")}`, { stdio: "pipe", encoding: "utf-8" });
|
|
132
|
+
} catch (err: unknown) {
|
|
133
|
+
if (opts.ignoreFailure) return;
|
|
134
|
+
const e = err as { stderr?: string };
|
|
135
|
+
console.error(`launchctl ${args[0]} failed: ${e.stderr || err}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class MacOsPlatform implements PlatformService {
|
|
140
|
+
installDaemon(config: HostConfig): void {
|
|
141
|
+
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
|
142
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
143
|
+
|
|
144
|
+
const palmierBin = process.argv[1] || "palmier";
|
|
145
|
+
const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
|
|
146
|
+
fs.writeFileSync(PATH_FILE, userPath, "utf-8");
|
|
147
|
+
|
|
148
|
+
const logPath = path.join(CONFIG_DIR, "daemon.log");
|
|
149
|
+
const plist = buildPlist({
|
|
150
|
+
Label: DAEMON_LABEL,
|
|
151
|
+
ProgramArguments: [process.execPath, palmierBin, "serve"],
|
|
152
|
+
WorkingDirectory: config.projectRoot,
|
|
153
|
+
RunAtLoad: true,
|
|
154
|
+
KeepAlive: { SuccessfulExit: false },
|
|
155
|
+
EnvironmentVariables: { PATH: userPath },
|
|
156
|
+
StandardOutPath: logPath,
|
|
157
|
+
StandardErrorPath: logPath,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const plistPath = daemonPlistPath();
|
|
161
|
+
fs.writeFileSync(plistPath, plist, "utf-8");
|
|
162
|
+
console.log("LaunchAgent installed at:", plistPath);
|
|
163
|
+
|
|
164
|
+
const domain = guiDomain();
|
|
165
|
+
runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
|
|
166
|
+
runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
|
|
167
|
+
runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
|
|
168
|
+
|
|
169
|
+
console.log("Palmier host LaunchAgent loaded and started.");
|
|
170
|
+
console.log(
|
|
171
|
+
"Note: LaunchAgents only run while you are logged into the GUI session. " +
|
|
172
|
+
"After reboot, tasks remain dormant until you log in at least once.",
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
console.log("\nHost initialization complete!");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
uninstallDaemon(): void {
|
|
179
|
+
const domain = guiDomain();
|
|
180
|
+
runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
|
|
181
|
+
try { fs.unlinkSync(daemonPlistPath()); } catch { /* may not exist */ }
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const entries = fs.readdirSync(AGENT_DIR).filter((f) => f.startsWith(TASK_LABEL_PREFIX) && f.endsWith(".plist"));
|
|
185
|
+
for (const f of entries) {
|
|
186
|
+
const label = f.slice(0, -".plist".length);
|
|
187
|
+
runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
|
|
188
|
+
try { fs.unlinkSync(path.join(AGENT_DIR, f)); } catch { /* ignore */ }
|
|
189
|
+
}
|
|
190
|
+
} catch { /* AGENT_DIR may not exist */ }
|
|
191
|
+
|
|
192
|
+
console.log("Palmier daemon and tasks uninstalled.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async restartDaemon(): Promise<void> {
|
|
196
|
+
const plistPath = daemonPlistPath();
|
|
197
|
+
const domain = guiDomain();
|
|
198
|
+
|
|
199
|
+
if (process.stdin.isTTY && fs.existsSync(plistPath)) {
|
|
200
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
201
|
+
const userPath = process.env.PATH || "";
|
|
202
|
+
fs.writeFileSync(PATH_FILE, userPath, "utf-8");
|
|
203
|
+
|
|
204
|
+
const content = fs.readFileSync(plistPath, "utf-8");
|
|
205
|
+
const updated = content.replace(
|
|
206
|
+
/(<key>PATH<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/,
|
|
207
|
+
`$1${escapeXml(userPath)}$2`,
|
|
208
|
+
);
|
|
209
|
+
if (updated !== content) {
|
|
210
|
+
fs.writeFileSync(plistPath, updated, "utf-8");
|
|
211
|
+
runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
|
|
212
|
+
runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
|
|
217
|
+
console.log("Palmier daemon restarted.");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
installTaskTimer(config: HostConfig, task: ParsedTask): void {
|
|
221
|
+
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
|
222
|
+
|
|
223
|
+
const taskId = task.frontmatter.id;
|
|
224
|
+
const label = taskLabel(taskId);
|
|
225
|
+
const plistPath = taskPlistPath(taskId);
|
|
226
|
+
const palmierBin = process.argv[1] || "palmier";
|
|
227
|
+
|
|
228
|
+
const dict: Record<string, unknown> = {
|
|
229
|
+
Label: label,
|
|
230
|
+
ProgramArguments: [process.execPath, palmierBin, "run", taskId],
|
|
231
|
+
WorkingDirectory: config.projectRoot,
|
|
232
|
+
RunAtLoad: false,
|
|
233
|
+
EnvironmentVariables: { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" },
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const scheduleType = task.frontmatter.schedule_type;
|
|
237
|
+
const scheduleValues = task.frontmatter.schedule_values;
|
|
238
|
+
const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
|
|
239
|
+
if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
|
|
240
|
+
const intervals: Record<string, number>[] = [];
|
|
241
|
+
for (const value of scheduleValues) {
|
|
242
|
+
try {
|
|
243
|
+
intervals.push(
|
|
244
|
+
scheduleType === "crons"
|
|
245
|
+
? cronToCalendarInterval(value)
|
|
246
|
+
: specificTimeToCalendarInterval(value),
|
|
247
|
+
);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.error(`Invalid schedule value: ${err}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (intervals.length > 0) dict.StartCalendarInterval = intervals;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fs.writeFileSync(plistPath, buildPlist(dict), "utf-8");
|
|
256
|
+
|
|
257
|
+
const domain = guiDomain();
|
|
258
|
+
runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
|
|
259
|
+
runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
removeTaskTimer(taskId: string): void {
|
|
263
|
+
const domain = guiDomain();
|
|
264
|
+
runLaunchctl(["bootout", `${domain}/${taskLabel(taskId)}`], { ignoreFailure: true });
|
|
265
|
+
try { fs.unlinkSync(taskPlistPath(taskId)); } catch { /* ignore */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async startTask(taskId: string): Promise<void> {
|
|
269
|
+
await execAsync(`launchctl kickstart ${guiDomain()}/${taskLabel(taskId)}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async stopTask(taskId: string): Promise<void> {
|
|
273
|
+
try {
|
|
274
|
+
const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
|
|
275
|
+
const status = readTaskStatus(taskDir);
|
|
276
|
+
if (status?.pid) {
|
|
277
|
+
process.kill(status.pid, "SIGTERM");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
} catch { /* fall through */ }
|
|
281
|
+
|
|
282
|
+
await execAsync(`launchctl kill SIGTERM ${guiDomain()}/${taskLabel(taskId)}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
isTaskRunning(taskId: string): boolean {
|
|
286
|
+
try {
|
|
287
|
+
const out = execSync(`launchctl print ${guiDomain()}/${taskLabel(taskId)}`, {
|
|
288
|
+
encoding: "utf-8",
|
|
289
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
290
|
+
});
|
|
291
|
+
// Running services show a numeric `pid = N`; idle ones show `state = not running`.
|
|
292
|
+
if (/^\s*pid\s*=\s*\d+/m.test(out)) return true;
|
|
293
|
+
} catch { /* service may not be loaded */ }
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
|
|
297
|
+
const status = readTaskStatus(taskDir);
|
|
298
|
+
if (status?.pid) {
|
|
299
|
+
process.kill(status.pid, 0);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
} catch { /* process not running or config unavailable */ }
|
|
303
|
+
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getGuiEnv(): Record<string, string> {
|
|
308
|
+
return {};
|
|
309
|
+
}
|
|
310
|
+
}
|
package/src/platform/platform.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import type { HostConfig, ParsedTask } from "../types.js";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Abstracts OS-specific daemon, scheduling, and process management.
|
|
5
|
-
* Linux uses systemd; Windows uses Task Scheduler; macOS will use launchd.
|
|
6
|
-
*/
|
|
3
|
+
/** Linux: systemd. Windows: Task Scheduler. macOS: launchd (planned). */
|
|
7
4
|
export interface PlatformService {
|
|
8
5
|
/** Install the main `palmier serve` daemon to start at boot. */
|
|
9
6
|
installDaemon(config: HostConfig): void;
|