@adecore/service 0.48.4 → 0.49.0

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.
@@ -1,2 +1,4 @@
1
1
  export declare function cmdEscaped(text: string): string;
2
+ export declare function batchEscaped(text: string): string;
3
+ export declare function scheduledTaskScriptName(label: string): string;
2
4
  export declare function scheduledTaskMarker(label: string): string;
package/dist/cmd-line.js CHANGED
@@ -3,9 +3,18 @@ const CMD_SPECIAL = /([()\][%!^"`<>&|;, *?])/g;
3
3
  export function cmdEscaped(text) {
4
4
  return text.replace(CMD_SPECIAL, '^$1');
5
5
  }
6
+ /* The same on a line of a batch file, where cmd.exe reads `%` before any caret: only doubling keeps one. */
7
+ export function batchEscaped(text) {
8
+ return text.replace(/[()\][!^"`<>&|;, *?]/g, '^$&').replace(/%/g, '%%');
9
+ }
10
+ /* The script of a task, beside its definition. Without anything that would need quoting on its command line. */
11
+ export function scheduledTaskScriptName(label) {
12
+ return `${label.replace(/[^\w.-]/g, '_')}.cmd`;
13
+ }
6
14
  /*
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.
15
+ * What a manager finds the cmd.exe of a task from before it had a script by: `schtasks /End` ends that
16
+ * process alone, so a stop has to end the tree under it first. Its title is the label, which is the name
17
+ * of the task. A task on a script is found by the script's name on the cmd.exe line.
9
18
  */
10
19
  export function scheduledTaskMarker(label) {
11
20
  return `title ${cmdEscaped(label)} && `;
@@ -7,8 +7,13 @@ export interface ServiceSpec {
7
7
  workingDirectory: string;
8
8
  logFile: string;
9
9
  user?: string;
10
+ scriptDirectory?: string;
10
11
  }
11
12
  export declare function launchAgentPlist(spec: ServiceSpec): string;
12
13
  export declare function systemdUnit(spec: ServiceSpec): string;
13
14
  export declare function scheduledTask(spec: ServiceSpec): string;
15
+ export declare function scheduledTaskScript(definition: string): {
16
+ path: string;
17
+ text: string;
18
+ } | null;
14
19
  export declare function definitionRunsProgram(definition: string, program: string): boolean;
@@ -1,9 +1,13 @@
1
- import { cmdEscaped, scheduledTaskMarker } from './cmd-line.js';
1
+ import { batchEscaped, cmdEscaped, scheduledTaskScriptName } from './cmd-line.js';
2
2
  function xmlText(value) {
3
3
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
4
4
  }
5
5
  function xmlValue(text) {
6
- return text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
6
+ return text
7
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
8
+ .replace(/&lt;/g, '<')
9
+ .replace(/&gt;/g, '>')
10
+ .replace(/&amp;/g, '&');
7
11
  }
8
12
  function plistString(value, indent) {
9
13
  return `${indent}<string>${xmlText(value)}</string>`;
@@ -64,21 +68,44 @@ function cmdArgument(arg) {
64
68
  if (/[\r\n]/.test(arg)) {
65
69
  throw new Error('A scheduled task cannot pass on a line break');
66
70
  }
67
- return cmdEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
71
+ return batchEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
68
72
  }
69
- function taskCommandLine(spec) {
70
- const variables = Object.entries(spec.environment).map(([name, value]) => `set ${cmdEscaped(`"${name}=${value}"`)}`);
71
- const program = [cmdEscaped(spec.program), ...spec.args.map(cmdArgument)].join(' ');
72
- return `${scheduledTaskMarker(spec.label)}${[...variables, `${program} >>${cmdEscaped(spec.logFile)} 2>&1`].join(' && ')}`;
73
+ /* In quotes of its own, or the C runtime of the program splits its own path at a space, whatever the carets told cmd.exe. */
74
+ function batchProgram(program) {
75
+ return batchEscaped(`"${program}"`);
76
+ }
77
+ /* The code page first, or cmd.exe reads the UTF-8 of a path outside the system's code page as something else. */
78
+ function taskScript(spec) {
79
+ const variables = Object.entries(spec.environment).map(([name, value]) => `set ${batchEscaped(`"${name}=${value}"`)}`);
80
+ const program = [batchProgram(spec.program), ...spec.args.map(cmdArgument)].join(' ');
81
+ const lines = [
82
+ '@echo off',
83
+ 'chcp 65001 >nul',
84
+ `cd /d ${batchEscaped(spec.workingDirectory)} || exit /b 1`,
85
+ ...variables,
86
+ `${program} >>${batchEscaped(spec.logFile)} 2>&1`
87
+ ];
88
+ if (lines.some((line) => /[\r\n]/.test(line))) {
89
+ throw new Error('A scheduled task cannot pass on a line break');
90
+ }
91
+ return `${lines.join('\r\n')}\r\n`;
73
92
  }
74
93
  /*
75
94
  * Started at the person's logon without a stored password, and run again every minute so it comes back
76
- * after a crash. RestartOnFailure would also start a task a stop ended. cmd.exe sets the environment.
95
+ * after a crash. RestartOnFailure would also start a task a stop ended.
96
+ *
97
+ * A task with an interactive token gives a console program a window, and closing that ends it. Only a
98
+ * headless conhost.exe shows none, but it joins its arguments again and loses every inner quote. So
99
+ * what needs quoting moves into a script, which the task starts from its own folder by a bare name.
100
+ * The definition carries the script in `Documentation`, and a manager writes it out on install.
77
101
  */
78
102
  export function scheduledTask(spec) {
79
103
  if (spec.user === undefined || spec.user === '') {
80
104
  throw new Error('A scheduled task needs the user it starts for.');
81
105
  }
106
+ if (spec.scriptDirectory === undefined || spec.scriptDirectory === '') {
107
+ throw new Error('A scheduled task needs the folder of its script.');
108
+ }
82
109
  const user = xmlText(spec.user);
83
110
  // No XML declaration: schtasks decodes the file itself and refuses one that names an encoding.
84
111
  const lines = [
@@ -86,6 +113,7 @@ export function scheduledTask(spec) {
86
113
  ' <RegistrationInfo>',
87
114
  ` <Description>${xmlText(spec.description)}</Description>`,
88
115
  ` <URI>\\${xmlText(spec.label)}</URI>`,
116
+ ` <Documentation>${xmlText(taskScript(spec))}</Documentation>`,
89
117
  ' </RegistrationInfo>',
90
118
  ' <Triggers>',
91
119
  ' <LogonTrigger>',
@@ -127,17 +155,33 @@ export function scheduledTask(spec) {
127
155
  ' </Settings>',
128
156
  ' <Actions Context="Author">',
129
157
  ' <Exec>',
130
- ' <Command>%SystemRoot%\\System32\\cmd.exe</Command>',
131
- ` <Arguments>${xmlText(`/d /s /c "${taskCommandLine(spec)}"`)}</Arguments>`,
132
- ` <WorkingDirectory>${xmlText(spec.workingDirectory)}</WorkingDirectory>`,
158
+ ' <Command>%SystemRoot%\\System32\\conhost.exe</Command>',
159
+ ` <Arguments>--headless cmd.exe /d /c .\\${scheduledTaskScriptName(spec.label)}</Arguments>`,
160
+ ` <WorkingDirectory>${xmlText(spec.scriptDirectory)}</WorkingDirectory>`,
133
161
  ' </Exec>',
134
162
  ' </Actions>',
135
163
  '</Task>',
136
164
  ''
137
165
  ];
138
- return lines.join('\r\n');
166
+ // ASCII with character references: schtasks reads a UTF-8 file as the system's code page, and refuses a BOM.
167
+ return lines.join('\r\n').replace(/[\u0080-\u{10ffff}]/gu, (character) => `&#${character.codePointAt(0)};`);
168
+ }
169
+ /* The script a definition starts, null for any other definition and for a task from before it had one. */
170
+ export function scheduledTaskScript(definition) {
171
+ const text = /<Documentation>([^<]*)<\/Documentation>/.exec(definition)?.[1];
172
+ const name = /<Arguments>--headless cmd\.exe \/d \/c \.\\([^<]+)<\/Arguments>/.exec(definition)?.[1];
173
+ const folder = /<WorkingDirectory>([^<]*)<\/WorkingDirectory>/.exec(definition)?.[1];
174
+ if (text === undefined || name === undefined || folder === undefined) {
175
+ return null;
176
+ }
177
+ return { path: `${xmlValue(folder).replace(/\\$/, '')}\\${name}`, text: xmlValue(text) };
139
178
  }
140
179
  function scheduledTaskRunsProgram(definition, program) {
180
+ const script = scheduledTaskScript(definition);
181
+ if (script !== null) {
182
+ return (script.text.trimEnd().split('\r\n').at(-1) ?? '').startsWith(`${batchProgram(program)} `);
183
+ }
184
+ // A task from before the script: its one line ends in the program.
141
185
  const exec = definition.match(/<Exec>[\s\S]*?<\/Exec>/g);
142
186
  if (exec?.length !== 1) {
143
187
  return false;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { definitionRunsProgram, launchAgentPlist, scheduledTask, systemdUnit, type ServiceSpec } from './definitions.js';
1
+ export { definitionRunsProgram, launchAgentPlist, scheduledTask, scheduledTaskScript, systemdUnit, type ServiceSpec } from './definitions.js';
2
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, scheduledTask, systemdUnit } from './definitions.js';
1
+ export { definitionRunsProgram, launchAgentPlist, scheduledTask, scheduledTaskScript, systemdUnit } from './definitions.js';
2
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.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { posix, win32 } from 'node:path';
2
- import { scheduledTaskMarker } from './cmd-line.js';
2
+ import { scheduledTaskMarker, scheduledTaskScriptName } from './cmd-line.js';
3
+ import { scheduledTaskScript } from './definitions.js';
3
4
  function expectSuccess(result, what) {
4
5
  if (result.code !== 0) {
5
6
  throw new Error(`${what} failed (${result.code}): ${(result.stderr || result.stdout).trim() || 'no output'}`);
@@ -134,9 +135,10 @@ export function taskSchedulerManager(options) {
134
135
  // Before `/End`, which leaves what the task's cmd.exe started running without a parent to find it by.
135
136
  const endTree = () => {
136
137
  const marker = scheduledTaskMarker(name).replace(/'/g, "''");
138
+ const scriptName = scheduledTaskScriptName(name);
137
139
  const script = [
138
140
  'Get-CimInstance Win32_Process -Filter "Name=\'cmd.exe\'"',
139
- `Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${marker}') }`,
141
+ `Where-Object { $_.CommandLine -and ($_.CommandLine.Contains('${scriptName}') -or $_.CommandLine.Contains('${marker}')) }`,
140
142
  'ForEach-Object { taskkill.exe /PID $_.ProcessId /T /F | Out-Null }'
141
143
  ].join(' | ');
142
144
  options.run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
@@ -154,6 +156,10 @@ export function taskSchedulerManager(options) {
154
156
  // cmd.exe appends to the log but makes no folder for it, and the task ends at once without one.
155
157
  options.files.makeDirectory(win32.dirname(options.logFile));
156
158
  const previous = writeDefinition(options.files, path, definition);
159
+ const script = scheduledTaskScript(definition);
160
+ if (script !== null && options.files.read(script.path) !== script.text) {
161
+ options.files.write(script.path, script.text);
162
+ }
157
163
  if (previous !== definition || !exists()) {
158
164
  expectSuccess(schtasks('/Create', '/TN', name, '/XML', path, '/F'), 'schtasks /Create');
159
165
  }
@@ -177,6 +183,10 @@ export function taskSchedulerManager(options) {
177
183
  },
178
184
  uninstall() {
179
185
  schtasks('/Delete', '/TN', name, '/F');
186
+ const script = scheduledTaskScript(options.files.read(path) ?? '');
187
+ if (script !== null) {
188
+ options.files.remove(script.path);
189
+ }
180
190
  options.files.remove(path);
181
191
  }
182
192
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/service",
3
- "version": "0.48.4",
3
+ "version": "0.49.0",
4
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",
package/src/cmd-line.ts CHANGED
@@ -5,9 +5,20 @@ export function cmdEscaped(text: string): string {
5
5
  return text.replace(CMD_SPECIAL, '^$1');
6
6
  }
7
7
 
8
+ /* The same on a line of a batch file, where cmd.exe reads `%` before any caret: only doubling keeps one. */
9
+ export function batchEscaped(text: string): string {
10
+ return text.replace(/[()\][!^"`<>&|;, *?]/g, '^$&').replace(/%/g, '%%');
11
+ }
12
+
13
+ /* The script of a task, beside its definition. Without anything that would need quoting on its command line. */
14
+ export function scheduledTaskScriptName(label: string): string {
15
+ return `${label.replace(/[^\w.-]/g, '_')}.cmd`;
16
+ }
17
+
8
18
  /*
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.
19
+ * What a manager finds the cmd.exe of a task from before it had a script by: `schtasks /End` ends that
20
+ * process alone, so a stop has to end the tree under it first. Its title is the label, which is the name
21
+ * of the task. A task on a script is found by the script's name on the cmd.exe line.
11
22
  */
12
23
  export function scheduledTaskMarker(label: string): string {
13
24
  return `title ${cmdEscaped(label)} && `;
@@ -1,4 +1,4 @@
1
- import { cmdEscaped, scheduledTaskMarker } from './cmd-line.js';
1
+ import { batchEscaped, cmdEscaped, scheduledTaskScriptName } from './cmd-line.js';
2
2
 
3
3
  export interface ServiceSpec {
4
4
  label: string;
@@ -11,6 +11,8 @@ export interface ServiceSpec {
11
11
  logFile: string;
12
12
  /* The scheduled task only, which needs it: the account it starts for at logon, as `DOMAIN\\name`. */
13
13
  user?: string;
14
+ /* The scheduled task only, which needs it: the folder of the script it runs. */
15
+ scriptDirectory?: string;
14
16
  }
15
17
 
16
18
  function xmlText(value: string): string {
@@ -18,7 +20,11 @@ function xmlText(value: string): string {
18
20
  }
19
21
 
20
22
  function xmlValue(text: string): string {
21
- return text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
23
+ return text
24
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
25
+ .replace(/&lt;/g, '<')
26
+ .replace(/&gt;/g, '>')
27
+ .replace(/&amp;/g, '&');
22
28
  }
23
29
 
24
30
  function plistString(value: string, indent: string): string {
@@ -108,25 +114,51 @@ function cmdArgument(arg: string): string {
108
114
  throw new Error('A scheduled task cannot pass on a line break');
109
115
  }
110
116
 
111
- return cmdEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
117
+ return batchEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
112
118
  }
113
119
 
114
- function taskCommandLine(spec: ServiceSpec): string {
115
- const variables = Object.entries(spec.environment).map(([name, value]) => `set ${cmdEscaped(`"${name}=${value}"`)}`);
116
- const program = [cmdEscaped(spec.program), ...spec.args.map(cmdArgument)].join(' ');
120
+ /* In quotes of its own, or the C runtime of the program splits its own path at a space, whatever the carets told cmd.exe. */
121
+ function batchProgram(program: string): string {
122
+ return batchEscaped(`"${program}"`);
123
+ }
117
124
 
118
- return `${scheduledTaskMarker(spec.label)}${[...variables, `${program} >>${cmdEscaped(spec.logFile)} 2>&1`].join(' && ')}`;
125
+ /* The code page first, or cmd.exe reads the UTF-8 of a path outside the system's code page as something else. */
126
+ function taskScript(spec: ServiceSpec): string {
127
+ const variables = Object.entries(spec.environment).map(([name, value]) => `set ${batchEscaped(`"${name}=${value}"`)}`);
128
+ const program = [batchProgram(spec.program), ...spec.args.map(cmdArgument)].join(' ');
129
+ const lines = [
130
+ '@echo off',
131
+ 'chcp 65001 >nul',
132
+ `cd /d ${batchEscaped(spec.workingDirectory)} || exit /b 1`,
133
+ ...variables,
134
+ `${program} >>${batchEscaped(spec.logFile)} 2>&1`
135
+ ];
136
+
137
+ if (lines.some((line) => /[\r\n]/.test(line))) {
138
+ throw new Error('A scheduled task cannot pass on a line break');
139
+ }
140
+
141
+ return `${lines.join('\r\n')}\r\n`;
119
142
  }
120
143
 
121
144
  /*
122
145
  * Started at the person's logon without a stored password, and run again every minute so it comes back
123
- * after a crash. RestartOnFailure would also start a task a stop ended. cmd.exe sets the environment.
146
+ * after a crash. RestartOnFailure would also start a task a stop ended.
147
+ *
148
+ * A task with an interactive token gives a console program a window, and closing that ends it. Only a
149
+ * headless conhost.exe shows none, but it joins its arguments again and loses every inner quote. So
150
+ * what needs quoting moves into a script, which the task starts from its own folder by a bare name.
151
+ * The definition carries the script in `Documentation`, and a manager writes it out on install.
124
152
  */
125
153
  export function scheduledTask(spec: ServiceSpec): string {
126
154
  if (spec.user === undefined || spec.user === '') {
127
155
  throw new Error('A scheduled task needs the user it starts for.');
128
156
  }
129
157
 
158
+ if (spec.scriptDirectory === undefined || spec.scriptDirectory === '') {
159
+ throw new Error('A scheduled task needs the folder of its script.');
160
+ }
161
+
130
162
  const user = xmlText(spec.user);
131
163
 
132
164
  // No XML declaration: schtasks decodes the file itself and refuses one that names an encoding.
@@ -135,6 +167,7 @@ export function scheduledTask(spec: ServiceSpec): string {
135
167
  ' <RegistrationInfo>',
136
168
  ` <Description>${xmlText(spec.description)}</Description>`,
137
169
  ` <URI>\\${xmlText(spec.label)}</URI>`,
170
+ ` <Documentation>${xmlText(taskScript(spec))}</Documentation>`,
138
171
  ' </RegistrationInfo>',
139
172
  ' <Triggers>',
140
173
  ' <LogonTrigger>',
@@ -176,19 +209,40 @@ export function scheduledTask(spec: ServiceSpec): string {
176
209
  ' </Settings>',
177
210
  ' <Actions Context="Author">',
178
211
  ' <Exec>',
179
- ' <Command>%SystemRoot%\\System32\\cmd.exe</Command>',
180
- ` <Arguments>${xmlText(`/d /s /c "${taskCommandLine(spec)}"`)}</Arguments>`,
181
- ` <WorkingDirectory>${xmlText(spec.workingDirectory)}</WorkingDirectory>`,
212
+ ' <Command>%SystemRoot%\\System32\\conhost.exe</Command>',
213
+ ` <Arguments>--headless cmd.exe /d /c .\\${scheduledTaskScriptName(spec.label)}</Arguments>`,
214
+ ` <WorkingDirectory>${xmlText(spec.scriptDirectory)}</WorkingDirectory>`,
182
215
  ' </Exec>',
183
216
  ' </Actions>',
184
217
  '</Task>',
185
218
  ''
186
219
  ];
187
220
 
188
- return lines.join('\r\n');
221
+ // ASCII with character references: schtasks reads a UTF-8 file as the system's code page, and refuses a BOM.
222
+ return lines.join('\r\n').replace(/[\u0080-\u{10ffff}]/gu, (character) => `&#${character.codePointAt(0)};`);
223
+ }
224
+
225
+ /* The script a definition starts, null for any other definition and for a task from before it had one. */
226
+ export function scheduledTaskScript(definition: string): { path: string; text: string } | null {
227
+ const text = /<Documentation>([^<]*)<\/Documentation>/.exec(definition)?.[1];
228
+ const name = /<Arguments>--headless cmd\.exe \/d \/c \.\\([^<]+)<\/Arguments>/.exec(definition)?.[1];
229
+ const folder = /<WorkingDirectory>([^<]*)<\/WorkingDirectory>/.exec(definition)?.[1];
230
+
231
+ if (text === undefined || name === undefined || folder === undefined) {
232
+ return null;
233
+ }
234
+
235
+ return { path: `${xmlValue(folder).replace(/\\$/, '')}\\${name}`, text: xmlValue(text) };
189
236
  }
190
237
 
191
238
  function scheduledTaskRunsProgram(definition: string, program: string): boolean {
239
+ const script = scheduledTaskScript(definition);
240
+
241
+ if (script !== null) {
242
+ return (script.text.trimEnd().split('\r\n').at(-1) ?? '').startsWith(`${batchProgram(program)} `);
243
+ }
244
+
245
+ // A task from before the script: its one line ends in the program.
192
246
  const exec = definition.match(/<Exec>[\s\S]*?<\/Exec>/g);
193
247
 
194
248
  if (exec?.length !== 1) {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { definitionRunsProgram, launchAgentPlist, scheduledTask, systemdUnit, type ServiceSpec } from './definitions.js';
1
+ export { definitionRunsProgram, launchAgentPlist, scheduledTask, scheduledTaskScript, systemdUnit, type ServiceSpec } from './definitions.js';
2
2
  export {
3
3
  launchdManager,
4
4
  systemdManager,
package/src/manager.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { posix, win32 } from 'node:path';
2
- import { scheduledTaskMarker } from './cmd-line.js';
2
+ import { scheduledTaskMarker, scheduledTaskScriptName } from './cmd-line.js';
3
+ import { scheduledTaskScript } from './definitions.js';
3
4
 
4
5
  export interface CommandResult {
5
6
  code: number;
@@ -231,10 +232,11 @@ export function taskSchedulerManager(options: TaskSchedulerOptions): ServiceMana
231
232
  // Before `/End`, which leaves what the task's cmd.exe started running without a parent to find it by.
232
233
  const endTree = (): void => {
233
234
  const marker = scheduledTaskMarker(name).replace(/'/g, "''");
235
+ const scriptName = scheduledTaskScriptName(name);
234
236
 
235
237
  const script = [
236
238
  'Get-CimInstance Win32_Process -Filter "Name=\'cmd.exe\'"',
237
- `Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${marker}') }`,
239
+ `Where-Object { $_.CommandLine -and ($_.CommandLine.Contains('${scriptName}') -or $_.CommandLine.Contains('${marker}')) }`,
238
240
  'ForEach-Object { taskkill.exe /PID $_.ProcessId /T /F | Out-Null }'
239
241
  ].join(' | ');
240
242
 
@@ -255,6 +257,11 @@ export function taskSchedulerManager(options: TaskSchedulerOptions): ServiceMana
255
257
  // cmd.exe appends to the log but makes no folder for it, and the task ends at once without one.
256
258
  options.files.makeDirectory(win32.dirname(options.logFile));
257
259
  const previous = writeDefinition(options.files, path, definition);
260
+ const script = scheduledTaskScript(definition);
261
+
262
+ if (script !== null && options.files.read(script.path) !== script.text) {
263
+ options.files.write(script.path, script.text);
264
+ }
258
265
 
259
266
  if (previous !== definition || !exists()) {
260
267
  expectSuccess(schtasks('/Create', '/TN', name, '/XML', path, '/F'), 'schtasks /Create');
@@ -281,6 +288,12 @@ export function taskSchedulerManager(options: TaskSchedulerOptions): ServiceMana
281
288
  },
282
289
  uninstall() {
283
290
  schtasks('/Delete', '/TN', name, '/F');
291
+ const script = scheduledTaskScript(options.files.read(path) ?? '');
292
+
293
+ if (script !== null) {
294
+ options.files.remove(script.path);
295
+ }
296
+
284
297
  options.files.remove(path);
285
298
  }
286
299
  };