palmier 0.9.6 → 0.9.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 +28 -13
- package/dist/agents/agent.d.ts +0 -1
- package/dist/agents/agent.js +0 -1
- package/dist/agents/aider.d.ts +0 -1
- package/dist/agents/aider.js +0 -1
- package/dist/agents/claude.d.ts +0 -1
- package/dist/agents/claude.js +0 -1
- package/dist/agents/cline.d.ts +0 -1
- package/dist/agents/cline.js +0 -1
- package/dist/agents/codex.d.ts +0 -1
- package/dist/agents/codex.js +0 -1
- package/dist/agents/copilot.d.ts +0 -1
- package/dist/agents/copilot.js +0 -1
- package/dist/agents/cursor.d.ts +0 -1
- package/dist/agents/cursor.js +0 -1
- package/dist/agents/deepagents.d.ts +0 -1
- package/dist/agents/deepagents.js +0 -1
- package/dist/agents/droid.d.ts +0 -1
- package/dist/agents/droid.js +0 -1
- package/dist/agents/gemini.d.ts +0 -1
- package/dist/agents/gemini.js +0 -1
- package/dist/agents/goose.d.ts +0 -1
- package/dist/agents/goose.js +0 -1
- package/dist/agents/hermes.d.ts +0 -1
- package/dist/agents/hermes.js +0 -1
- package/dist/agents/kimi.d.ts +0 -1
- package/dist/agents/kimi.js +0 -1
- package/dist/agents/kiro.d.ts +0 -1
- package/dist/agents/kiro.js +0 -1
- package/dist/agents/openclaw.d.ts +0 -1
- package/dist/agents/openclaw.js +0 -1
- package/dist/agents/opencode.d.ts +0 -1
- package/dist/agents/opencode.js +0 -1
- package/dist/agents/qoder.d.ts +0 -1
- package/dist/agents/qoder.js +0 -1
- package/dist/agents/qwen.d.ts +0 -1
- package/dist/agents/qwen.js +0 -1
- package/dist/agents/shared-prompt.d.ts +0 -1
- package/dist/agents/shared-prompt.js +0 -1
- package/dist/client-store.d.ts +0 -1
- package/dist/client-store.js +0 -1
- package/dist/commands/clients.d.ts +0 -1
- package/dist/commands/clients.js +0 -1
- package/dist/commands/info.d.ts +0 -1
- package/dist/commands/info.js +0 -1
- package/dist/commands/init.d.ts +0 -1
- package/dist/commands/init.js +1 -2
- package/dist/commands/pair.d.ts +0 -1
- package/dist/commands/pair.js +0 -1
- package/dist/commands/restart.d.ts +0 -1
- package/dist/commands/restart.js +0 -1
- package/dist/commands/run.d.ts +0 -1
- package/dist/commands/run.js +0 -1
- package/dist/commands/serve.d.ts +0 -1
- package/dist/commands/serve.js +0 -1
- package/dist/commands/uninstall.d.ts +0 -1
- package/dist/commands/uninstall.js +0 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/event-queues.d.ts +0 -1
- package/dist/event-queues.js +0 -1
- package/dist/events.d.ts +0 -1
- package/dist/events.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/linked-device.d.ts +0 -1
- package/dist/linked-device.js +0 -1
- package/dist/mcp-handler.d.ts +0 -1
- package/dist/mcp-handler.js +0 -1
- package/dist/mcp-tools.d.ts +0 -1
- package/dist/mcp-tools.js +0 -1
- package/dist/nats-client.d.ts +0 -1
- package/dist/nats-client.js +0 -1
- package/dist/network.d.ts +0 -1
- package/dist/network.js +0 -1
- package/dist/notification-store.d.ts +0 -1
- package/dist/notification-store.js +0 -1
- package/dist/pending-requests.d.ts +0 -1
- package/dist/pending-requests.js +0 -1
- package/dist/platform/index.d.ts +0 -1
- package/dist/platform/index.js +0 -1
- package/dist/platform/linux.d.ts +0 -1
- package/dist/platform/linux.js +0 -1
- package/dist/platform/macos.d.ts +0 -1
- package/dist/platform/macos.js +0 -1
- package/dist/platform/platform.d.ts +0 -1
- package/dist/platform/platform.js +0 -1
- package/dist/platform/windows.d.ts +0 -1
- package/dist/platform/windows.js +0 -1
- package/dist/pwa/assets/{index-MLEFUP3r.js → index-DWvRAUiy.js} +31 -31
- package/dist/pwa/assets/{web-B1sKCc7e.js → web-C4iZbqTC.js} +1 -1
- package/dist/pwa/assets/{web-ETD-8ZHd.js → web-CBFqJGX6.js} +1 -1
- package/dist/pwa/assets/{web-B4xEa6WO.js → web-DL4uXOpS.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 -1
- package/dist/rpc-handler.js +0 -1
- package/dist/sms-store.d.ts +0 -1
- package/dist/sms-store.js +0 -1
- package/dist/spawn-command.d.ts +0 -1
- package/dist/spawn-command.js +0 -1
- package/dist/task.d.ts +0 -1
- package/dist/task.js +0 -1
- package/dist/transports/http-transport.d.ts +0 -1
- package/dist/transports/http-transport.js +0 -1
- package/dist/transports/nats-transport.d.ts +0 -1
- package/dist/transports/nats-transport.js +0 -1
- package/dist/types.d.ts +0 -1
- package/dist/types.js +0 -1
- package/dist/update-checker.d.ts +0 -1
- package/dist/update-checker.js +0 -1
- package/package.json +5 -1
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -37
- package/CLAUDE.md +0 -22
- package/palmier-server/.github/workflows/ci.yml +0 -21
- package/palmier-server/.github/workflows/deploy.yml +0 -38
- package/palmier-server/CLAUDE.md +0 -17
- package/palmier-server/PRODUCTION.md +0 -358
- package/palmier-server/README.md +0 -231
- package/palmier-server/nats.conf +0 -19
- package/palmier-server/package.json +0 -15
- package/palmier-server/pnpm-lock.yaml +0 -7639
- package/palmier-server/pnpm-workspace.yaml +0 -3
- package/palmier-server/pwa/index.html +0 -16
- package/palmier-server/pwa/logo/logo_20260421.png +0 -0
- package/palmier-server/pwa/package.json +0 -34
- package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
- package/palmier-server/pwa/public/favicon.ico +0 -0
- package/palmier-server/pwa/public/pwa-192x192.png +0 -0
- package/palmier-server/pwa/public/pwa-512x512.png +0 -0
- package/palmier-server/pwa/src/App.css +0 -3012
- package/palmier-server/pwa/src/App.tsx +0 -59
- package/palmier-server/pwa/src/agentLabels.ts +0 -11
- package/palmier-server/pwa/src/api.ts +0 -67
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +0 -170
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +0 -113
- package/palmier-server/pwa/src/components/HostMenu.tsx +0 -429
- package/palmier-server/pwa/src/components/PermissionsDialog.tsx +0 -34
- package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +0 -46
- package/palmier-server/pwa/src/components/RunDetailView.tsx +0 -343
- package/palmier-server/pwa/src/components/SessionComposer.tsx +0 -157
- package/palmier-server/pwa/src/components/SessionsView.tsx +0 -326
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +0 -170
- package/palmier-server/pwa/src/components/TabBar.tsx +0 -40
- package/palmier-server/pwa/src/components/TaskCard.tsx +0 -255
- package/palmier-server/pwa/src/components/TaskForm.tsx +0 -766
- package/palmier-server/pwa/src/components/TasksView.tsx +0 -179
- package/palmier-server/pwa/src/constants.ts +0 -2
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +0 -432
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +0 -124
- package/palmier-server/pwa/src/draftGuard.ts +0 -24
- package/palmier-server/pwa/src/formatTime.ts +0 -44
- package/palmier-server/pwa/src/hooks/useBackClose.ts +0 -75
- package/palmier-server/pwa/src/hooks/useMediaQuery.ts +0 -17
- package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +0 -102
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +0 -77
- package/palmier-server/pwa/src/main.tsx +0 -14
- package/palmier-server/pwa/src/native/Device.ts +0 -49
- package/palmier-server/pwa/src/pages/Dashboard.tsx +0 -542
- package/palmier-server/pwa/src/pages/PairHost.tsx +0 -232
- package/palmier-server/pwa/src/pages/PairSetup.tsx +0 -134
- package/palmier-server/pwa/src/service-worker.ts +0 -142
- package/palmier-server/pwa/src/types.ts +0 -75
- package/palmier-server/pwa/src/vite-env.d.ts +0 -11
- package/palmier-server/pwa/tsconfig.json +0 -21
- package/palmier-server/pwa/tsconfig.node.json +0 -19
- package/palmier-server/pwa/vite.config.ts +0 -47
- package/palmier-server/server/.env.example +0 -20
- package/palmier-server/server/package.json +0 -36
- package/palmier-server/server/src/db.ts +0 -44
- package/palmier-server/server/src/fcm.ts +0 -74
- package/palmier-server/server/src/index.ts +0 -688
- package/palmier-server/server/src/nats-jwt.ts +0 -299
- package/palmier-server/server/src/nats-setup.ts +0 -48
- package/palmier-server/server/src/nats.ts +0 -33
- package/palmier-server/server/src/notify.ts +0 -34
- package/palmier-server/server/src/push.ts +0 -68
- package/palmier-server/server/src/routes/device.ts +0 -224
- package/palmier-server/server/src/routes/fcm.ts +0 -64
- package/palmier-server/server/src/routes/hosts.ts +0 -56
- package/palmier-server/server/src/routes/push.ts +0 -101
- package/palmier-server/server/tsconfig.json +0 -20
- package/palmier-server/spec.md +0 -533
- package/src/agents/agent-instructions.md +0 -28
- package/src/agents/agent.ts +0 -114
- package/src/agents/aider.ts +0 -35
- package/src/agents/claude.ts +0 -39
- package/src/agents/cline.ts +0 -35
- package/src/agents/codex.ts +0 -40
- package/src/agents/copilot.ts +0 -37
- package/src/agents/cursor.ts +0 -36
- package/src/agents/deepagents.ts +0 -36
- package/src/agents/droid.ts +0 -35
- package/src/agents/gemini.ts +0 -43
- package/src/agents/goose.ts +0 -33
- package/src/agents/hermes.ts +0 -36
- package/src/agents/kimi.ts +0 -35
- package/src/agents/kiro.ts +0 -36
- package/src/agents/openclaw.ts +0 -29
- package/src/agents/opencode.ts +0 -36
- package/src/agents/qoder.ts +0 -36
- package/src/agents/qwen.ts +0 -32
- package/src/agents/shared-prompt.ts +0 -30
- package/src/client-store.ts +0 -68
- package/src/commands/clients.ts +0 -29
- package/src/commands/info.ts +0 -29
- package/src/commands/init.ts +0 -165
- package/src/commands/pair.ts +0 -137
- package/src/commands/restart.ts +0 -6
- package/src/commands/run.ts +0 -608
- package/src/commands/serve.ts +0 -211
- package/src/commands/uninstall.ts +0 -9
- package/src/config.ts +0 -36
- package/src/cross-spawn.d.ts +0 -5
- package/src/event-queues.ts +0 -41
- package/src/events.ts +0 -29
- package/src/index.ts +0 -111
- package/src/linked-device.ts +0 -52
- package/src/mcp-handler.ts +0 -200
- package/src/mcp-tools.ts +0 -839
- package/src/nats-client.ts +0 -19
- package/src/network.ts +0 -96
- package/src/notification-store.ts +0 -30
- package/src/pending-requests.ts +0 -73
- package/src/platform/index.ts +0 -20
- package/src/platform/linux.ts +0 -296
- package/src/platform/macos.ts +0 -329
- package/src/platform/platform.ts +0 -31
- package/src/platform/windows.ts +0 -299
- package/src/rpc-handler.ts +0 -691
- package/src/sms-store.ts +0 -28
- package/src/spawn-command.ts +0 -123
- package/src/task.ts +0 -343
- package/src/transports/http-transport.ts +0 -478
- package/src/transports/nats-transport.ts +0 -76
- package/src/types.ts +0 -89
- package/src/update-checker.ts +0 -40
- package/test/agent-instructions.test.ts +0 -209
- package/test/agent-output-parsing.test.ts +0 -74
- package/test/linux-cron.test.ts +0 -41
- package/test/macos-plist.test.ts +0 -112
- package/test/notification-store.test.ts +0 -57
- package/test/pairing.test.ts +0 -35
- package/test/result-state.test.ts +0 -110
- package/test/task-parsing.test.ts +0 -82
- package/test/taskrun-messages.test.ts +0 -224
- package/test/tsconfig.json +0 -9
- package/test/windows-xml.test.ts +0 -89
- package/tsconfig.json +0 -19
package/src/platform/macos.ts
DELETED
|
@@ -1,329 +0,0 @@
|
|
|
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 taskLogPath(taskId: string): string {
|
|
31
|
-
return path.join(CONFIG_DIR, `task-${taskId}.log`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function guiDomain(): string {
|
|
35
|
-
const uid = process.getuid?.();
|
|
36
|
-
if (uid === undefined) throw new Error("getuid() unavailable — macOS platform requires POSIX uid");
|
|
37
|
-
return `gui/${uid}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Convert one of the four PWA-produced cron patterns to a launchd
|
|
42
|
-
* `StartCalendarInterval` dict.
|
|
43
|
-
* hourly "0 * * * *" → { Minute: 0 }
|
|
44
|
-
* daily "MM HH * * *" → { Minute, Hour }
|
|
45
|
-
* weekly "MM HH * * D" → { Minute, Hour, Weekday }
|
|
46
|
-
* monthly "MM HH D * *" → { Minute, Hour, Day }
|
|
47
|
-
* launchd Weekday: Sunday is 0 (cron 7 → 0).
|
|
48
|
-
*/
|
|
49
|
-
export function cronToCalendarInterval(cron: string): Record<string, number> {
|
|
50
|
-
const parts = cron.trim().split(/\s+/);
|
|
51
|
-
if (parts.length !== 5) {
|
|
52
|
-
throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
|
|
53
|
-
}
|
|
54
|
-
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
|
55
|
-
const result: Record<string, number> = {};
|
|
56
|
-
|
|
57
|
-
if (minute !== "*") result.Minute = Number(minute);
|
|
58
|
-
if (hour !== "*") result.Hour = Number(hour);
|
|
59
|
-
if (dayOfMonth !== "*") result.Day = Number(dayOfMonth);
|
|
60
|
-
if (dayOfWeek !== "*") {
|
|
61
|
-
const dow = Number(dayOfWeek);
|
|
62
|
-
result.Weekday = dow === 7 ? 0 : dow;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
for (const [k, v] of Object.entries(result)) {
|
|
66
|
-
if (!Number.isInteger(v) || v < 0) {
|
|
67
|
-
throw new Error(`Invalid cron field ${k}=${v} in ${cron}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return result;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Convert a PWA `specific_times` value (ISO local datetime like "2026-04-20T09:00")
|
|
75
|
-
* to a `StartCalendarInterval` dict. launchd has no "one-shot at date X" trigger,
|
|
76
|
-
* so we omit Year — the task fires yearly on the same date and time. Sufficient
|
|
77
|
-
* because the PWA regenerates/removes one-off tasks after they run.
|
|
78
|
-
*/
|
|
79
|
-
export function specificTimeToCalendarInterval(iso: string): Record<string, number> {
|
|
80
|
-
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
81
|
-
if (!m) throw new Error(`Invalid specific_times value: ${iso}`);
|
|
82
|
-
return {
|
|
83
|
-
Month: Number(m[2]),
|
|
84
|
-
Day: Number(m[3]),
|
|
85
|
-
Hour: Number(m[4]),
|
|
86
|
-
Minute: Number(m[5]),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function escapeXml(s: string): string {
|
|
91
|
-
return s
|
|
92
|
-
.replace(/&/g, "&")
|
|
93
|
-
.replace(/</g, "<")
|
|
94
|
-
.replace(/>/g, ">");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Serialize a JS value to a plist XML fragment. Supports string/number/boolean/array/plain object. */
|
|
98
|
-
function plistValue(value: unknown, indent: string): string {
|
|
99
|
-
if (typeof value === "string") return `${indent}<string>${escapeXml(value)}</string>`;
|
|
100
|
-
if (typeof value === "boolean") return `${indent}<${value ? "true" : "false"}/>`;
|
|
101
|
-
if (typeof value === "number") {
|
|
102
|
-
return Number.isInteger(value)
|
|
103
|
-
? `${indent}<integer>${value}</integer>`
|
|
104
|
-
: `${indent}<real>${value}</real>`;
|
|
105
|
-
}
|
|
106
|
-
if (Array.isArray(value)) {
|
|
107
|
-
if (value.length === 0) return `${indent}<array/>`;
|
|
108
|
-
const inner = value.map((v) => plistValue(v, indent + " ")).join("\n");
|
|
109
|
-
return `${indent}<array>\n${inner}\n${indent}</array>`;
|
|
110
|
-
}
|
|
111
|
-
if (value && typeof value === "object") {
|
|
112
|
-
const entries = Object.entries(value as Record<string, unknown>);
|
|
113
|
-
if (entries.length === 0) return `${indent}<dict/>`;
|
|
114
|
-
const inner = entries
|
|
115
|
-
.map(([k, v]) => `${indent} <key>${escapeXml(k)}</key>\n${plistValue(v, indent + " ")}`)
|
|
116
|
-
.join("\n");
|
|
117
|
-
return `${indent}<dict>\n${inner}\n${indent}</dict>`;
|
|
118
|
-
}
|
|
119
|
-
throw new Error(`Unsupported plist value type: ${typeof value}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function buildPlist(dict: Record<string, unknown>): string {
|
|
123
|
-
return [
|
|
124
|
-
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
125
|
-
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
|
|
126
|
-
`<plist version="1.0">`,
|
|
127
|
-
plistValue(dict, ""),
|
|
128
|
-
`</plist>`,
|
|
129
|
-
``,
|
|
130
|
-
].join("\n");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function runLaunchctl(args: string[], opts: { ignoreFailure?: boolean } = {}): void {
|
|
134
|
-
try {
|
|
135
|
-
execSync(`launchctl ${args.join(" ")}`, { stdio: "pipe", encoding: "utf-8" });
|
|
136
|
-
} catch (err: unknown) {
|
|
137
|
-
if (opts.ignoreFailure) return;
|
|
138
|
-
const e = err as { stderr?: string };
|
|
139
|
-
console.error(`launchctl ${args[0]} failed: ${e.stderr || err}`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Reload a LaunchAgent plist. The `enable` call is essential: after `bootout`
|
|
145
|
-
* macOS can leave the service in a *disabled* state (tracked in
|
|
146
|
-
* /var/db/com.apple.xpc.launchd/disabled.<uid>.plist). A subsequent bootstrap
|
|
147
|
-
* then fails with "Bootstrap failed: 5: Input/output error".
|
|
148
|
-
*/
|
|
149
|
-
function reloadAgent(domain: string, label: string, plistPath: string): void {
|
|
150
|
-
runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
|
|
151
|
-
runLaunchctl(["enable", `${domain}/${label}`], { ignoreFailure: true });
|
|
152
|
-
runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export class MacOsPlatform implements PlatformService {
|
|
156
|
-
installDaemon(config: HostConfig): void {
|
|
157
|
-
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
|
158
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
159
|
-
|
|
160
|
-
const palmierBin = process.argv[1] || "palmier";
|
|
161
|
-
const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
|
|
162
|
-
fs.writeFileSync(PATH_FILE, userPath, "utf-8");
|
|
163
|
-
|
|
164
|
-
const logPath = path.join(CONFIG_DIR, "daemon.log");
|
|
165
|
-
const plist = buildPlist({
|
|
166
|
-
Label: DAEMON_LABEL,
|
|
167
|
-
ProgramArguments: [process.execPath, palmierBin, "serve"],
|
|
168
|
-
WorkingDirectory: config.projectRoot,
|
|
169
|
-
RunAtLoad: true,
|
|
170
|
-
KeepAlive: { SuccessfulExit: false },
|
|
171
|
-
EnvironmentVariables: { PATH: userPath },
|
|
172
|
-
StandardOutPath: logPath,
|
|
173
|
-
StandardErrorPath: logPath,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
const plistPath = daemonPlistPath();
|
|
177
|
-
fs.writeFileSync(plistPath, plist, "utf-8");
|
|
178
|
-
console.log("LaunchAgent installed at:", plistPath);
|
|
179
|
-
|
|
180
|
-
const domain = guiDomain();
|
|
181
|
-
reloadAgent(domain, DAEMON_LABEL, plistPath);
|
|
182
|
-
runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
|
|
183
|
-
|
|
184
|
-
console.log("Palmier host LaunchAgent loaded and started.");
|
|
185
|
-
console.log(
|
|
186
|
-
"Note: LaunchAgents only run while you are logged into the GUI session. " +
|
|
187
|
-
"After reboot, tasks remain dormant until you log in at least once.",
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
console.log("\nHost initialization complete!");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
uninstallDaemon(): void {
|
|
194
|
-
const domain = guiDomain();
|
|
195
|
-
runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
|
|
196
|
-
try { fs.unlinkSync(daemonPlistPath()); } catch { /* may not exist */ }
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
const entries = fs.readdirSync(AGENT_DIR).filter((f) => f.startsWith(TASK_LABEL_PREFIX) && f.endsWith(".plist"));
|
|
200
|
-
for (const f of entries) {
|
|
201
|
-
const label = f.slice(0, -".plist".length);
|
|
202
|
-
runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
|
|
203
|
-
try { fs.unlinkSync(path.join(AGENT_DIR, f)); } catch { /* ignore */ }
|
|
204
|
-
}
|
|
205
|
-
} catch { /* AGENT_DIR may not exist */ }
|
|
206
|
-
|
|
207
|
-
console.log("Palmier daemon and tasks uninstalled.");
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async restartDaemon(): Promise<void> {
|
|
211
|
-
const plistPath = daemonPlistPath();
|
|
212
|
-
const domain = guiDomain();
|
|
213
|
-
|
|
214
|
-
if (process.stdin.isTTY && fs.existsSync(plistPath)) {
|
|
215
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
216
|
-
const userPath = process.env.PATH || "";
|
|
217
|
-
fs.writeFileSync(PATH_FILE, userPath, "utf-8");
|
|
218
|
-
|
|
219
|
-
const content = fs.readFileSync(plistPath, "utf-8");
|
|
220
|
-
const updated = content.replace(
|
|
221
|
-
/(<key>PATH<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/,
|
|
222
|
-
`$1${escapeXml(userPath)}$2`,
|
|
223
|
-
);
|
|
224
|
-
if (updated !== content) {
|
|
225
|
-
fs.writeFileSync(plistPath, updated, "utf-8");
|
|
226
|
-
reloadAgent(domain, DAEMON_LABEL, plistPath);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
|
|
231
|
-
console.log("Palmier daemon restarted.");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
installTaskTimer(config: HostConfig, task: ParsedTask): void {
|
|
235
|
-
fs.mkdirSync(AGENT_DIR, { recursive: true });
|
|
236
|
-
|
|
237
|
-
const taskId = task.frontmatter.id;
|
|
238
|
-
const label = taskLabel(taskId);
|
|
239
|
-
const plistPath = taskPlistPath(taskId);
|
|
240
|
-
const palmierBin = process.argv[1] || "palmier";
|
|
241
|
-
|
|
242
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
243
|
-
const logPath = taskLogPath(taskId);
|
|
244
|
-
const dict: Record<string, unknown> = {
|
|
245
|
-
Label: label,
|
|
246
|
-
ProgramArguments: [process.execPath, palmierBin, "run", taskId],
|
|
247
|
-
WorkingDirectory: config.projectRoot,
|
|
248
|
-
RunAtLoad: false,
|
|
249
|
-
EnvironmentVariables: { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" },
|
|
250
|
-
StandardOutPath: logPath,
|
|
251
|
-
StandardErrorPath: logPath,
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const scheduleType = task.frontmatter.schedule_type;
|
|
255
|
-
const scheduleValues = task.frontmatter.schedule_values;
|
|
256
|
-
const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
|
|
257
|
-
if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
|
|
258
|
-
const intervals: Record<string, number>[] = [];
|
|
259
|
-
for (const value of scheduleValues) {
|
|
260
|
-
try {
|
|
261
|
-
intervals.push(
|
|
262
|
-
scheduleType === "crons"
|
|
263
|
-
? cronToCalendarInterval(value)
|
|
264
|
-
: specificTimeToCalendarInterval(value),
|
|
265
|
-
);
|
|
266
|
-
} catch (err) {
|
|
267
|
-
console.error(`Invalid schedule value: ${err}`);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
if (intervals.length > 0) dict.StartCalendarInterval = intervals;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
fs.writeFileSync(plistPath, buildPlist(dict), "utf-8");
|
|
274
|
-
|
|
275
|
-
const domain = guiDomain();
|
|
276
|
-
reloadAgent(domain, label, plistPath);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
removeTaskTimer(taskId: string): void {
|
|
280
|
-
const domain = guiDomain();
|
|
281
|
-
runLaunchctl(["bootout", `${domain}/${taskLabel(taskId)}`], { ignoreFailure: true });
|
|
282
|
-
try { fs.unlinkSync(taskPlistPath(taskId)); } catch { /* ignore */ }
|
|
283
|
-
// Keep the log file — parity with journald retention on Linux, and
|
|
284
|
-
// needed to debug the last fire of one-shot specific_times tasks.
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async startTask(taskId: string): Promise<void> {
|
|
288
|
-
await execAsync(`launchctl kickstart ${guiDomain()}/${taskLabel(taskId)}`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async stopTask(taskId: string): Promise<void> {
|
|
292
|
-
try {
|
|
293
|
-
const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
|
|
294
|
-
const status = readTaskStatus(taskDir);
|
|
295
|
-
if (status?.pid) {
|
|
296
|
-
process.kill(status.pid, "SIGTERM");
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
} catch { /* fall through */ }
|
|
300
|
-
|
|
301
|
-
await execAsync(`launchctl kill SIGTERM ${guiDomain()}/${taskLabel(taskId)}`);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
isTaskRunning(taskId: string): boolean {
|
|
305
|
-
try {
|
|
306
|
-
const out = execSync(`launchctl print ${guiDomain()}/${taskLabel(taskId)}`, {
|
|
307
|
-
encoding: "utf-8",
|
|
308
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
309
|
-
});
|
|
310
|
-
// Running services show a numeric `pid = N`; idle ones show `state = not running`.
|
|
311
|
-
if (/^\s*pid\s*=\s*\d+/m.test(out)) return true;
|
|
312
|
-
} catch { /* service may not be loaded */ }
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
|
|
316
|
-
const status = readTaskStatus(taskDir);
|
|
317
|
-
if (status?.pid) {
|
|
318
|
-
process.kill(status.pid, 0);
|
|
319
|
-
return true;
|
|
320
|
-
}
|
|
321
|
-
} catch { /* process not running or config unavailable */ }
|
|
322
|
-
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
getGuiEnv(): Record<string, string> {
|
|
327
|
-
return {};
|
|
328
|
-
}
|
|
329
|
-
}
|
package/src/platform/platform.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { HostConfig, ParsedTask } from "../types.js";
|
|
2
|
-
|
|
3
|
-
/** Linux: systemd. Windows: Task Scheduler. macOS: launchd (planned). */
|
|
4
|
-
export interface PlatformService {
|
|
5
|
-
/** Install the main `palmier serve` daemon to start at boot. */
|
|
6
|
-
installDaemon(config: HostConfig): void;
|
|
7
|
-
|
|
8
|
-
/** Restart the `palmier serve` daemon. */
|
|
9
|
-
restartDaemon(): Promise<void>;
|
|
10
|
-
|
|
11
|
-
/** Stop the daemon and remove all scheduled tasks/timers. */
|
|
12
|
-
uninstallDaemon(): void;
|
|
13
|
-
|
|
14
|
-
/** Install a scheduled trigger (timer) for a task. */
|
|
15
|
-
installTaskTimer(config: HostConfig, task: ParsedTask): void;
|
|
16
|
-
|
|
17
|
-
/** Remove a task's scheduled trigger and service files. */
|
|
18
|
-
removeTaskTimer(taskId: string): void;
|
|
19
|
-
|
|
20
|
-
/** Start a task execution (non-blocking). */
|
|
21
|
-
startTask(taskId: string): Promise<void>;
|
|
22
|
-
|
|
23
|
-
/** Abort/stop a running task. */
|
|
24
|
-
stopTask(taskId: string): Promise<void>;
|
|
25
|
-
|
|
26
|
-
/** Check if a task is currently running via the system scheduler. */
|
|
27
|
-
isTaskRunning(taskId: string): boolean;
|
|
28
|
-
|
|
29
|
-
/** Return env vars needed for GUI access (Linux: DISPLAY, etc.). */
|
|
30
|
-
getGuiEnv(): Record<string, string>;
|
|
31
|
-
}
|
package/src/platform/windows.ts
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import { execFileSync } from "child_process";
|
|
4
|
-
import type { PlatformService } from "./platform.js";
|
|
5
|
-
import type { HostConfig, ParsedTask } from "../types.js";
|
|
6
|
-
import { CONFIG_DIR, loadConfig } from "../config.js";
|
|
7
|
-
import { getTaskDir, readTaskStatus } from "../task.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const TASK_PREFIX = "\\Palmier\\PalmierTask-";
|
|
11
|
-
const DAEMON_TASK_NAME = "PalmierDaemon";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Convert a single schedule value to a Task Scheduler XML trigger element.
|
|
18
|
-
*
|
|
19
|
-
* `specific_times` values are ISO datetime strings like "2026-03-28T09:00".
|
|
20
|
-
*
|
|
21
|
-
* `crons` values are cron expressions. Only these patterns (produced by the PWA UI) are handled:
|
|
22
|
-
* hourly: "0 * * * *"
|
|
23
|
-
* daily: "MM HH * * *"
|
|
24
|
-
* weekly: "MM HH * * D"
|
|
25
|
-
* monthly: "MM HH D * *"
|
|
26
|
-
*/
|
|
27
|
-
export function scheduleValueToXml(scheduleType: "crons" | "specific_times", value: string): string {
|
|
28
|
-
if (scheduleType === "specific_times") {
|
|
29
|
-
return `<TimeTrigger><StartBoundary>${value}:00</StartBoundary></TimeTrigger>`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const parts = value.trim().split(/\s+/);
|
|
33
|
-
if (parts.length !== 5) throw new Error(`Invalid cron expression: ${value}`);
|
|
34
|
-
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
|
35
|
-
const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
|
|
36
|
-
// StartBoundary needs a full date; anchor to a past one.
|
|
37
|
-
const base = `2000-01-01T${st}`;
|
|
38
|
-
|
|
39
|
-
if (hour === "*") {
|
|
40
|
-
return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (dayOfMonth === "*" && dayOfWeek !== "*") {
|
|
44
|
-
const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
|
|
45
|
-
return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (dayOfMonth !== "*" && dayOfWeek === "*") {
|
|
49
|
-
return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByMonth><DaysOfMonth><Day>${dayOfMonth}</Day></DaysOfMonth><Months><January /><February /><March /><April /><May /><June /><July /><August /><September /><October /><November /><December /></Months></ScheduleByMonth></CalendarTrigger>`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string {
|
|
56
|
-
const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
|
|
57
|
-
const commandStr = command?.replace(/"/g, "") ?? "";
|
|
58
|
-
const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
|
|
59
|
-
|
|
60
|
-
return [
|
|
61
|
-
`<?xml version="1.0" encoding="UTF-16"?>`,
|
|
62
|
-
`<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
|
|
63
|
-
` <Principals>`,
|
|
64
|
-
` <Principal>`,
|
|
65
|
-
` <LogonType>${foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
|
|
66
|
-
` <RunLevel>LeastPrivilege</RunLevel>`,
|
|
67
|
-
` </Principal>`,
|
|
68
|
-
` </Principals>`,
|
|
69
|
-
` <Settings>`,
|
|
70
|
-
` <MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>`,
|
|
71
|
-
` <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`,
|
|
72
|
-
` <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>`,
|
|
73
|
-
` <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>`,
|
|
74
|
-
` </Settings>`,
|
|
75
|
-
` <Triggers>${triggers.join("")}</Triggers>`,
|
|
76
|
-
` <Actions>`,
|
|
77
|
-
` <Exec>`,
|
|
78
|
-
` <Command>${commandStr}</Command>`,
|
|
79
|
-
` <Arguments>${argsStr}</Arguments>`,
|
|
80
|
-
` </Exec>`,
|
|
81
|
-
` </Actions>`,
|
|
82
|
-
`</Task>`,
|
|
83
|
-
].join("\n");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function schtasksTaskName(taskId: string): string {
|
|
87
|
-
return `${TASK_PREFIX}${taskId}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export class WindowsPlatform implements PlatformService {
|
|
91
|
-
installDaemon(config: HostConfig): void {
|
|
92
|
-
const script = process.argv[1] || "palmier";
|
|
93
|
-
|
|
94
|
-
this.ensureDaemonTask(script);
|
|
95
|
-
this.startDaemonTask();
|
|
96
|
-
|
|
97
|
-
console.log("\nHost initialization complete!");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
uninstallDaemon(): void {
|
|
101
|
-
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
105
|
-
} catch { /* task may not be running */ }
|
|
106
|
-
|
|
107
|
-
// Deleting an S4U task requires elevation.
|
|
108
|
-
try {
|
|
109
|
-
execFileSync("powershell", [
|
|
110
|
-
"-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
|
|
111
|
-
], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
112
|
-
console.log("Daemon task removed.");
|
|
113
|
-
} catch { /* task may not exist */ }
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
117
|
-
for (const line of out.split("\n")) {
|
|
118
|
-
const match = line.match(/"(\\Palmier\\PalmierTask-[^"]+)"/);
|
|
119
|
-
if (match) {
|
|
120
|
-
try { execFileSync("schtasks", ["/end", "/tn", match[1]], { encoding: "utf-8", windowsHide: true, stdio: "pipe" }); } catch { /* ignore */ }
|
|
121
|
-
try { execFileSync("schtasks", ["/delete", "/tn", match[1], "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" }); } catch { /* ignore */ }
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
console.log("Task timers removed.");
|
|
125
|
-
} catch { /* ignore */ }
|
|
126
|
-
|
|
127
|
-
console.log("Palmier daemon and tasks uninstalled.");
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async restartDaemon(): Promise<void> {
|
|
131
|
-
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
135
|
-
} catch { /* task may not be running */ }
|
|
136
|
-
|
|
137
|
-
this.startDaemonTask();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** S4U LogonType requires elevation to create. */
|
|
141
|
-
private ensureDaemonTask(script: string): void {
|
|
142
|
-
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
143
|
-
const tr = `"${process.execPath}" "${script}" serve`;
|
|
144
|
-
const xml = buildTaskXml(tr, [`<BootTrigger><Enabled>true</Enabled></BootTrigger>`]);
|
|
145
|
-
const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
|
|
146
|
-
try {
|
|
147
|
-
const bom = Buffer.from([0xFF, 0xFE]);
|
|
148
|
-
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
149
|
-
// S4U requires elevation — spawn schtasks via RunAs.
|
|
150
|
-
const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
|
|
151
|
-
execFileSync("powershell", [
|
|
152
|
-
"-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
|
|
153
|
-
], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
154
|
-
} catch (err: unknown) {
|
|
155
|
-
const e = err as { stderr?: string };
|
|
156
|
-
console.error(`Failed to create daemon task: ${e.stderr || err}`);
|
|
157
|
-
} finally {
|
|
158
|
-
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Starting via Task Scheduler runs the daemon outside any session's job object. */
|
|
164
|
-
private startDaemonTask(): void {
|
|
165
|
-
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
166
|
-
try {
|
|
167
|
-
execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
168
|
-
} catch (err: unknown) {
|
|
169
|
-
const e = err as { stderr?: string };
|
|
170
|
-
console.error(`Failed to start daemon via Task Scheduler: ${e.stderr || err}`);
|
|
171
|
-
}
|
|
172
|
-
console.log("Palmier daemon started.");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
installTaskTimer(config: HostConfig, task: ParsedTask): void {
|
|
176
|
-
const taskId = task.frontmatter.id;
|
|
177
|
-
const tn = schtasksTaskName(taskId);
|
|
178
|
-
const script = process.argv[1] || "palmier";
|
|
179
|
-
const tr = `"${process.execPath}" "${script}" run ${taskId}`;
|
|
180
|
-
|
|
181
|
-
// Event-based schedule types (on_new_notification/on_new_sms) are driven by
|
|
182
|
-
// the run process, not the OS scheduler — they fall through to the dummy trigger.
|
|
183
|
-
const triggerElements: string[] = [];
|
|
184
|
-
const scheduleType = task.frontmatter.schedule_type;
|
|
185
|
-
const scheduleValues = task.frontmatter.schedule_values;
|
|
186
|
-
const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
|
|
187
|
-
if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
|
|
188
|
-
for (const value of scheduleValues) {
|
|
189
|
-
try {
|
|
190
|
-
triggerElements.push(scheduleValueToXml(scheduleType, value));
|
|
191
|
-
} catch (err) {
|
|
192
|
-
console.error(`Invalid schedule value: ${err}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
// Dummy trigger so schtasks /run still works.
|
|
197
|
-
if (triggerElements.length === 0) {
|
|
198
|
-
triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// XML registration (vs schtasks flags) gives us access to settings like
|
|
202
|
-
// MultipleInstancesPolicy. S4U keeps the console hidden unless
|
|
203
|
-
// foreground_mode is set. Works unelevated because the caller (daemon)
|
|
204
|
-
// runs elevated.
|
|
205
|
-
const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
|
|
206
|
-
const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
|
|
207
|
-
try {
|
|
208
|
-
// schtasks /xml requires UTF-16LE with BOM.
|
|
209
|
-
const bom = Buffer.from([0xFF, 0xFE]);
|
|
210
|
-
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
211
|
-
execFileSync("schtasks", [
|
|
212
|
-
"/create", "/tn", tn, "/xml", xmlPath, "/f",
|
|
213
|
-
], { encoding: "utf-8", windowsHide: true });
|
|
214
|
-
} catch (err: unknown) {
|
|
215
|
-
const e = err as { stderr?: string };
|
|
216
|
-
console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
|
|
217
|
-
} finally {
|
|
218
|
-
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
removeTaskTimer(taskId: string): void {
|
|
224
|
-
const tn = schtasksTaskName(taskId);
|
|
225
|
-
try {
|
|
226
|
-
execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
|
|
227
|
-
} catch { /* task may not exist */ }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
async startTask(taskId: string): Promise<void> {
|
|
231
|
-
const tn = schtasksTaskName(taskId);
|
|
232
|
-
try {
|
|
233
|
-
execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true });
|
|
234
|
-
} catch (err: unknown) {
|
|
235
|
-
const e = err as { stderr?: string; message?: string };
|
|
236
|
-
throw new Error(`Failed to start task via schtasks: ${e.stderr || e.message}`);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async stopTask(taskId: string): Promise<void> {
|
|
241
|
-
// schtasks /end leaves agent children orphaned, so kill the process tree
|
|
242
|
-
// via the PID recorded in status.json first.
|
|
243
|
-
try {
|
|
244
|
-
const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
|
|
245
|
-
const status = readTaskStatus(taskDir);
|
|
246
|
-
if (status?.pid) {
|
|
247
|
-
execFileSync("taskkill", ["/pid", String(status.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
} catch {
|
|
251
|
-
// PID may be stale or config unavailable; fall through to schtasks /end.
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const tn = schtasksTaskName(taskId);
|
|
255
|
-
try {
|
|
256
|
-
execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
|
|
257
|
-
} catch (err: unknown) {
|
|
258
|
-
const e = err as { stderr?: string; message?: string };
|
|
259
|
-
throw new Error(`Failed to stop task via schtasks: ${e.stderr || e.message}`);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
isTaskRunning(taskId: string): boolean {
|
|
264
|
-
const tn = schtasksTaskName(taskId);
|
|
265
|
-
try {
|
|
266
|
-
const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
|
|
267
|
-
encoding: "utf-8",
|
|
268
|
-
windowsHide: true,
|
|
269
|
-
});
|
|
270
|
-
if (out.includes('"Running"')) return true;
|
|
271
|
-
} catch { /* task may not exist in scheduler */ }
|
|
272
|
-
|
|
273
|
-
// Follow-up runs are spawned directly (not via schtasks), so check PID too.
|
|
274
|
-
try {
|
|
275
|
-
const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
|
|
276
|
-
const status = readTaskStatus(taskDir);
|
|
277
|
-
if (status?.pid) {
|
|
278
|
-
execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
|
|
279
|
-
encoding: "utf-8",
|
|
280
|
-
windowsHide: true,
|
|
281
|
-
stdio: "pipe",
|
|
282
|
-
});
|
|
283
|
-
// tasklist always exits 0, so match the output for the PID.
|
|
284
|
-
const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
|
|
285
|
-
encoding: "utf-8",
|
|
286
|
-
windowsHide: true,
|
|
287
|
-
stdio: "pipe",
|
|
288
|
-
});
|
|
289
|
-
if (out.includes(`"${status.pid}"`)) return true;
|
|
290
|
-
}
|
|
291
|
-
} catch { /* ignore */ }
|
|
292
|
-
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
getGuiEnv(): Record<string, string> {
|
|
297
|
-
return {};
|
|
298
|
-
}
|
|
299
|
-
}
|