@adecore/service 0.29.1 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @adecore/service
2
2
 
3
- Definitions and managers for macOS launchd GUI jobs and Linux systemd user services, with everything they touch injected. The host supplies the identity, paths, environment, commands and files. Deciding who may install a service and which executable it runs stays with the host.
3
+ Definitions and managers for macOS launchd GUI jobs, Linux systemd user services and Windows scheduled tasks at logon, with everything they touch injected. The host supplies the identity, paths, environment, commands and files. Deciding who may install a service and which executable it runs stays with the host.
4
4
 
5
5
  ```sh
6
6
  bun add @adecore/service
@@ -0,0 +1,2 @@
1
+ export declare function cmdEscaped(text: string): string;
2
+ export declare function scheduledTaskMarker(label: string): string;
@@ -0,0 +1,12 @@
1
+ // What cmd.exe reads as syntax outside quotes, and so escapes with a caret.
2
+ const CMD_SPECIAL = /([()\][%!^"`<>&|;, *?])/g;
3
+ export function cmdEscaped(text) {
4
+ return text.replace(CMD_SPECIAL, '^$1');
5
+ }
6
+ /*
7
+ * What a manager finds the cmd.exe of a task by: `schtasks /End` ends that process alone, so a stop
8
+ * has to end the tree under it first. Its title is the label, which is the name of the task.
9
+ */
10
+ export function scheduledTaskMarker(label) {
11
+ return `title ${cmdEscaped(label)} && `;
12
+ }
@@ -6,7 +6,9 @@ export interface ServiceSpec {
6
6
  environment: Record<string, string>;
7
7
  workingDirectory: string;
8
8
  logFile: string;
9
+ user?: string;
9
10
  }
10
11
  export declare function launchAgentPlist(spec: ServiceSpec): string;
11
12
  export declare function systemdUnit(spec: ServiceSpec): string;
13
+ export declare function scheduledTask(spec: ServiceSpec): string;
12
14
  export declare function definitionRunsProgram(definition: string, program: string): boolean;
@@ -1,6 +1,10 @@
1
+ import { cmdEscaped, scheduledTaskMarker } from './cmd-line.js';
1
2
  function xmlText(value) {
2
3
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3
4
  }
5
+ function xmlValue(text) {
6
+ return text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
7
+ }
4
8
  function plistString(value, indent) {
5
9
  return `${indent}<string>${xmlText(value)}</string>`;
6
10
  }
@@ -59,6 +63,96 @@ export function systemdUnit(spec) {
59
63
  ];
60
64
  return lines.join('\n');
61
65
  }
