akm-cli 0.9.0-rc.3 → 0.9.0-rc.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +6 -4
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/cli.js +25 -10
  8. package/dist/commands/health/html-report.js +23 -2
  9. package/dist/commands/health/improve-metrics.js +45 -6
  10. package/dist/commands/health/md-report.js +10 -1
  11. package/dist/commands/health/windows.js +1 -1
  12. package/dist/commands/health.js +1 -1
  13. package/dist/commands/tasks/default-tasks.js +5 -5
  14. package/dist/commands/tasks/tasks-cli.js +7 -3
  15. package/dist/commands/tasks/tasks.js +261 -68
  16. package/dist/core/improve-result.js +87 -7
  17. package/dist/core/state-db.js +1 -0
  18. package/dist/scripts/migrate-storage.js +21 -3
  19. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +89 -8
  20. package/dist/storage/repositories/task-history-repository.js +30 -3
  21. package/dist/tasks/backends/cron.js +71 -38
  22. package/dist/tasks/backends/exec-utils.js +55 -3
  23. package/dist/tasks/backends/launchd.js +241 -50
  24. package/dist/tasks/backends/schtasks.js +434 -59
  25. package/dist/tasks/command-executable.js +93 -0
  26. package/dist/tasks/parser.js +142 -6
  27. package/dist/tasks/resolve-akm-bin.js +19 -4
  28. package/dist/tasks/runner.js +258 -93
  29. package/dist/tasks/schedule.js +108 -19
  30. package/dist/tasks/scheduler-invocation.js +86 -0
  31. package/dist/tasks/task-id.js +37 -0
  32. package/docs/README.md +103 -0
  33. package/docs/migration/release-notes/0.9.0.md +4 -2
  34. package/docs/migration/v0.8-to-v0.9.md +64 -9
  35. package/package.json +2 -3
@@ -13,9 +13,8 @@
13
13
  * • `LogonType=InteractiveToken` means the task runs in the context of
14
14
  * the registering user only when they are logged in — there is no
15
15
  * stored password and the task will not fire at the lock screen.
16
- * • `<Principal>` deliberately omits `<UserId>`; per the Task Scheduler
17
- * 2.0 schema (`principalType.UserId` minOccurs=0) this is valid and
18
- * defaults to the registering user.
16
+ * • `<Principal>` records the current user SID so sync can detect identity
17
+ * drift instead of silently accepting a task registered to another user.
19
18
  * • `<DisallowStartIfOnBatteries>false</…>` and `<StopIfGoingOnBatteries>
20
19
  * false</…>` allow the task to run on battery — utility tasks would
21
20
  * otherwise be silently skipped on laptops.
@@ -29,6 +28,7 @@
29
28
  *
30
29
  * Tests inject a fake exec + filesystem.
31
30
  */
31
+ import { createHash } from "node:crypto";
32
32
  import fs from "node:fs";
33
33
  import os from "node:os";
34
34
  import path from "node:path";
@@ -37,33 +37,87 @@ import { ConfigError } from "../../core/errors.js";
37
37
  import { getTaskLogDir } from "../../core/paths.js";
38
38
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
39
39
  import { parseSchedule, translateToSchtasks } from "../schedule.js";
40
- import { escapeXml, nodeExec, nodeFs } from "./exec-utils.js";
40
+ import { buildScheduledTaskInvocation, resolveScheduledTaskContext, } from "../scheduler-invocation.js";
41
+ import { escapeXml, nodeExec, nodeFs, normalizeXmlForUtf16File } from "./exec-utils.js";
41
42
  export const DEFAULT_FOLDER_PREFIX = "\\akm\\";
