palmier 0.8.3 → 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/README.md +6 -4
- package/dist/commands/pair.js +2 -0
- package/dist/commands/serve.js +0 -4
- package/dist/platform/index.js +7 -3
- package/dist/platform/macos.d.ts +32 -0
- package/dist/platform/macos.js +287 -0
- package/dist/pwa/assets/index-499vYQvR.js +120 -0
- package/dist/pwa/assets/{index-B0F9mtid.css → index-UaZFu6XL.css} +1 -1
- package/dist/pwa/assets/{web-Z1623me-.js → web-Bp48ONY3.js} +1 -1
- package/dist/pwa/assets/{web-C6lkQj9J.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.js +0 -4
- package/dist/transports/http-transport.js +1 -0
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +191 -33
- 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 +15 -312
- package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
- package/palmier-server/pwa/src/components/SessionsView.tsx +3 -1
- package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
- package/palmier-server/pwa/src/components/TaskForm.tsx +126 -74
- package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
- package/palmier-server/pwa/src/native/Device.ts +0 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +2 -0
- package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
- package/src/commands/pair.ts +2 -0
- package/src/commands/serve.ts +0 -3
- package/src/platform/index.ts +4 -3
- package/src/platform/macos.ts +310 -0
- package/src/rpc-handler.ts +0 -5
- package/src/transports/http-transport.ts +1 -0
- package/test/macos-plist.test.ts +112 -0
- package/dist/app-registry.d.ts +0 -10
- package/dist/app-registry.js +0 -44
- package/dist/pwa/assets/index-SYs3mcdJ.js +0 -120
- package/src/app-registry.ts +0 -52
|
@@ -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/rpc-handler.ts
CHANGED
|
@@ -12,7 +12,6 @@ import { getAgent } from "./agents/agent.js";
|
|
|
12
12
|
import { validateClient } from "./client-store.js";
|
|
13
13
|
import { publishHostEvent } from "./events.js";
|
|
14
14
|
import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice, type DeviceCapability } from "./device-capabilities.js";
|
|
15
|
-
import { listApps } from "./app-registry.js";
|
|
16
15
|
import { currentVersion, performUpdate } from "./update-checker.js";
|
|
17
16
|
import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
|
|
18
17
|
import { clearTaskQueue } from "./event-queues.js";
|
|
@@ -662,10 +661,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
662
661
|
return { ok: true };
|
|
663
662
|
}
|
|
664
663
|
|
|
665
|
-
case "device.notifications.apps": {
|
|
666
|
-
return { apps: listApps() };
|
|
667
|
-
}
|
|
668
|
-
|
|
669
664
|
default:
|
|
670
665
|
return { error: `Unknown method: ${request.method}` };
|
|
671
666
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
cronToCalendarInterval,
|
|
5
|
+
specificTimeToCalendarInterval,
|
|
6
|
+
buildPlist,
|
|
7
|
+
} from "../src/platform/macos.js";
|
|
8
|
+
|
|
9
|
+
describe("cronToCalendarInterval", () => {
|
|
10
|
+
it("converts hourly cron", () => {
|
|
11
|
+
assert.deepEqual(cronToCalendarInterval("0 * * * *"), { Minute: 0 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("converts daily cron", () => {
|
|
15
|
+
assert.deepEqual(cronToCalendarInterval("30 9 * * *"), { Minute: 30, Hour: 9 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("converts weekly Monday cron", () => {
|
|
19
|
+
assert.deepEqual(cronToCalendarInterval("0 10 * * 1"), { Minute: 0, Hour: 10, Weekday: 1 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("converts weekly Sunday (day 0)", () => {
|
|
23
|
+
assert.deepEqual(cronToCalendarInterval("0 8 * * 0"), { Minute: 0, Hour: 8, Weekday: 0 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("converts weekly Sunday (day 7 -> 0)", () => {
|
|
27
|
+
assert.deepEqual(cronToCalendarInterval("0 8 * * 7"), { Minute: 0, Hour: 8, Weekday: 0 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("converts monthly cron", () => {
|
|
31
|
+
assert.deepEqual(cronToCalendarInterval("0 14 15 * *"), { Minute: 0, Hour: 14, Day: 15 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("throws on invalid cron expression", () => {
|
|
35
|
+
assert.throws(() => cronToCalendarInterval("bad"), /Invalid cron/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws on too few fields", () => {
|
|
39
|
+
assert.throws(() => cronToCalendarInterval("0 * *"), /Invalid cron/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("specificTimeToCalendarInterval", () => {
|
|
44
|
+
it("parses an ISO local datetime", () => {
|
|
45
|
+
assert.deepEqual(
|
|
46
|
+
specificTimeToCalendarInterval("2026-04-20T09:00"),
|
|
47
|
+
{ Month: 4, Day: 20, Hour: 9, Minute: 0 },
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("parses an ISO datetime with seconds", () => {
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
specificTimeToCalendarInterval("2026-12-31T23:59:30"),
|
|
54
|
+
{ Month: 12, Day: 31, Hour: 23, Minute: 59 },
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws on malformed input", () => {
|
|
59
|
+
assert.throws(() => specificTimeToCalendarInterval("not-a-date"), /Invalid specific_times/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("buildPlist", () => {
|
|
64
|
+
it("emits a valid plist envelope with ProgramArguments", () => {
|
|
65
|
+
const xml = buildPlist({
|
|
66
|
+
Label: "me.palmier.host",
|
|
67
|
+
ProgramArguments: ["/usr/local/bin/node", "/opt/palmier/dist/index.js", "serve"],
|
|
68
|
+
RunAtLoad: true,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.ok(xml.startsWith(`<?xml version="1.0" encoding="UTF-8"?>`), "xml header");
|
|
72
|
+
assert.ok(xml.includes(`<!DOCTYPE plist PUBLIC`), "doctype");
|
|
73
|
+
assert.ok(xml.includes(`<plist version="1.0">`), "plist tag");
|
|
74
|
+
assert.ok(xml.includes(`<key>Label</key>`), "label key");
|
|
75
|
+
assert.ok(xml.includes(`<string>me.palmier.host</string>`), "label value");
|
|
76
|
+
assert.ok(xml.includes(`<key>ProgramArguments</key>`), "program args key");
|
|
77
|
+
assert.ok(xml.includes(`<string>/usr/local/bin/node</string>`), "program args first");
|
|
78
|
+
assert.ok(xml.includes(`<true/>`), "boolean");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("serializes StartCalendarInterval as an array of dicts", () => {
|
|
82
|
+
const xml = buildPlist({
|
|
83
|
+
Label: "me.palmier.task.abc",
|
|
84
|
+
StartCalendarInterval: [
|
|
85
|
+
{ Minute: 0, Hour: 9 },
|
|
86
|
+
{ Minute: 30, Hour: 14, Weekday: 1 },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.ok(xml.includes(`<key>StartCalendarInterval</key>`));
|
|
91
|
+
assert.ok(xml.includes(`<array>`));
|
|
92
|
+
assert.ok(xml.includes(`<key>Minute</key>`));
|
|
93
|
+
assert.ok(xml.includes(`<integer>0</integer>`));
|
|
94
|
+
assert.ok(xml.includes(`<integer>9</integer>`));
|
|
95
|
+
assert.ok(xml.includes(`<key>Weekday</key>`));
|
|
96
|
+
assert.ok(xml.includes(`<integer>1</integer>`));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("nests dicts (EnvironmentVariables.PATH)", () => {
|
|
100
|
+
const xml = buildPlist({
|
|
101
|
+
EnvironmentVariables: { PATH: "/usr/local/bin:/usr/bin:/bin" },
|
|
102
|
+
});
|
|
103
|
+
assert.ok(xml.includes(`<key>EnvironmentVariables</key>`));
|
|
104
|
+
assert.ok(xml.includes(`<key>PATH</key>`));
|
|
105
|
+
assert.ok(xml.includes(`<string>/usr/local/bin:/usr/bin:/bin</string>`));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("escapes XML special characters in strings", () => {
|
|
109
|
+
const xml = buildPlist({ Label: "a & b <c>" });
|
|
110
|
+
assert.ok(xml.includes(`<string>a & b <c></string>`));
|
|
111
|
+
});
|
|
112
|
+
});
|
package/dist/app-registry.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/** Persistent cache of packageName → appName pairs seen via device notifications. */
|
|
2
|
-
export interface AppInfo {
|
|
3
|
-
packageName: string;
|
|
4
|
-
appName: string;
|
|
5
|
-
}
|
|
6
|
-
/** Writes only on change so we track the latest label if an app renames itself. */
|
|
7
|
-
export declare function recordApp(packageName: string, appName: string): void;
|
|
8
|
-
export declare function listApps(): AppInfo[];
|
|
9
|
-
export declare function getAppName(packageName: string): string | undefined;
|
|
10
|
-
//# sourceMappingURL=app-registry.d.ts.map
|
package/dist/app-registry.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import { CONFIG_DIR } from "./config.js";
|
|
4
|
-
const REGISTRY_FILE = path.join(CONFIG_DIR, "app-registry.json");
|
|
5
|
-
let cache = null;
|
|
6
|
-
function load() {
|
|
7
|
-
if (cache)
|
|
8
|
-
return cache;
|
|
9
|
-
try {
|
|
10
|
-
if (fs.existsSync(REGISTRY_FILE)) {
|
|
11
|
-
cache = JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf-8"));
|
|
12
|
-
return cache;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
// Corrupt file — start fresh rather than fail notifications.
|
|
17
|
-
}
|
|
18
|
-
cache = {};
|
|
19
|
-
return cache;
|
|
20
|
-
}
|
|
21
|
-
function persist(map) {
|
|
22
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
23
|
-
fs.writeFileSync(REGISTRY_FILE, JSON.stringify(map, null, 2), "utf-8");
|
|
24
|
-
}
|
|
25
|
-
/** Writes only on change so we track the latest label if an app renames itself. */
|
|
26
|
-
export function recordApp(packageName, appName) {
|
|
27
|
-
if (!packageName || !appName)
|
|
28
|
-
return;
|
|
29
|
-
const map = load();
|
|
30
|
-
if (map[packageName] === appName)
|
|
31
|
-
return;
|
|
32
|
-
map[packageName] = appName;
|
|
33
|
-
persist(map);
|
|
34
|
-
}
|
|
35
|
-
export function listApps() {
|
|
36
|
-
const map = load();
|
|
37
|
-
return Object.entries(map)
|
|
38
|
-
.map(([packageName, appName]) => ({ packageName, appName }))
|
|
39
|
-
.sort((a, b) => a.appName.localeCompare(b.appName));
|
|
40
|
-
}
|
|
41
|
-
export function getAppName(packageName) {
|
|
42
|
-
return load()[packageName];
|
|
43
|
-
}
|
|
44
|
-
//# sourceMappingURL=app-registry.js.map
|