66
+ /* One argument as the C runtime splits a command line, escaped so cmd.exe passes it on as it is. */
67
+ function cmdArgument(arg) {
68
+ if (/[\r\n]/.test(arg)) {
69
+ throw new Error('A scheduled task cannot pass on a line break');
70
+ }
71
+ return cmdEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
72
+ }
73
+ /* The line cmd.exe runs: its title, the environment, the program and the log it appends to. */
74
+ function taskCommandLine(spec) {
75
+ const variables = Object.entries(spec.environment).map(([name, value]) => `set ${cmdEscaped(`"${name}=${value}"`)}`);
76
+ const program = [cmdEscaped(spec.program), ...spec.args.map(cmdArgument)].join(' ');
77
+ return `${scheduledTaskMarker(spec.label)}${[...variables, `${program} >>${cmdEscaped(spec.logFile)} 2>&1`].join(' && ')}`;
78
+ }
79
+ /*
80
+ * A scheduled task for one person, started at their logon in their own session without a stored
81
+ * password, and run again every minute so it comes back after a crash: a second instance is ignored
82
+ * while one runs. RestartOnFailure is left out, since it also starts a task a stop ended. A task carries no environment
83
+ * of its own, so cmd.exe sets it and appends the output to the log.
84
+ */
85
+ export function scheduledTask(spec) {
86
+ if (spec.user === undefined || spec.user === '') {
87
+ throw new Error('A scheduled task needs the user it starts for.');
88
+ }
89
+ const user = xmlText(spec.user);
90
+ // No XML declaration: schtasks decodes the file itself and refuses one that names an encoding.
91
+ const lines = [
92
+ '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
93
+ ' <RegistrationInfo>',
94
+ ` <Description>${xmlText(spec.description)}</Description>`,
95
+ ` <URI>\\${xmlText(spec.label)}</URI>`,
96
+ ' </RegistrationInfo>',
97
+ ' <Triggers>',
98
+ ' <LogonTrigger>',
99
+ ' <Enabled>true</Enabled>',
100
+ ` <UserId>${user}</UserId>`,
101
+ ' <Repetition>',
102
+ ' <Interval>PT1M</Interval>',
103
+ ' <StopAtDurationEnd>false</StopAtDurationEnd>',
104
+ ' </Repetition>',
105
+ ' </LogonTrigger>',
106
+ ' </Triggers>',
107
+ ' <Principals>',
108
+ ' <Principal id="Author">',
109
+ ` <UserId>${user}</UserId>`,
110
+ ' <LogonType>InteractiveToken</LogonType>',
111
+ ' <RunLevel>LeastPrivilege</RunLevel>',
112
+ ' </Principal>',
113
+ ' </Principals>',
114
+ ' <Settings>',
115
+ ' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
116
+ ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
117
+ ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
118
+ ' <AllowHardTerminate>true</AllowHardTerminate>',
119
+ ' <StartWhenAvailable>true</StartWhenAvailable>',
120
+ ' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>',
121
+ ' <IdleSettings>',
122
+ ' <StopOnIdleEnd>false</StopOnIdleEnd>',
123
+ ' <RestartOnIdle>false</RestartOnIdle>',
124
+ ' </IdleSettings>',
125
+ ' <AllowStartOnDemand>true</AllowStartOnDemand>',
126
+ ' <Enabled>true</Enabled>',
127
+ ' <Hidden>false</Hidden>',
128
+ ' <RunOnlyIfIdle>false</RunOnlyIfIdle>',
129
+ ' <WakeToRun>false</WakeToRun>',
130
+ // The default ends a task after three days.
131
+ ' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>',
132
+ // The default, 7, runs a task below normal priority.
133
+ ' <Priority>5</Priority>',
134
+ ' </Settings>',
135
+ ' <Actions Context="Author">',
136
+ ' <Exec>',
137
+ ' <Command>%SystemRoot%\\System32\\cmd.exe</Command>',
138
+ ` <Arguments>${xmlText(`/d /s /c "${taskCommandLine(spec)}"`)}</Arguments>`,
139
+ ` <WorkingDirectory>${xmlText(spec.workingDirectory)}</WorkingDirectory>`,
140
+ ' </Exec>',
141
+ ' </Actions>',
142
+ '</Task>',
143
+ ''
144
+ ];
145
+ return lines.join('\r\n');
146
+ }
147
+ function scheduledTaskRunsProgram(definition, program) {
148
+ const exec = definition.match(/<Exec>[\s\S]*?<\/Exec>/g);
149
+ if (exec?.length !== 1) {
150
+ return false;
151
+ }
152
+ const args = /<Arguments>([^<]*)<\/Arguments>/.exec(exec[0])?.[1];
153
+ const line = args === undefined ? undefined : /\/c "([\s\S]*)"$/.exec(xmlValue(args))?.[1];
154
+ return line !== undefined && (line.split(' && ').at(-1) ?? '').startsWith(`${cmdEscaped(program)} `);
155
+ }
62
156
  /* Only keys of the outer plist dictionary identify the executable; nested environment keys are data. */