43
+ const SIGNATURE_PREFIX = "akm:v1:";
42
44
  export function SCHTASKS_BACKEND(options = {}) {
43
45
  const exec = options.exec ?? defaultSchtasksExec();
44
46
  const fsLike = options.fs ?? defaultSchtasksFs();
45
47
  const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
46
48
  const logDir = options.logDir ?? getTaskLogDir();
47
49
  const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
50
+ const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
51
+ const userSid = options.userSid ?? resolveCurrentUserSid(exec);
48
52
  const taskName = (id) => `${folder}${id}`;
49
53
  return {
50
54
  name: "schtasks",
51
55
  install(task) {
56
+ const xml = normalizeXmlForUtf16File(buildSchtasksXml(task, akmArgv, logDir, { folderPrefix: folder, scheduledContext, userSid }));
57
+ const query = exec.run(["schtasks", "/Query", "/TN", taskName(task.id), "/XML"]);
58
+ let previous;
59
+ if (query.status === 0) {
60
+ const enabled = taskXmlEnabled(query.stdout);
61
+ if (enabled === undefined) {
62
+ throw new ConfigError(`schtasks /Query returned an unreadable definition for "${taskName(task.id)}"; refusing to replace it.`, "INVALID_CONFIG_FILE");
63
+ }
64
+ previous = { xml: normalizeXmlForUtf16File(query.stdout), enabled };
65
+ }
66
+ else if (!isMissingTaskResult(query)) {
67
+ throw new ConfigError(`schtasks /Query failed (exit ${query.status}): ${query.stderr || query.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
68
+ }
52
69
  fsLike.ensureDir(logDir);
53
- const xml = buildSchtasksXml(task, akmArgv, logDir, { folderPrefix: folder });
54
70
  const tmpFile = path.join(fsLike.tmpdir(), `akm-task-${task.id}-${Date.now()}.xml`);
55
71
  fsLike.writeFile(tmpFile, xml);
56
72
  try {
57
- // /F forces overwrite if a task with the same name exists.
58
- const r = exec.run(["schtasks", "/Create", "/TN", taskName(task.id), "/XML", tmpFile, "/F"]);
59
- if (r.status !== 0) {
60
- throw new ConfigError(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
73
+ try {
74
+ // /F forces overwrite if a task with the same name exists.
75
+ const r = exec.run(["schtasks", "/Create", "/TN", taskName(task.id), "/XML", tmpFile, "/F"]);
76
+ if (r.status !== 0) {
77
+ throw new ConfigError(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
78
+ }
79
+ if (!task.enabled) {
80
+ const dis = exec.run(["schtasks", "/Change", "/TN", taskName(task.id), "/DISABLE"]);
81
+ if (dis.status !== 0) {
82
+ throw new ConfigError(`schtasks /Change /DISABLE failed: ${dis.stderr || dis.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
83
+ }
84
+ }
61
85
  }
62
- if (!task.enabled) {
63
- const dis = exec.run(["schtasks", "/Change", "/TN", taskName(task.id), "/DISABLE"]);
64
- if (dis.status !== 0) {
65
- throw new ConfigError(`schtasks /Change /DISABLE failed: ${dis.stderr || dis.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
86
+ catch (err) {
87
+ const rollbackErrors = [];
88
+ if (previous === undefined) {
89
+ try {
90
+ const remove = exec.run(["schtasks", "/Delete", "/TN", taskName(task.id), "/F"]);
91
+ if (remove.status !== 0 && !isMissingTaskResult(remove)) {
92
+ rollbackErrors.push(new ConfigError(`schtasks /Delete during rollback failed: ${remove.stderr || remove.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
93
+ }
94
+ }
95
+ catch (rollbackError) {
96
+ rollbackErrors.push(rollbackError);
97
+ }
66
98
  }
99
+ else {
100
+ try {
101
+ fsLike.writeFile(tmpFile, previous.xml);
102
+ const restore = exec.run(["schtasks", "/Create", "/TN", taskName(task.id), "/XML", tmpFile, "/F"]);
103
+ if (restore.status !== 0) {
104
+ throw new ConfigError(`schtasks /Create during rollback failed: ${restore.stderr || restore.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
105
+ }
106
+ const stateFlag = previous.enabled ? "/ENABLE" : "/DISABLE";
107
+ const state = exec.run(["schtasks", "/Change", "/TN", taskName(task.id), stateFlag]);
108
+ if (state.status !== 0) {
109
+ throw new ConfigError(`schtasks /Change ${stateFlag} during rollback failed: ${state.stderr || state.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
110
+ }
111
+ }
112
+ catch (rollbackError) {
113
+ rollbackErrors.push(rollbackError);
114
+ }
115
+ }
116
+ if (rollbackErrors.length > 0) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for Task Scheduler task "${task.id}" was incomplete.`);
119
+ }
120
+ throw err;
67
121
  }
68
122
  }
69
123
  finally {
@@ -85,8 +139,9 @@ export function SCHTASKS_BACKEND(options = {}) {
85
139
  },
86
140
  list() {
87
141
  const r = exec.run(["schtasks", "/Query", "/FO", "CSV", "/NH"]);
88
- if (r.status !== 0)
89
- return [];
142
+ if (r.status !== 0) {
143
+ throw new ConfigError(`schtasks /Query failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
144
+ }
90
145
  const ids = [];
91
146
  for (const line of (r.stdout ?? "").split(/\r?\n/)) {
92
147
  const m = line.match(/^"([^"]+)",/);
@@ -97,58 +152,106 @@ export function SCHTASKS_BACKEND(options = {}) {
97
152
  ids.push(name.slice(folder.length));
98
153
  }
99
154
  }
100
- return ids.map((id) => ({ id }));
155
+ return ids.map((id) => {
156
+ const query = exec.run(["schtasks", "/Query", "/TN", taskName(id), "/XML"]);
157
+ if (query.status !== 0) {
158
+ throw new ConfigError(`schtasks /Query /XML for "${taskName(id)}" failed (exit ${query.status}): ${query.stderr || query.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
159
+ }
160
+ const signature = installedSignature(query.stdout);
161
+ return signature === undefined ? { id } : { id, signature };
162
+ });
163
+ },
164
+ expectedSignature(task) {
165
+ const signature = taskXmlSignature(buildSchtasksXml(task, akmArgv, logDir, { folderPrefix: folder, scheduledContext, userSid }));
166
+ if (signature === undefined)
167
+ throw new Error("Failed to fingerprint generated Task Scheduler XML.");
168
+ return signature;
101
169
  },
102
170
  };
103
171
  }
104
- export function buildSchtasksXml(task, akmArgv, logDir, options = {}) {
172
+ export function buildSchtasksXml(task, akmArgv, logDir, options) {
105
173
  const folder = options.folderPrefix ?? DEFAULT_FOLDER_PREFIX;
106
174
  const now = options.now ? options.now() : new Date();
107
- const startBoundary = formatStartBoundary(now);
108
- const spec = parseSchedule(task.schedule, "schtasks");
109
- const trigger = translateToSchtasks(spec);
110
- const command = akmArgv[0];
111
- const args = [...akmArgv.slice(1), "tasks", "run", task.id].map((a) => quoteArg(a)).join(" ");
112
- const triggerXml = renderSchtasksTrigger(trigger, startBoundary);
113
- const logPath = path.join(logDir, `${task.id}.log`);
175
+ const definition = buildSchtasksDefinition(task, akmArgv, logDir, folder, options.scheduledContext, options.userSid);
176
+ const triggerXml = renderSchtasksTrigger(definition.trigger, now);
114
177
  return schtasksTemplate
115
178
  .replaceAll("{{TASK_ID}}", escapeXml(task.id))
116
179
  .replaceAll("{{FOLDER}}", escapeXml(folder))
180
+ .replace("{{SIGNATURE}}", definition.signature)
117
181
  .replace("{{TRIGGER_XML}}", triggerXml)
182
+ .replace('<Principal id="Author">', `<Principal id="Author">\n <UserId>${escapeXml(options.userSid)}</UserId>`)
118
183
  .replace("{{ENABLED}}", task.enabled ? "true" : "false")
119
- .replace("{{COMMAND}}", escapeXml(command))
120
- .replace("{{ARGS}}", escapeXml(args))
121
- .replace("{{LOG_PATH}}", escapeXml(logPath));
184
+ .replace("{{COMMAND}}", escapeXml(definition.command))
185
+ .replace("{{ARGS}}", escapeXml(definition.args))
186
+ .replace("{{LOG_PATH}}", escapeXml(definition.logPath));
187
+ }
188
+ function buildSchtasksDefinition(task, akmArgv, logDir, folder, scheduledContext, userSid) {
189
+ const spec = parseSchedule(task.schedule, "schtasks");
190
+ const trigger = translateToSchtasks(spec);
191
+ const invocation = buildScheduledTaskInvocation(akmArgv, task.id, scheduledContext);
192
+ const environment = Object.entries(invocation.environment)
193
+ .map(([key, value]) => `$env:${key}=${quotePowerShell(value)}`)
194
+ .join("; ");
195
+ const invoke = `& ${invocation.argv.map((arg) => quotePowerShell(arg)).join(" ")}`;
196
+ const script = `${environment}; ${invoke}; exit $LASTEXITCODE`;
197
+ const command = "powershell.exe";
198
+ const args = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script].map(quoteArg).join(" ");
199
+ const logPath = path.join(logDir, `${task.id}.log`);
200
+ // The boundary changes on reinstall, and enabled state can change via /Change.
201
+ // Keep both outside the stored definition fingerprint so no-op sync stays stable.
202
+ const fingerprint = createHash("sha256")
203
+ .update(JSON.stringify({ folder, id: task.id, trigger, command, args, logPath, userSid }))
204
+ .digest("hex");
205
+ return { trigger, command, args, logPath, signature: `${SIGNATURE_PREFIX}${fingerprint}` };
206
+ }
207
+ function renderSchtasksTrigger(trigger, now) {
208
+ return expandNativeTriggers(trigger)
209
+ .map((native) => renderNativeTrigger(native, formatStartBoundary(nextStartBoundary(native, now))))
210
+ .join("\n");
122
211
  }
123
- function renderSchtasksTrigger(trigger, startBoundary) {
212
+ function expandNativeTriggers(trigger) {
124
213
  switch (trigger.kind) {
125
214
  case "minute":
126
- return ` <TimeTrigger>
127
- <Repetition>
128
- <Interval>PT${trigger.everyMinutes}M</Interval>
129
- </Repetition>
130
- <StartBoundary>${startBoundary}</StartBoundary>
131
- <Enabled>true</Enabled>
132
- </TimeTrigger>`;
215
+ return [{ kind: "daily", atHour: 0, atMinute: 0, repeatEveryMinutes: trigger.everyMinutes }];
216
+ case "minuteValues":
217
+ return trigger.minutes.map((atMinute) => ({ kind: "daily", atHour: 0, atMinute, repeatEveryMinutes: 60 }));
133
218
  case "hour":
134
- return ` <TimeTrigger>
135
- <Repetition>
136
- <Interval>PT${trigger.everyHours}H</Interval>
137
- </Repetition>
138
- <StartBoundary>${startBoundary}</StartBoundary>
139
- <Enabled>true</Enabled>
140
- </TimeTrigger>`;
219
+ return [
220
+ {
221
+ kind: "daily",
222
+ atHour: 0,
223
+ atMinute: trigger.atMinute,
224
+ repeatEveryMinutes: trigger.everyHours * 60,
225
+ },
226
+ ];
227
+ case "hourValues":
228
+ return trigger.hours.map((atHour) => ({ kind: "daily", atHour, atMinute: trigger.atMinute }));
141
229
  case "daily":
142
- return ` <CalendarTrigger>
143
- <StartBoundary>${pad(startBoundary, trigger.atHour, trigger.atMinute)}</StartBoundary>
230
+ return [{ kind: "daily", atHour: trigger.atHour, atMinute: trigger.atMinute }];
231
+ case "weekly":
232
+ return [trigger];
233
+ }
234
+ }
235
+ function renderNativeTrigger(trigger, startBoundary) {
236
+ if (trigger.kind === "daily") {
237
+ const repetition = trigger.repeatEveryMinutes === undefined
238
+ ? ""
239
+ : ` <Repetition>
240
+ <Interval>${formatRepetitionInterval(trigger.repeatEveryMinutes)}</Interval>
241
+ <Duration>${formatRepetitionDuration(trigger.repeatEveryMinutes)}</Duration>
242
+ <StopAtDurationEnd>false</StopAtDurationEnd>
243
+ </Repetition>
244
+ `;
245
+ return ` <CalendarTrigger>
246
+ ${repetition} <StartBoundary>${startBoundary}</StartBoundary>
144
247
  <Enabled>true</Enabled>
145
248
  <ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
146
249
  </CalendarTrigger>`;
147
- case "weekly": {
148
- const dayMap = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
149
- const days = trigger.daysOfWeek.map((d) => ` <${dayMap[d]} />`).join("\n");
150
- return ` <CalendarTrigger>
151
- <StartBoundary>${pad(startBoundary, trigger.atHour, trigger.atMinute)}</StartBoundary>
250
+ }
251
+ const dayMap = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
252
+ const days = trigger.daysOfWeek.map((d) => ` <${dayMap[d]} />`).join("\n");
253
+ return ` <CalendarTrigger>
254
+ <StartBoundary>${startBoundary}</StartBoundary>
152
255
  <Enabled>true</Enabled>
153
256
  <ScheduleByWeek>
154
257
  <DaysOfWeek>
@@ -157,16 +260,42 @@ ${days}
157
260
  <WeeksInterval>1</WeeksInterval>
158
261
  </ScheduleByWeek>
159
262
  </CalendarTrigger>`;
160
- }
161
- }
162
263
  }
163
- function pad(base, hour, minute) {
164
- // Rewrite the time component of an ISO-8601 boundary while preserving
165
- // the date so daily/weekly triggers fire at the configured wall-clock
166
- // time rather than the install instant.
167
- const hh = String(hour).padStart(2, "0");
168
- const mm = String(minute).padStart(2, "0");
169
- return base.replace(/T\d\d:\d\d:\d\d$/, `T${hh}:${mm}:00`);
264
+ function formatRepetitionInterval(minutes) {
265
+ return formatMinuteDuration(minutes);
266
+ }
267
+ function formatRepetitionDuration(intervalMinutes) {
268
+ return formatMinuteDuration(24 * 60 - intervalMinutes);
269
+ }
270
+ function formatMinuteDuration(minutes) {
271
+ const hours = Math.floor(minutes / 60);
272
+ const remainingMinutes = minutes % 60;
273
+ const hourPart = hours > 0 ? `${hours}H` : "";
274
+ const minutePart = remainingMinutes > 0 ? `${remainingMinutes}M` : "";
275
+ return `PT${hourPart}${minutePart}`;
276
+ }
277
+ function nextStartBoundary(trigger, now) {
278
+ const boundary = new Date(now.getTime());
279
+ switch (trigger.kind) {
280
+ case "daily":
281
+ boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
282
+ if (trigger.repeatEveryMinutes !== undefined) {
283
+ while (boundary.getTime() <= now.getTime()) {
284
+ boundary.setMinutes(boundary.getMinutes() + trigger.repeatEveryMinutes);
285
+ }
286
+ }
287
+ else if (boundary.getTime() <= now.getTime()) {
288
+ boundary.setDate(boundary.getDate() + 1);
289
+ }
290
+ return boundary;
291
+ case "weekly":
292
+ boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
293
+ while (!trigger.daysOfWeek.includes(boundary.getDay()) || boundary.getTime() <= now.getTime()) {
294
+ boundary.setDate(boundary.getDate() + 1);
295
+ boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
296
+ }
297
+ return boundary;
298
+ }
170
299
  }
171
300
  function formatStartBoundary(d) {
172
301
  // Local-time ISO-8601 (no zone suffix) — Task Scheduler interprets a
@@ -183,7 +312,250 @@ function formatStartBoundary(d) {
183
312
  function quoteArg(s) {
184
313
  if (/^[A-Za-z0-9_\-./@:%=+,\\]+$/.test(s))
185
314
  return s;
186
- return `"${s.replace(/"/g, '\\"')}"`;
315
+ // CommandLineToArgvW requires backslashes before quotes (including the
316
+ // closing quote) to be doubled so they survive as literal backslashes.
317
+ return `"${s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
318
+ }
319
+ function quotePowerShell(value) {
320
+ return `'${value.replaceAll("'", "''")}'`;
321
+ }
322
+ function installedSignature(xml) {
323
+ return taskXmlSignature(xml);
324
+ }
325
+ function isMissingTaskResult(result) {
326
+ return /cannot find|not found/i.test(`${result.stderr ?? ""}\n${result.stdout ?? ""}`);
327
+ }
328
+ function taskXmlSignature(xml) {
329
+ try {
330
+ const document = parseXml(xml);
331
+ const task = findChild(document, "Task");
332
+ const triggers = findChild(task, "Triggers");
333
+ const principals = findChild(task, "Principals");
334
+ const settings = findChild(task, "Settings");
335
+ const actions = findChild(task, "Actions");
336
+ if (!triggers || !principals || !settings || !actions)
337
+ return undefined;
338
+ normalizeTriggerBoundaries(triggers);
339
+ normalizeNativeDefaults(triggers, principals, settings);
340
+ const enabledElement = findChild(settings, "Enabled");
341
+ const enabledValue = enabledElement ? elementText(enabledElement).toLowerCase() : undefined;
342
+ const enabled = enabledValue === undefined || enabledValue === "true" || enabledValue === "1";
343
+ // Enabled is represented explicitly in the signature suffix. Removing it
344
+ // here also treats an omitted Enabled element as its schema default, true.
345
+ settings.children = settings.children.filter((child) => typeof child === "string" || child.name.toLowerCase() !== "enabled");
346
+ const canonical = [triggers, principals, settings, actions].map(canonicalXmlElement).join("\n");
347
+ const fingerprint = createHash("sha256").update(canonical).digest("hex");
348
+ return signatureWithEnabled(`${SIGNATURE_PREFIX}${fingerprint}`, enabled);
349
+ }
350
+ catch {
351
+ return undefined;
352
+ }
353
+ }
354
+ function taskXmlEnabled(xml) {
355
+ try {
356
+ const document = parseXml(xml);
357
+ const settings = findChild(findChild(document, "Task"), "Settings");
358
+ if (!settings)
359
+ return undefined;
360
+ const enabledElement = findChild(settings, "Enabled");
361
+ if (!enabledElement)
362
+ return true;
363
+ const enabled = elementText(enabledElement).toLowerCase();
364
+ if (enabled === "true" || enabled === "1")
365
+ return true;
366
+ if (enabled === "false" || enabled === "0")
367
+ return false;
368
+ return undefined;
369
+ }
370
+ catch {
371
+ return undefined;
372
+ }
373
+ }
374
+ function parseXml(xml) {
375
+ const document = { name: "#document", attributes: {}, children: [] };
376
+ const stack = [document];
377
+ const tokens = xml.match(/<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<[^>]+>|[^<]+/g) ?? [];
378
+ for (const token of tokens) {
379
+ if (token.startsWith("<!--") || token.startsWith("<?"))
380
+ continue;
381
+ if (token.startsWith("</")) {
382
+ if (stack.length === 1)
383
+ throw new Error("Unexpected XML closing tag.");
384
+ const closingName = localXmlName(token.slice(2, -1).trim());
385
+ const current = stack.pop();
386
+ if (current?.name !== closingName)
387
+ throw new Error("Mismatched XML closing tag.");
388
+ continue;
389
+ }
390
+ if (token.startsWith("<")) {
391
+ const selfClosing = /\/\s*>$/.test(token);
392
+ const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/);
393
+ if (!match)
394
+ throw new Error("Invalid XML opening tag.");
395
+ const element = {
396
+ name: localXmlName(match[1]),
397
+ attributes: parseXmlAttributes(match[2]),
398
+ children: [],
399
+ };
400
+ stack[stack.length - 1].children.push(element);
401
+ if (!selfClosing)
402
+ stack.push(element);
403
+ continue;
404
+ }
405
+ const text = decodeXml(token.trim());
406
+ if (text.length > 0)
407
+ stack[stack.length - 1].children.push(text);
408
+ }
409
+ if (stack.length !== 1)
410
+ throw new Error("Unclosed XML tag.");
411
+ return document;
412
+ }
413
+ function parseXmlAttributes(raw) {
414
+ const attributes = {};
415
+ const pattern = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
416
+ for (const match of raw.matchAll(pattern)) {
417
+ const name = localXmlName(match[1]);
418
+ if (match[1] === "xmlns" || match[1].startsWith("xmlns:"))
419
+ continue;
420
+ attributes[name] = decodeXml(match[2] ?? match[3] ?? "");
421
+ }
422
+ return attributes;
423
+ }
424
+ function localXmlName(name) {
425
+ return name.slice(name.lastIndexOf(":") + 1);
426
+ }
427
+ function decodeXml(value) {
428
+ return value.replace(/&(?:#(\d+)|#x([\da-f]+)|amp|lt|gt|quot|apos);/gi, (entity, decimal, hex) => {
429
+ if (decimal !== undefined)
430
+ return String.fromCodePoint(Number(decimal));
431
+ if (hex !== undefined)
432
+ return String.fromCodePoint(Number.parseInt(hex, 16));
433
+ switch (entity.toLowerCase()) {
434
+ case "&amp;":
435
+ return "&";
436
+ case "&lt;":
437
+ return "<";
438
+ case "&gt;":
439
+ return ">";
440
+ case "&quot;":
441
+ return '"';
442
+ case "&apos;":
443
+ return "'";
444
+ default:
445
+ return entity;
446
+ }
447
+ });
448
+ }
449
+ function findChild(parent, name) {
450
+ return parent?.children.find((child) => typeof child !== "string" && child.name.toLowerCase() === name.toLowerCase());
451
+ }
452
+ function elementText(element) {
453
+ return element.children.filter((child) => typeof child === "string").join("");
454
+ }
455
+ function normalizeTriggerBoundaries(triggers) {
456
+ for (const trigger of triggers.children) {
457
+ if (typeof trigger === "string")
458
+ continue;
459
+ const boundary = findChild(trigger, "StartBoundary");
460
+ if (!boundary)
461
+ continue;
462
+ const time = elementText(boundary).match(/T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?/);
463
+ if (!time)
464
+ continue;
465
+ const secondsSinceMidnight = Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3]);
466
+ const interval = findChild(findChild(trigger, "Repetition"), "Interval");
467
+ const intervalSeconds = interval ? parseIsoDurationSeconds(elementText(interval)) : undefined;
468
+ boundary.children = [
469
+ intervalSeconds === undefined
470
+ ? `dynamic-date;time=${time[1]}:${time[2]}:${time[3]}`
471
+ : `dynamic-cycle;phase-seconds=${secondsSinceMidnight % intervalSeconds}`,
472
+ ];
473
+ }
474
+ }
475
+ const MATERIALIZED_SETTING_DEFAULTS = {
476
+ allowstartondemand: "true",
477
+ allowhardterminate: "true",
478
+ startwhenavailable: "false",
479
+ runonlyifnetworkavailable: "false",
480
+ hidden: "false",
481
+ runonlyifidle: "false",
482
+ waketorun: "false",
483
+ executiontimelimit: "PT72H",
484
+ priority: "7",
485
+ compatibility: "Vista",
486
+ useunifiedschedulingengine: "true",
487
+ disallowstartonremoteappsession: "false",
488
+ volatile: "false",
489
+ };
490
+ const MATERIALIZED_IDLE_DEFAULTS = {
491
+ duration: "PT10M",
492
+ waittimeout: "PT1H",
493
+ stoponidleend: "true",
494
+ restartonidle: "false",
495
+ };
496
+ function normalizeNativeDefaults(triggers, principals, settings) {
497
+ for (const principal of elementChildren(principals)) {
498
+ principal.children = principal.children.filter((child) => {
499
+ if (typeof child === "string")
500
+ return true;
501
+ return child.name.toLowerCase() !== "runlevel" || elementText(child).toLowerCase() !== "leastprivilege";
502
+ });
503
+ }
504
+ settings.children = settings.children.filter((child) => {
505
+ if (typeof child === "string")
506
+ return true;
507
+ const name = child.name.toLowerCase();
508
+ if (name === "idlesettings")
509
+ return !containsOnlyDefaults(child, MATERIALIZED_IDLE_DEFAULTS);
510
+ const defaultValue = MATERIALIZED_SETTING_DEFAULTS[name];
511
+ return defaultValue === undefined || elementText(child) !== defaultValue;
512
+ });
513
+ for (const trigger of elementChildren(triggers)) {
514
+ trigger.children = trigger.children.filter((child) => {
515
+ if (typeof child === "string")
516
+ return true;
517
+ const name = child.name.toLowerCase();
518
+ if (name === "enabled")
519
+ return elementText(child).toLowerCase() !== "true";
520
+ return name !== "executiontimelimit" || elementText(child) !== "PT72H";
521
+ });
522
+ }
523
+ }
524
+ function resolveCurrentUserSid(exec) {
525
+ const result = exec.run(["whoami", "/user", "/fo", "csv", "/nh"]);
526
+ if (result.status !== 0) {
527
+ throw new ConfigError(`whoami /user failed (exit ${result.status}): ${result.stderr || result.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
528
+ }
529
+ const match = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.match(/\bS-\d+(?:-\d+){2,}\b/i);
530
+ if (!match) {
531
+ throw new ConfigError("whoami /user returned no Windows user SID.", "INVALID_CONFIG_FILE");
532
+ }
533
+ return `S${match[0].slice(1)}`;
534
+ }
535
+ function elementChildren(element) {
536
+ return element.children.filter((child) => typeof child !== "string");
537
+ }
538
+ function containsOnlyDefaults(element, defaults) {
539
+ const children = elementChildren(element);
540
+ return (children.length > 0 &&
541
+ children.length === element.children.length &&
542
+ children.every((child) => defaults[child.name.toLowerCase()] === elementText(child)));
543
+ }
544
+ function parseIsoDurationSeconds(value) {
545
+ const match = value.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i);
546
+ if (!match)
547
+ return undefined;
548
+ const seconds = Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0);
549
+ return seconds > 0 ? seconds : undefined;
550
+ }
551
+ function canonicalXmlElement(element) {
552
+ const attributes = Object.entries(element.attributes).sort(([a], [b]) => a.localeCompare(b));
553
+ const children = element.children.map((child) => typeof child === "string" ? ["text", child] : ["element", canonicalXmlElement(child)]);
554
+ children.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
555
+ return JSON.stringify([element.name, attributes, children]);
556
+ }
557
+ function signatureWithEnabled(signature, enabled) {
558
+ return `${signature}|enabled=${enabled ? "true" : "false"}`;
187
559
  }
188
560
  function defaultSchtasksExec() {
189
561
  return nodeExec();
@@ -191,6 +563,9 @@ function defaultSchtasksExec() {
191
563
  function defaultSchtasksFs() {
192
564
  return {
193
565
  ...nodeFs(),
566
+ writeFile(file, content) {
567
+ fs.writeFileSync(file, `\uFEFF${content}`, { encoding: "utf16le" });
568
+ },
194
569
  removeFile(file) {
195
570
  try {
196
571
  fs.rmSync(file, { force: true });