63
157
  function launchAgentProgram(definition) {
64
158
  const xml = definition.replace(/<!--[\s\S]*?-->/g, '');
@@ -130,5 +224,8 @@ function systemdRunsProgram(definition, program) {
130
224
  * Callers sharing a service identity can check executable ownership before replacing its definition.
131
225
  */
132
226
  export function definitionRunsProgram(definition, program) {
227
+ if (/<Task[\s>]/.test(definition)) {
228
+ return scheduledTaskRunsProgram(definition, program);
229
+ }
133
230
  return definition.trimStart().startsWith('<') ? launchAgentProgram(definition) === xmlText(program) : systemdRunsProgram(definition, program);
134
231
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { definitionRunsProgram, launchAgentPlist, systemdUnit, type ServiceSpec } from './definitions.js';
2
- export { launchdManager, systemdManager, type CommandResult, type CommandRunner, type LaunchdOptions, type ServiceFiles, type ServiceManager, type SystemdOptions } from './manager.js';
1
+ export { definitionRunsProgram, launchAgentPlist, scheduledTask, systemdUnit, type ServiceSpec } from './definitions.js';
2
+ export { launchdManager, systemdManager, taskSchedulerManager, type CommandResult, type CommandRunner, type LaunchdOptions, type ServiceFiles, type ServiceManager, type SystemdOptions, type TaskSchedulerOptions } from './manager.js';
3
3
  export { platformServiceManager, serviceDefinition, type PlatformServiceOptions } from './platform.js';
4
4
  export { diskFiles, runCommand } from './system.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { definitionRunsProgram, launchAgentPlist, systemdUnit } from './definitions.js';
2
- export { launchdManager, systemdManager } from './manager.js';
1
+ export { definitionRunsProgram, launchAgentPlist, scheduledTask, systemdUnit } from './definitions.js';
2
+ export { launchdManager, systemdManager, taskSchedulerManager } from './manager.js';
3
3
  export { platformServiceManager, serviceDefinition } from './platform.js';
4
4
  export { diskFiles, runCommand } from './system.js';
package/dist/manager.d.ts CHANGED
@@ -11,7 +11,7 @@ export interface ServiceFiles {
11
11
  makeDirectory(path: string): void;
12
12
  }
13
13
  export interface ServiceManager {
14
- readonly kind: 'launchd' | 'systemd';
14
+ readonly kind: 'launchd' | 'systemd' | 'task-scheduler';
15
15
  readonly path: string;
16
16
  isInstalled(): boolean;
17
17
  read(): string | null;
@@ -43,3 +43,12 @@ export interface SystemdOptions {
43
43
  unitName: string;
44
44
  }
45
45
  export declare function systemdManager(options: SystemdOptions): ServiceManager;
46
+ export interface TaskSchedulerOptions {
47
+ taskName: string;
48
+ definitionFile: string;
49
+ logFile: string;
50
+ run: CommandRunner;
51
+ files: ServiceFiles;
52
+ sleep(ms: number): Promise<void>;
53
+ }
54
+ export declare function taskSchedulerManager(options: TaskSchedulerOptions): ServiceManager;
package/dist/manager.js CHANGED
@@ -1,4 +1,5 @@
1
- import { dirname, join } from 'node:path';
1
+ import { posix, win32 } from 'node:path';
2
+ import { scheduledTaskMarker } from './cmd-line.js';
2
3
  function expectSuccess(result, what) {
3
4
  if (result.code !== 0) {
4
5
  throw new Error(`${what} failed (${result.code}): ${(result.stderr || result.stdout).trim() || 'no output'}`);
@@ -10,7 +11,7 @@ function expectSuccess(result, what) {
10
11
  * the bundle, where the login shell's PATH and a log path in the home cannot be written.
11
12
  */
12
13
  export function launchdManager(options) {
13
- const path = join(options.home, 'Library', 'LaunchAgents', `${options.label}.plist`);
14
+ const path = posix.join(options.home, 'Library', 'LaunchAgents', `${options.label}.plist`);
14
15
  const domain = `gui/${options.uid}`;
15
16
  const target = `${domain}/${options.label}`;
16
17
  const launchctl = (...args) => options.run('/bin/launchctl', args);
@@ -25,7 +26,7 @@ export function launchdManager(options) {
25
26
  read: () => options.files.read(path),
26
27
  install(definition) {
27
28
  // launchd opens the log but makes no folder for it, and a job whose log cannot open never starts.
28
- options.files.makeDirectory(dirname(options.logFile));
29
+ options.files.makeDirectory(posix.dirname(options.logFile));
29
30
  const previous = options.files.read(path);
30
31
  if (previous !== definition) {
31
32
  options.files.write(path, definition);
@@ -62,7 +63,7 @@ export function launchdManager(options) {
62
63
  /* A user unit under ~/.config/systemd/user, enabled for `default.target`. */
63
64
  export function systemdManager(options) {
64
65
  const unit = options.unitName;
65
- const path = join(options.configHome, 'systemd', 'user', unit);
66
+ const path = posix.join(options.configHome, 'systemd', 'user', unit);
66
67
  const systemctl = (...args) => options.run('systemctl', ['--user', ...args]);
67
68
  const reload = () => {
68
69
  expectSuccess(systemctl('daemon-reload'), 'systemctl daemon-reload');
@@ -105,3 +106,79 @@ export function systemdManager(options) {
105
106
  }
106
107
  };
107
108
  }
109
+ /*
110
+ * A task in the person's own Task Scheduler library, registered with `schtasks` from a copy of the
111
+ * definition on disk. Creating one for oneself takes no admin. The task runs again every minute, so
112
+ * a stop also disables it, and a start enables it again.
113
+ */
114
+ export function taskSchedulerManager(options) {
115
+ const path = options.definitionFile;
116
+ const name = options.taskName;
117
+ const schtasks = (...args) => options.run('schtasks.exe', args);
118
+ const exists = () => schtasks('/Query', '/TN', name).code === 0;
119
+ // schtasks writes the state in the language of the system; PowerShell names it the same everywhere.
120
+ const isRunning = () => options
121
+ .run('powershell.exe', [
122
+ '-NoProfile',
123
+ '-NonInteractive',
124
+ '-Command',
125
+ `(Get-ScheduledTask -TaskPath '\\' -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop).State`
126
+ ])
127
+ .stdout.trim() === 'Running';
128
+ // Before `/End`, which leaves what the task's cmd.exe started running without a parent to find it by.
129
+ const endTree = () => {
130
+ const marker = scheduledTaskMarker(name).replace(/'/g, "''");
131
+ const script = [
132
+ 'Get-CimInstance Win32_Process -Filter "Name=\'cmd.exe\'"',
133
+ `Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${marker}') }`,
134
+ 'ForEach-Object { taskkill.exe /PID $_.ProcessId /T /F | Out-Null }'
135
+ ].join(' | ');
136
+ options.run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
137
+ };
138
+ const run = () => {
139
+ expectSuccess(schtasks('/Change', '/TN', name, '/ENABLE'), 'schtasks /Change /ENABLE');
140
+ expectSuccess(schtasks('/Run', '/TN', name), 'schtasks /Run');
141
+ };
142
+ return {
143
+ kind: 'task-scheduler',
144
+ path,
145
+ isInstalled: () => options.files.read(path) !== null && exists(),
146
+ read: () => options.files.read(path),
147
+ install(definition) {
148
+ // cmd.exe appends to the log but makes no folder for it, and the task ends at once without one.
149
+ options.files.makeDirectory(win32.dirname(options.logFile));
150
+ const previous = options.files.read(path);
151
+ if (previous !== definition) {
152
+ options.files.write(path, definition);
153
+ }
154
+ if (previous !== definition || !exists()) {
155
+ expectSuccess(schtasks('/Create', '/TN', name, '/XML', path, '/F'), 'schtasks /Create');
156
+ }
157
+ return previous !== null && previous !== definition;
158
+ },
159
+ start() {
160
+ run();
161
+ },
162
+ async restart() {
163
+ if (isRunning()) {
164
+ endTree();
165
+ schtasks('/End', '/TN', name);
166
+ // A task that still counts as running ignores the next start.
167
+ for (let attempt = 0; attempt < 25 && isRunning(); attempt++) {
168
+ await options.sleep(200);
169
+ }
170
+ }
171
+ run();
172
+ },
173
+ stop() {
174
+ endTree();
175
+ // schtasks ends no instance of a task that is disabled already.
176
+ schtasks('/End', '/TN', name);
177
+ schtasks('/Change', '/TN', name, '/DISABLE');
178
+ },
179
+ uninstall() {
180
+ schtasks('/Delete', '/TN', name, '/F');
181
+ options.files.remove(path);
182
+ }
183
+ };
184
+ }
@@ -1,8 +1,9 @@
1
1
  import { type ServiceSpec } from './definitions.js';
2
- import { type LaunchdOptions, type ServiceManager, type SystemdOptions } from './manager.js';
2
+ import { type LaunchdOptions, type ServiceManager, type SystemdOptions, type TaskSchedulerOptions } from './manager.js';
3
3
  export interface PlatformServiceOptions {
4
4
  launchd: LaunchdOptions;
5
5
  systemd: SystemdOptions;
6
+ taskScheduler?: TaskSchedulerOptions;
6
7
  }
7
8
  export declare function platformServiceManager(platform: NodeJS.Platform, options: PlatformServiceOptions): ServiceManager | null;
8
9
  export declare function serviceDefinition(platform: NodeJS.Platform, spec: ServiceSpec): string;
package/dist/platform.js CHANGED
@@ -1,5 +1,5 @@
1
- import { launchAgentPlist, systemdUnit } from './definitions.js';
2
- import { launchdManager, systemdManager } from './manager.js';
1
+ import { launchAgentPlist, scheduledTask, systemdUnit } from './definitions.js';
2
+ import { launchdManager, systemdManager, taskSchedulerManager } from './manager.js';
3
3
  export function platformServiceManager(platform, options) {
4
4
  if (platform === 'darwin') {
5
5
  return launchdManager(options.launchd);
@@ -7,6 +7,9 @@ export function platformServiceManager(platform, options) {
7
7
  if (platform === 'linux') {
8
8
  return systemdManager(options.systemd);
9
9
  }
10
+ if (platform === 'win32' && options.taskScheduler !== undefined) {
11
+ return taskSchedulerManager(options.taskScheduler);
12
+ }
10
13
  return null;
11
14
  }
12
15
  export function serviceDefinition(platform, spec) {
@@ -16,5 +19,8 @@ export function serviceDefinition(platform, spec) {
16
19
  if (platform === 'linux') {
17
20
  return systemdUnit(spec);
18
21
  }
22
+ if (platform === 'win32') {
23
+ return scheduledTask(spec);
24
+ }
19
25
  throw new Error(`Background services are unsupported on ${platform}.`);
20
26
  }
package/dist/system.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
- /* The real launchctl, systemctl and loginctl under a service manager, bounded so a hung one cannot hold up a quit. */
4
+ /* The real launchctl, systemctl, loginctl and schtasks under a service manager, bounded so a hung one cannot hold up a quit. */
5
5
  export function runCommand(command, args) {
6
- const result = spawnSync(command, args, { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] });
6
+ const result = spawnSync(command, args, { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
7
7
  return { code: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr || result.error?.message || '' };
8
8
  }
9
9
  export const diskFiles = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adecore/service",
3
- "version": "0.29.1",
4
- "description": "User service definitions and injectable launchd and systemd managers.",
3
+ "version": "0.29.2",
4
+ "description": "User service definitions and injectable launchd, systemd and Task Scheduler managers.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
7
7
  "repository": {
@@ -0,0 +1,14 @@
1
+ // What cmd.exe reads as syntax outside quotes, and so escapes with a caret.
2
+ const CMD_SPECIAL = /([()\][%!^"`<>&|;, *?])/g;
3
+
4
+ export function cmdEscaped(text: string): string {
5
+ return text.replace(CMD_SPECIAL, '^$1');
6
+ }
7
+
8
+ /*
9
+ * What a manager finds the cmd.exe of a task by: `schtasks /End` ends that process alone, so a stop
10
+ * has to end the tree under it first. Its title is the label, which is the name of the task.
11
+ */
12
+ export function scheduledTaskMarker(label: string): string {
13
+ return `title ${cmdEscaped(label)} && `;
14
+ }
@@ -1,3 +1,5 @@
1
+ import { cmdEscaped, scheduledTaskMarker } from './cmd-line.js';
2
+
1
3
  export interface ServiceSpec {
2
4
  label: string;
3
5
  description: string;
@@ -5,14 +7,20 @@ export interface ServiceSpec {
5
7
  args: string[];
6
8
  environment: Record<string, string>;
7
9
  workingDirectory: string;
8
- /* launchd only: where stdout and stderr go. systemd has the journal. */
10
+ /* launchd and the scheduled task: where stdout and stderr go. systemd has the journal. */
9
11
  logFile: string;
12
+ /* The scheduled task only, which needs it: the account it starts for at logon, as `DOMAIN\\name`. */
13
+ user?: string;
10
14
  }
11
15
 
12
16
  function xmlText(value: string): string {
13
17
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
14
18
  }
15
19
 
20
+ function xmlValue(text: string): string {
21
+ return text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
22
+ }
23
+
16
24
  function plistString(value: string, indent: string): string {
17
25
  return `${indent}<string>${xmlText(value)}</string>`;
18
26
  }
@@ -92,6 +100,100 @@ export function systemdUnit(spec: ServiceSpec): string {
92
100
  return lines.join('\n');
93
101
  }
94
102
 
103
+ /* One argument as the C runtime splits a command line, escaped so cmd.exe passes it on as it is. */
104
+ function cmdArgument(arg: string): string {
105
+ if (/[\r\n]/.test(arg)) {
106
+ throw new Error('A scheduled task cannot pass on a line break');
107
+ }
108
+ return cmdEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
109
+ }
110
+
111
+ /* The line cmd.exe runs: its title, the environment, the program and the log it appends to. */
112
+ function taskCommandLine(spec: ServiceSpec): string {
113
+ const variables = Object.entries(spec.environment).map(([name, value]) => `set ${cmdEscaped(`"${name}=${value}"`)}`);
114
+ const program = [cmdEscaped(spec.program), ...spec.args.map(cmdArgument)].join(' ');
115
+ return `${scheduledTaskMarker(spec.label)}${[...variables, `${program} >>${cmdEscaped(spec.logFile)} 2>&1`].join(' && ')}`;
116
+ }
117
+
118
+ /*
119
+ * A scheduled task for one person, started at their logon in their own session without a stored
120
+ * password, and run again every minute so it comes back after a crash: a second instance is ignored
121
+ * while one runs. RestartOnFailure is left out, since it also starts a task a stop ended. A task carries no environment
122
+ * of its own, so cmd.exe sets it and appends the output to the log.
123
+ */
124
+ export function scheduledTask(spec: ServiceSpec): string {
125
+ if (spec.user === undefined || spec.user === '') {
126
+ throw new Error('A scheduled task needs the user it starts for.');
127
+ }
128
+ const user = xmlText(spec.user);
129
+ // No XML declaration: schtasks decodes the file itself and refuses one that names an encoding.
130
+ const lines = [
131
+ '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
132
+ ' <RegistrationInfo>',
133
+ ` <Description>${xmlText(spec.description)}</Description>`,
134
+ ` <URI>\\${xmlText(spec.label)}</URI>`,
135
+ ' </RegistrationInfo>',
136
+ ' <Triggers>',
137
+ ' <LogonTrigger>',
138
+ ' <Enabled>true</Enabled>',
139
+ ` <UserId>${user}</UserId>`,
140
+ ' <Repetition>',
141
+ ' <Interval>PT1M</Interval>',
142
+ ' <StopAtDurationEnd>false</StopAtDurationEnd>',
143
+ ' </Repetition>',
144
+ ' </LogonTrigger>',
145
+ ' </Triggers>',
146
+ ' <Principals>',
147
+ ' <Principal id="Author">',
148
+ ` <UserId>${user}</UserId>`,
149
+ ' <LogonType>InteractiveToken</LogonType>',
150
+ ' <RunLevel>LeastPrivilege</RunLevel>',
151
+ ' </Principal>',
152
+ ' </Principals>',
153
+ ' <Settings>',
154
+ ' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
155
+ ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
156
+ ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
157
+ ' <AllowHardTerminate>true</AllowHardTerminate>',
158
+ ' <StartWhenAvailable>true</StartWhenAvailable>',
159
+ ' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>',
160
+ ' <IdleSettings>',
161
+ ' <StopOnIdleEnd>false</StopOnIdleEnd>',
162
+ ' <RestartOnIdle>false</RestartOnIdle>',
163
+ ' </IdleSettings>',
164
+ ' <AllowStartOnDemand>true</AllowStartOnDemand>',
165
+ ' <Enabled>true</Enabled>',
166
+ ' <Hidden>false</Hidden>',
167
+ ' <RunOnlyIfIdle>false</RunOnlyIfIdle>',
168
+ ' <WakeToRun>false</WakeToRun>',
169
+ // The default ends a task after three days.
170
+ ' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>',
171
+ // The default, 7, runs a task below normal priority.
172
+ ' <Priority>5</Priority>',
173
+ ' </Settings>',
174
+ ' <Actions Context="Author">',
175
+ ' <Exec>',
176
+ ' <Command>%SystemRoot%\\System32\\cmd.exe</Command>',
177
+ ` <Arguments>${xmlText(`/d /s /c "${taskCommandLine(spec)}"`)}</Arguments>`,
178
+ ` <WorkingDirectory>${xmlText(spec.workingDirectory)}</WorkingDirectory>`,
179
+ ' </Exec>',
180
+ ' </Actions>',
181
+ '</Task>',
182
+ ''
183
+ ];
184
+ return lines.join('\r\n');
185
+ }
186
+
187
+ function scheduledTaskRunsProgram(definition: string, program: string): boolean {
188
+ const exec = definition.match(/<Exec>[\s\S]*?<\/Exec>/g);
189
+ if (exec?.length !== 1) {
190
+ return false;
191
+ }
192
+ const args = /<Arguments>([^<]*)<\/Arguments>/.exec(exec[0])?.[1];
193
+ const line = args === undefined ? undefined : /\/c "([\s\S]*)"$/.exec(xmlValue(args))?.[1];
194
+ return line !== undefined && (line.split(' && ').at(-1) ?? '').startsWith(`${cmdEscaped(program)} `);
195
+ }
196
+
95
197
  /* Only keys of the outer plist dictionary identify the executable; nested environment keys are data. */
96
198
  function launchAgentProgram(definition: string): string | null {
97
199
  const xml = definition.replace(/<!--[\s\S]*?-->/g, '');
@@ -162,5 +264,8 @@ function systemdRunsProgram(definition: string, program: string): boolean {
162
264
  * Callers sharing a service identity can check executable ownership before replacing its definition.
163
265
  */
164
266
  export function definitionRunsProgram(definition: string, program: string): boolean {
267
+ if (/<Task[\s>]/.test(definition)) {
268
+ return scheduledTaskRunsProgram(definition, program);
269
+ }
165
270
  return definition.trimStart().startsWith('<') ? launchAgentProgram(definition) === xmlText(program) : systemdRunsProgram(definition, program);
166
271
  }
package/src/index.ts CHANGED
@@ -1,13 +1,15 @@
1
- export { definitionRunsProgram, launchAgentPlist, systemdUnit, type ServiceSpec } from './definitions.js';
1
+ export { definitionRunsProgram, launchAgentPlist, scheduledTask, systemdUnit, type ServiceSpec } from './definitions.js';
2
2
  export {
3
3
  launchdManager,
4
4
  systemdManager,
5
+ taskSchedulerManager,
5
6
  type CommandResult,
6
7
  type CommandRunner,
7
8
  type LaunchdOptions,
8
9
  type ServiceFiles,
9
10
  type ServiceManager,
10
- type SystemdOptions
11
+ type SystemdOptions,
12
+ type TaskSchedulerOptions
11
13
  } from './manager.js';
12
14
  export { platformServiceManager, serviceDefinition, type PlatformServiceOptions } from './platform.js';
13
15
  export { diskFiles, runCommand } from './system.js';
package/src/manager.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { dirname, join } from 'node:path';
1
+ import { posix, win32 } from 'node:path';
2
+ import { scheduledTaskMarker } from './cmd-line.js';
2
3
 
3
4
  export interface CommandResult {
4
5
  code: number;
@@ -18,7 +19,7 @@ export interface ServiceFiles {
18
19
  }
19
20
 
20
21
  export interface ServiceManager {
21
- readonly kind: 'launchd' | 'systemd';
22
+ readonly kind: 'launchd' | 'systemd' | 'task-scheduler';
22
23
  /* The definition's file. */
23
24
  readonly path: string;
24
25
  isInstalled(): boolean;
@@ -63,7 +64,7 @@ export interface LaunchdOptions {
63
64
  * the bundle, where the login shell's PATH and a log path in the home cannot be written.
64
65
  */
65
66
  export function launchdManager(options: LaunchdOptions): ServiceManager {
66
- const path = join(options.home, 'Library', 'LaunchAgents', `${options.label}.plist`);
67
+ const path = posix.join(options.home, 'Library', 'LaunchAgents', `${options.label}.plist`);
67
68
  const domain = `gui/${options.uid}`;
68
69
  const target = `${domain}/${options.label}`;
69
70
  const launchctl = (...args: string[]): CommandResult => options.run('/bin/launchctl', args);
@@ -80,7 +81,7 @@ export function launchdManager(options: LaunchdOptions): ServiceManager {
80
81
  read: () => options.files.read(path),
81
82
  install(definition) {
82
83
  // launchd opens the log but makes no folder for it, and a job whose log cannot open never starts.
83
- options.files.makeDirectory(dirname(options.logFile));
84
+ options.files.makeDirectory(posix.dirname(options.logFile));
84
85
  const previous = options.files.read(path);
85
86
  if (previous !== definition) {
86
87
  options.files.write(path, definition);
@@ -126,7 +127,7 @@ export interface SystemdOptions {
126
127
  /* A user unit under ~/.config/systemd/user, enabled for `default.target`. */
127
128
  export function systemdManager(options: SystemdOptions): ServiceManager {
128
129
  const unit = options.unitName;
129
- const path = join(options.configHome, 'systemd', 'user', unit);
130
+ const path = posix.join(options.configHome, 'systemd', 'user', unit);
130
131
  const systemctl = (...args: string[]): CommandResult => options.run('systemctl', ['--user', ...args]);
131
132
 
132
133
  const reload = (): void => {
@@ -171,3 +172,95 @@ export function systemdManager(options: SystemdOptions): ServiceManager {
171
172
  }
172
173
  };
173
174
  }
175
+
176
+ export interface TaskSchedulerOptions {
177
+ /* The name of the task, which has to be the label of the spec: a stop finds the task's processes by it. */
178
+ taskName: string;
179
+ /* Where the definition is kept and registered from. */
180
+ definitionFile: string;
181
+ logFile: string;
182
+ run: CommandRunner;
183
+ files: ServiceFiles;
184
+ sleep(ms: number): Promise<void>;
185
+ }
186
+
187
+ /*
188
+ * A task in the person's own Task Scheduler library, registered with `schtasks` from a copy of the
189
+ * definition on disk. Creating one for oneself takes no admin. The task runs again every minute, so
190
+ * a stop also disables it, and a start enables it again.
191
+ */
192
+ export function taskSchedulerManager(options: TaskSchedulerOptions): ServiceManager {
193
+ const path = options.definitionFile;
194
+ const name = options.taskName;
195
+ const schtasks = (...args: string[]): CommandResult => options.run('schtasks.exe', args);
196
+ const exists = (): boolean => schtasks('/Query', '/TN', name).code === 0;
197
+ // schtasks writes the state in the language of the system; PowerShell names it the same everywhere.
198
+ const isRunning = (): boolean =>
199
+ options
200
+ .run('powershell.exe', [
201
+ '-NoProfile',
202
+ '-NonInteractive',
203
+ '-Command',
204
+ `(Get-ScheduledTask -TaskPath '\\' -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop).State`
205
+ ])
206
+ .stdout.trim() === 'Running';
207
+
208
+ // Before `/End`, which leaves what the task's cmd.exe started running without a parent to find it by.
209
+ const endTree = (): void => {
210
+ const marker = scheduledTaskMarker(name).replace(/'/g, "''");
211
+ const script = [
212
+ 'Get-CimInstance Win32_Process -Filter "Name=\'cmd.exe\'"',
213
+ `Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${marker}') }`,
214
+ 'ForEach-Object { taskkill.exe /PID $_.ProcessId /T /F | Out-Null }'
215
+ ].join(' | ');
216
+ options.run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
217
+ };
218
+
219
+ const run = (): void => {
220
+ expectSuccess(schtasks('/Change', '/TN', name, '/ENABLE'), 'schtasks /Change /ENABLE');
221
+ expectSuccess(schtasks('/Run', '/TN', name), 'schtasks /Run');
222
+ };
223
+
224
+ return {
225
+ kind: 'task-scheduler',
226
+ path,
227
+ isInstalled: () => options.files.read(path) !== null && exists(),
228
+ read: () => options.files.read(path),
229
+ install(definition) {
230
+ // cmd.exe appends to the log but makes no folder for it, and the task ends at once without one.
231
+ options.files.makeDirectory(win32.dirname(options.logFile));
232
+ const previous = options.files.read(path);
233
+ if (previous !== definition) {
234
+ options.files.write(path, definition);
235
+ }
236
+ if (previous !== definition || !exists()) {
237
+ expectSuccess(schtasks('/Create', '/TN', name, '/XML', path, '/F'), 'schtasks /Create');
238
+ }
239
+ return previous !== null && previous !== definition;
240
+ },
241
+ start() {
242
+ run();
243
+ },
244
+ async restart() {
245
+ if (isRunning()) {
246
+ endTree();
247
+ schtasks('/End', '/TN', name);
248
+ // A task that still counts as running ignores the next start.
249
+ for (let attempt = 0; attempt < 25 && isRunning(); attempt++) {
250
+ await options.sleep(200);
251
+ }
252
+ }
253
+ run();
254
+ },
255
+ stop() {
256
+ endTree();
257
+ // schtasks ends no instance of a task that is disabled already.
258
+ schtasks('/End', '/TN', name);
259
+ schtasks('/Change', '/TN', name, '/DISABLE');
260
+ },
261
+ uninstall() {
262
+ schtasks('/Delete', '/TN', name, '/F');
263
+ options.files.remove(path);
264
+ }
265
+ };
266
+ }
package/src/platform.ts CHANGED
@@ -1,9 +1,19 @@
1
- import { launchAgentPlist, systemdUnit, type ServiceSpec } from './definitions.js';
2
- import { launchdManager, systemdManager, type LaunchdOptions, type ServiceManager, type SystemdOptions } from './manager.js';
1
+ import { launchAgentPlist, scheduledTask, systemdUnit, type ServiceSpec } from './definitions.js';
2
+ import {
3
+ launchdManager,
4
+ systemdManager,
5
+ taskSchedulerManager,
6
+ type LaunchdOptions,
7
+ type ServiceManager,
8
+ type SystemdOptions,
9
+ type TaskSchedulerOptions
10
+ } from './manager.js';
3
11
 
4
12
  export interface PlatformServiceOptions {
5
13
  launchd: LaunchdOptions;
6
14
  systemd: SystemdOptions;
15
+ /* Without it there is no manager on Windows. */
16
+ taskScheduler?: TaskSchedulerOptions;
7
17
  }
8
18
 
9
19
  export function platformServiceManager(platform: NodeJS.Platform, options: PlatformServiceOptions): ServiceManager | null {
@@ -13,6 +23,9 @@ export function platformServiceManager(platform: NodeJS.Platform, options: Platf
13
23
  if (platform === 'linux') {
14
24
  return systemdManager(options.systemd);
15
25
  }
26
+ if (platform === 'win32' && options.taskScheduler !== undefined) {
27
+ return taskSchedulerManager(options.taskScheduler);
28
+ }
16
29
  return null;
17
30
  }
18
31
 
@@ -23,5 +36,8 @@ export function serviceDefinition(platform: NodeJS.Platform, spec: ServiceSpec):
23
36
  if (platform === 'linux') {
24
37
  return systemdUnit(spec);
25
38
  }
39
+ if (platform === 'win32') {
40
+ return scheduledTask(spec);
41
+ }
26
42
  throw new Error(`Background services are unsupported on ${platform}.`);
27
43
  }
package/src/system.ts CHANGED
@@ -3,9 +3,9 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import type { CommandResult, ServiceFiles } from './manager.js';
5
5
 
6
- /* The real launchctl, systemctl and loginctl under a service manager, bounded so a hung one cannot hold up a quit. */
6
+ /* The real launchctl, systemctl, loginctl and schtasks under a service manager, bounded so a hung one cannot hold up a quit. */
7
7
  export function runCommand(command: string, args: string[]): CommandResult {
8
- const result = spawnSync(command, args, { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] });
8
+ const result = spawnSync(command, args, { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
9
9
  return { code: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr || result.error?.message || '' };
10
10
  }
11
11