@parall/daemon 1.43.0 → 1.45.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.
- package/bundle/manifest.json +11 -11
- package/bundle/parall-browser-pod.js +4 -1
- package/bundle/parall-claude-agent.js +455 -102
- package/bundle/parall-codex-agent.js +1044 -1313
- package/bundle/parall-daemon.js +870 -312
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +229 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/runtime-bin-resolver.d.ts +7 -1
- package/dist/runtime-bin-resolver.d.ts.map +1 -1
- package/dist/runtime-bin-resolver.js +57 -22
- package/dist/runtimes.d.ts +15 -4
- package/dist/runtimes.d.ts.map +1 -1
- package/dist/runtimes.js +60 -5
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +4 -1
- package/dist/win-lifecycle.d.ts +96 -0
- package/dist/win-lifecycle.d.ts.map +1 -0
- package/dist/win-lifecycle.js +229 -0
- package/dist/win-service.d.ts +119 -0
- package/dist/win-service.d.ts.map +1 -0
- package/dist/win-service.js +226 -0
- package/package.json +6 -6
package/dist/supervisor.js
CHANGED
|
@@ -1799,7 +1799,10 @@ export class DaemonSupervisor {
|
|
|
1799
1799
|
child.once('error', (err) => {
|
|
1800
1800
|
if (err.code === 'ENOENT') {
|
|
1801
1801
|
if (adapter.args.length > 0) {
|
|
1802
|
-
|
|
1802
|
+
// Overlay / entry-sibling resolution: bin is the node executable
|
|
1803
|
+
// and args[0] is the bridge bundle js that just resolved on disk.
|
|
1804
|
+
this.log.error(`Node runtime "${adapter.bin}" not found while launching ${adapter.args[0]} — ` +
|
|
1805
|
+
`reinstall the daemon (npm install -g @parall/daemon) or the Desktop app`);
|
|
1803
1806
|
}
|
|
1804
1807
|
else {
|
|
1805
1808
|
const pkg = RUNTIME_PACKAGES[state.runtimeType] ?? `@parall/${state.runtimeType}-agent`;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type WinServicePaths } from './win-service.js';
|
|
2
|
+
/**
|
|
3
|
+
* Windows service lifecycle operations (stop / uninstall / probes) with
|
|
4
|
+
* injected command execution and filesystem, so every failure mode —
|
|
5
|
+
* CIM query failure, access denied, kill failure, task-delete failure —
|
|
6
|
+
* unit-tests on any platform. cli.ts binds the real execFileSync/fs.
|
|
7
|
+
*
|
|
8
|
+
* Fail-closed contract (PR #1876 round 2): tracking state (pidfile,
|
|
9
|
+
* daemon-running marker) and service artifacts are removed ONLY after the
|
|
10
|
+
* relevant facts are POSITIVELY confirmed — task disabled/deleted, process
|
|
11
|
+
* exited. Any indeterminate probe (query failed, unreadable identity)
|
|
12
|
+
* keeps the state on disk and surfaces as a problem + non-zero exit;
|
|
13
|
+
* "could not check" is never treated as "gone".
|
|
14
|
+
*/
|
|
15
|
+
/** One external command execution; implementations must never throw. */
|
|
16
|
+
export interface WinCmdResult {
|
|
17
|
+
/** Process exit code; null = the command could not be spawned at all. */
|
|
18
|
+
code: number | null;
|
|
19
|
+
stdout: string;
|
|
20
|
+
spawnError?: string;
|
|
21
|
+
}
|
|
22
|
+
export type WinCmdRunner = (file: string, args: string[]) => WinCmdResult;
|
|
23
|
+
export interface WinFsOps {
|
|
24
|
+
/** null on any read error (missing file included). */
|
|
25
|
+
readFile(p: string): string | null;
|
|
26
|
+
/** true when the file is gone afterwards (missing counts); false on failure. */
|
|
27
|
+
unlink(p: string): boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface WinLifecycleDeps {
|
|
30
|
+
run: WinCmdRunner;
|
|
31
|
+
fs: WinFsOps;
|
|
32
|
+
paths: WinServicePaths;
|
|
33
|
+
sleep(ms: number): void;
|
|
34
|
+
now(): number;
|
|
35
|
+
}
|
|
36
|
+
export interface WinOpResult {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
problems: string[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Three-state process identity:
|
|
42
|
+
* - 'daemon' — pid exists AND is a Node process running our launcher
|
|
43
|
+
* - 'not-daemon' — pid does not exist, or verifiably belongs to something
|
|
44
|
+
* else (our tracked process has therefore exited)
|
|
45
|
+
* - 'indeterminate' — the question could not be answered (query failed, or
|
|
46
|
+
* the process exists but its identity is unreadable) —
|
|
47
|
+
* callers MUST fail closed on this
|
|
48
|
+
*/
|
|
49
|
+
export type PidIdentity = 'daemon' | 'not-daemon' | 'indeterminate';
|
|
50
|
+
export declare function probePidIdentity(deps: WinLifecycleDeps, pid: number): PidIdentity;
|
|
51
|
+
export declare function readTrackedPid(deps: WinLifecycleDeps): number | null;
|
|
52
|
+
/**
|
|
53
|
+
* Task registration state, three-state and locale-safe.
|
|
54
|
+
*
|
|
55
|
+
* NOT schtasks: `schtasks /Query` exits non-zero for "no such task" AND for
|
|
56
|
+
* access-denied / RPC-unavailable alike, and its error text is localized — so
|
|
57
|
+
* an exit-code probe silently reads a permissions failure as "not installed",
|
|
58
|
+
* which would let stop skip the disable guard and let uninstall wipe tracking
|
|
59
|
+
* state for a task that is still very much registered. That is a fail-open
|
|
60
|
+
* hole in a module whose whole contract is fail-closed.
|
|
61
|
+
*
|
|
62
|
+
* Get-ScheduledTask raises a typed ObjectNotFound error for a missing task and
|
|
63
|
+
* a different category for everything else, so the three states are actually
|
|
64
|
+
* distinguishable. Emitted as an ASCII token, never parsed from a message.
|
|
65
|
+
*/
|
|
66
|
+
export declare function queryTaskExists(deps: WinLifecycleDeps): boolean | 'indeterminate';
|
|
67
|
+
/**
|
|
68
|
+
* Disabled-state readback from /Query /XML — tag names are English on every
|
|
69
|
+
* locale. Our generated triggers are always <Enabled>true</Enabled>, so any
|
|
70
|
+
* false Enabled means the task itself is disabled.
|
|
71
|
+
*/
|
|
72
|
+
export declare function queryTaskDisabled(deps: WinLifecycleDeps): boolean | 'indeterminate';
|
|
73
|
+
/**
|
|
74
|
+
* Stop: disable the task (so no trigger resurrects it), confirm the disabled
|
|
75
|
+
* readback, best-effort /End, then positively confirm the tracked process is
|
|
76
|
+
* gone. Tracking files are removed only when every fact is confirmed.
|
|
77
|
+
*
|
|
78
|
+
* Disabling is a GUARD, not a step — so every one of its failure modes aborts
|
|
79
|
+
* immediately, before anything is terminated (same contract as uninstall). If
|
|
80
|
+
* the task state cannot be read, the /Change fails (access denied), or the
|
|
81
|
+
* readback is not `disabled`, then a trigger — the hourly revival one, or the
|
|
82
|
+
* logon one — is still free to relaunch the daemon. Killing the process in
|
|
83
|
+
* that state would make a failed stop destructive: it could interrupt the
|
|
84
|
+
* daemon while leaving the task armed to relaunch it. Fail closed and leave
|
|
85
|
+
* tracking state untouched instead.
|
|
86
|
+
*/
|
|
87
|
+
export declare function stopDaemonWindows(deps: WinLifecycleDeps): WinOpResult;
|
|
88
|
+
/**
|
|
89
|
+
* Uninstall: full stop semantics first — DISABLE (so no trigger can relaunch
|
|
90
|
+
* the daemon between the process-exit confirmation and /Delete, which would
|
|
91
|
+
* strand an orphan with no task left to manage it), /End, confirmed process
|
|
92
|
+
* exit — then /Delete with a confirmed disappearance readback, then artifact
|
|
93
|
+
* cleanup. Any unconfirmed step aborts before state is destroyed.
|
|
94
|
+
*/
|
|
95
|
+
export declare function uninstallDaemonWindows(deps: WinLifecycleDeps): WinOpResult;
|
|
96
|
+
//# sourceMappingURL=win-lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"win-lifecycle.d.ts","sourceRoot":"","sources":["../src/win-lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,EAAiB,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEvE;;;;;;;;;;;;GAYG;AAEH,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC3B,yEAAyE;IACzE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AACD,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,YAAY,CAAC;AAE1E,MAAM,WAAW,QAAQ;IACvB,sDAAsD;IACtD,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACnC,gFAAgF;IAChF,MAAM,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,YAAY,CAAC;IAClB,EAAE,EAAE,QAAQ,CAAC;IACb,KAAK,EAAE,eAAe,CAAC;IACvB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,IAAI,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,YAAY,GAAG,eAAe,CAAC;AAEpE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB,EAAE,GAAG,EAAE,MAAM,GAAG,WAAW,CAwBjF;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAKpE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,GAAG,eAAe,CAUjF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,GAAG,eAAe,CAInF;AAkDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,GAAG,WAAW,CAoCrE;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,gBAAgB,GAAG,WAAW,CA+D1E"}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { WIN_TASK_NAME } from './win-service.js';
|
|
3
|
+
export function probePidIdentity(deps, pid) {
|
|
4
|
+
// pid comes from readPidFile (validated positive integer) — safe to
|
|
5
|
+
// interpolate. [char]9 = TAB without backtick-escape pitfalls.
|
|
6
|
+
const script = `$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; ` +
|
|
7
|
+
`if ($null -eq $p) { 'ABSENT' } else { 'PRESENT' + [char]9 + $p.Name + [char]9 + "$($p.CommandLine)" }`;
|
|
8
|
+
const r = deps.run('powershell.exe', ['-NoProfile', '-Command', script]);
|
|
9
|
+
if (r.code !== 0 || r.spawnError)
|
|
10
|
+
return 'indeterminate';
|
|
11
|
+
const out = r.stdout.trim();
|
|
12
|
+
if (out === 'ABSENT')
|
|
13
|
+
return 'not-daemon';
|
|
14
|
+
if (!out.startsWith('PRESENT\t'))
|
|
15
|
+
return 'indeterminate';
|
|
16
|
+
const [, name = '', ...rest] = out.split('\t');
|
|
17
|
+
const commandLine = rest.join('\t').trim();
|
|
18
|
+
// Identity = expected image name AND our launcher path on the command
|
|
19
|
+
// line. The extension-less needle covers the current .cjs launcher and the
|
|
20
|
+
// legacy .js one (pre-.cjs installs may still be running).
|
|
21
|
+
if (!/node/i.test(name))
|
|
22
|
+
return 'not-daemon';
|
|
23
|
+
if (!commandLine) {
|
|
24
|
+
// Node-named process whose command line we cannot read (e.g. access
|
|
25
|
+
// denied on an elevated process): could be ours — fail closed.
|
|
26
|
+
return 'indeterminate';
|
|
27
|
+
}
|
|
28
|
+
const launcherNeedle = path.join(deps.paths.serviceDir, 'parall-daemon-launcher').toLowerCase();
|
|
29
|
+
return commandLine.toLowerCase().includes(launcherNeedle) ? 'daemon' : 'not-daemon';
|
|
30
|
+
}
|
|
31
|
+
export function readTrackedPid(deps) {
|
|
32
|
+
const raw = deps.fs.readFile(deps.paths.pidFile);
|
|
33
|
+
if (raw === null)
|
|
34
|
+
return null;
|
|
35
|
+
const pid = Number(raw.trim());
|
|
36
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Task registration state, three-state and locale-safe.
|
|
40
|
+
*
|
|
41
|
+
* NOT schtasks: `schtasks /Query` exits non-zero for "no such task" AND for
|
|
42
|
+
* access-denied / RPC-unavailable alike, and its error text is localized — so
|
|
43
|
+
* an exit-code probe silently reads a permissions failure as "not installed",
|
|
44
|
+
* which would let stop skip the disable guard and let uninstall wipe tracking
|
|
45
|
+
* state for a task that is still very much registered. That is a fail-open
|
|
46
|
+
* hole in a module whose whole contract is fail-closed.
|
|
47
|
+
*
|
|
48
|
+
* Get-ScheduledTask raises a typed ObjectNotFound error for a missing task and
|
|
49
|
+
* a different category for everything else, so the three states are actually
|
|
50
|
+
* distinguishable. Emitted as an ASCII token, never parsed from a message.
|
|
51
|
+
*/
|
|
52
|
+
export function queryTaskExists(deps) {
|
|
53
|
+
const script = `try { Get-ScheduledTask -TaskName '${WIN_TASK_NAME}' -ErrorAction Stop | Out-Null; 'PRESENT' } ` +
|
|
54
|
+
`catch { if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { 'ABSENT' } else { 'ERROR' } }`;
|
|
55
|
+
const r = deps.run('powershell.exe', ['-NoProfile', '-Command', script]);
|
|
56
|
+
if (r.code !== 0 || r.spawnError)
|
|
57
|
+
return 'indeterminate';
|
|
58
|
+
const out = r.stdout.trim();
|
|
59
|
+
if (out === 'PRESENT')
|
|
60
|
+
return true;
|
|
61
|
+
if (out === 'ABSENT')
|
|
62
|
+
return false;
|
|
63
|
+
return 'indeterminate';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Disabled-state readback from /Query /XML — tag names are English on every
|
|
67
|
+
* locale. Our generated triggers are always <Enabled>true</Enabled>, so any
|
|
68
|
+
* false Enabled means the task itself is disabled.
|
|
69
|
+
*/
|
|
70
|
+
export function queryTaskDisabled(deps) {
|
|
71
|
+
const r = deps.run('schtasks', ['/Query', '/TN', WIN_TASK_NAME, '/XML']);
|
|
72
|
+
if (r.code !== 0 || r.spawnError)
|
|
73
|
+
return 'indeterminate';
|
|
74
|
+
return /<Enabled>\s*false\s*<\/Enabled>/i.test(r.stdout);
|
|
75
|
+
}
|
|
76
|
+
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
|
|
77
|
+
const PROCESS_EXIT_POLL_MS = 200;
|
|
78
|
+
/**
|
|
79
|
+
* Confirm the tracked process is gone, killing it first when it is
|
|
80
|
+
* positively ours. Appends to `problems` and returns false on anything
|
|
81
|
+
* indeterminate — the caller must then keep the pidfile.
|
|
82
|
+
*/
|
|
83
|
+
function confirmProcessGone(deps, problems) {
|
|
84
|
+
const pid = readTrackedPid(deps);
|
|
85
|
+
if (pid === null)
|
|
86
|
+
return true; // nothing tracked
|
|
87
|
+
let identity = probePidIdentity(deps, pid);
|
|
88
|
+
if (identity === 'not-daemon')
|
|
89
|
+
return true; // stale pidfile — daemon already exited
|
|
90
|
+
if (identity === 'indeterminate') {
|
|
91
|
+
problems.push(`cannot verify ownership of pid ${pid} (process query failed) — leaving the pidfile in place`);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const kill = deps.run('taskkill', ['/T', '/F', '/PID', String(pid)]);
|
|
95
|
+
const deadline = deps.now() + PROCESS_EXIT_TIMEOUT_MS;
|
|
96
|
+
for (;;) {
|
|
97
|
+
identity = probePidIdentity(deps, pid);
|
|
98
|
+
if (identity === 'not-daemon')
|
|
99
|
+
return true;
|
|
100
|
+
if (identity === 'indeterminate') {
|
|
101
|
+
problems.push(`process query failed while confirming pid ${pid} exited`);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (deps.now() >= deadline) {
|
|
105
|
+
problems.push(`daemon process ${pid} is still running after taskkill` +
|
|
106
|
+
(kill.code !== 0 ? ` (taskkill exit ${kill.code ?? 'spawn-failed'})` : ''));
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
deps.sleep(PROCESS_EXIT_POLL_MS);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function cleanupTracking(deps, problems) {
|
|
113
|
+
if (!deps.fs.unlink(deps.paths.pidFile)) {
|
|
114
|
+
problems.push(`could not remove pidfile ${deps.paths.pidFile}`);
|
|
115
|
+
}
|
|
116
|
+
if (!deps.fs.unlink(deps.paths.runningMarker)) {
|
|
117
|
+
problems.push(`could not remove crash marker ${deps.paths.runningMarker}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Stop: disable the task (so no trigger resurrects it), confirm the disabled
|
|
122
|
+
* readback, best-effort /End, then positively confirm the tracked process is
|
|
123
|
+
* gone. Tracking files are removed only when every fact is confirmed.
|
|
124
|
+
*
|
|
125
|
+
* Disabling is a GUARD, not a step — so every one of its failure modes aborts
|
|
126
|
+
* immediately, before anything is terminated (same contract as uninstall). If
|
|
127
|
+
* the task state cannot be read, the /Change fails (access denied), or the
|
|
128
|
+
* readback is not `disabled`, then a trigger — the hourly revival one, or the
|
|
129
|
+
* logon one — is still free to relaunch the daemon. Killing the process in
|
|
130
|
+
* that state would make a failed stop destructive: it could interrupt the
|
|
131
|
+
* daemon while leaving the task armed to relaunch it. Fail closed and leave
|
|
132
|
+
* tracking state untouched instead.
|
|
133
|
+
*/
|
|
134
|
+
export function stopDaemonWindows(deps) {
|
|
135
|
+
const problems = [];
|
|
136
|
+
const exists = queryTaskExists(deps);
|
|
137
|
+
if (exists === 'indeterminate') {
|
|
138
|
+
problems.push('schtasks could not be executed to query the task');
|
|
139
|
+
return { ok: false, problems };
|
|
140
|
+
}
|
|
141
|
+
if (exists) {
|
|
142
|
+
const disable = deps.run('schtasks', ['/Change', '/TN', WIN_TASK_NAME, '/DISABLE']);
|
|
143
|
+
if (disable.code !== 0) {
|
|
144
|
+
problems.push(`could not disable the task (schtasks /Change exit ${disable.code ?? 'spawn-failed'})`);
|
|
145
|
+
return { ok: false, problems };
|
|
146
|
+
}
|
|
147
|
+
const disabled = queryTaskDisabled(deps);
|
|
148
|
+
if (disabled !== true) {
|
|
149
|
+
problems.push(disabled === 'indeterminate'
|
|
150
|
+
? 'could not read back the task state after disabling'
|
|
151
|
+
: 'task did not read back as disabled');
|
|
152
|
+
return { ok: false, problems };
|
|
153
|
+
}
|
|
154
|
+
// Best-effort accelerator only: its failure modes ("no running
|
|
155
|
+
// instance" vs anything else) are locale-dependent text, and process
|
|
156
|
+
// exit is independently confirmed below — so the result is ignored.
|
|
157
|
+
deps.run('schtasks', ['/End', '/TN', WIN_TASK_NAME]);
|
|
158
|
+
}
|
|
159
|
+
// Task confirmed absent or confirmed disabled: nothing can relaunch the
|
|
160
|
+
// daemon behind us, so terminating the tracked process is safe.
|
|
161
|
+
if (!confirmProcessGone(deps, problems)) {
|
|
162
|
+
return { ok: false, problems };
|
|
163
|
+
}
|
|
164
|
+
cleanupTracking(deps, problems);
|
|
165
|
+
return { ok: problems.length === 0, problems };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Uninstall: full stop semantics first — DISABLE (so no trigger can relaunch
|
|
169
|
+
* the daemon between the process-exit confirmation and /Delete, which would
|
|
170
|
+
* strand an orphan with no task left to manage it), /End, confirmed process
|
|
171
|
+
* exit — then /Delete with a confirmed disappearance readback, then artifact
|
|
172
|
+
* cleanup. Any unconfirmed step aborts before state is destroyed.
|
|
173
|
+
*/
|
|
174
|
+
export function uninstallDaemonWindows(deps) {
|
|
175
|
+
const problems = [];
|
|
176
|
+
const exists = queryTaskExists(deps);
|
|
177
|
+
if (exists === 'indeterminate') {
|
|
178
|
+
problems.push('schtasks could not be executed to query the task');
|
|
179
|
+
return { ok: false, problems };
|
|
180
|
+
}
|
|
181
|
+
if (exists) {
|
|
182
|
+
// Disable BEFORE terminating: the hourly revival TimeTrigger (and the
|
|
183
|
+
// logon trigger) would otherwise be free to start a fresh instance in
|
|
184
|
+
// the gap between the exit confirmation below and /Delete — a daemon the
|
|
185
|
+
// deleted task can no longer stop.
|
|
186
|
+
const disable = deps.run('schtasks', ['/Change', '/TN', WIN_TASK_NAME, '/DISABLE']);
|
|
187
|
+
if (disable.code !== 0) {
|
|
188
|
+
problems.push(`could not disable the task before uninstalling (schtasks /Change exit ${disable.code ?? 'spawn-failed'})`);
|
|
189
|
+
return { ok: false, problems };
|
|
190
|
+
}
|
|
191
|
+
const disabled = queryTaskDisabled(deps);
|
|
192
|
+
if (disabled !== true) {
|
|
193
|
+
problems.push(disabled === 'indeterminate'
|
|
194
|
+
? 'could not read back the task state after disabling'
|
|
195
|
+
: 'task did not read back as disabled before uninstall');
|
|
196
|
+
return { ok: false, problems };
|
|
197
|
+
}
|
|
198
|
+
deps.run('schtasks', ['/End', '/TN', WIN_TASK_NAME]); // best-effort, see stop
|
|
199
|
+
}
|
|
200
|
+
if (!confirmProcessGone(deps, problems)) {
|
|
201
|
+
return { ok: false, problems };
|
|
202
|
+
}
|
|
203
|
+
if (exists) {
|
|
204
|
+
const del = deps.run('schtasks', ['/Delete', '/TN', WIN_TASK_NAME, '/F']);
|
|
205
|
+
if (del.code !== 0) {
|
|
206
|
+
problems.push(`could not delete the task (schtasks /Delete exit ${del.code ?? 'spawn-failed'})`);
|
|
207
|
+
return { ok: false, problems };
|
|
208
|
+
}
|
|
209
|
+
const stillThere = queryTaskExists(deps);
|
|
210
|
+
if (stillThere !== false) {
|
|
211
|
+
problems.push(stillThere === 'indeterminate'
|
|
212
|
+
? 'could not confirm the task was deleted'
|
|
213
|
+
: 'task is still registered after /Delete');
|
|
214
|
+
return { ok: false, problems };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
cleanupTracking(deps, problems);
|
|
218
|
+
for (const artifact of [
|
|
219
|
+
deps.paths.launcherCjs,
|
|
220
|
+
deps.paths.legacyLauncherJs,
|
|
221
|
+
deps.paths.launcherVbs,
|
|
222
|
+
deps.paths.taskXml,
|
|
223
|
+
]) {
|
|
224
|
+
if (!deps.fs.unlink(artifact)) {
|
|
225
|
+
problems.push(`could not remove ${artifact}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { ok: problems.length === 0, problems };
|
|
229
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows service (Task Scheduler) artifact generation. Everything here is a
|
|
3
|
+
* pure function from paths to strings so the whole surface unit-tests on any
|
|
4
|
+
* platform; cli.ts owns filesystem writes and schtasks invocations.
|
|
5
|
+
*
|
|
6
|
+
* The chain at run time:
|
|
7
|
+
* Task Scheduler → wscript.exe //B launcher.vbs
|
|
8
|
+
* → vbs Run("node.exe launcher.js", 0, True) — window hidden, waits,
|
|
9
|
+
* propagates the exit code via WScript.Quit
|
|
10
|
+
* → launcher.js sets PRLL_DAEMON_MANAGED=1, writes the pidfile,
|
|
11
|
+
* redirects stdio to the log file, then imports the daemon bundle
|
|
12
|
+
* IN-PROCESS (overlay-first) — so the pid in the pidfile IS the
|
|
13
|
+
* daemon and a self-update exit(42) is the task's own exit code,
|
|
14
|
+
* which RestartOnFailure turns into a restart on the new overlay.
|
|
15
|
+
*/
|
|
16
|
+
export declare const WIN_TASK_NAME = "ParallDaemon";
|
|
17
|
+
export interface WinServicePaths {
|
|
18
|
+
serviceDir: string;
|
|
19
|
+
/**
|
|
20
|
+
* .cjs, deliberately: the launcher is CommonJS (require) and a package.json
|
|
21
|
+
* with "type": "module" anywhere up the tree (e.g. a user's ~/package.json)
|
|
22
|
+
* would flip a .js file into ESM scope and break require() at startup.
|
|
23
|
+
* .cjs is CommonJS regardless of ancestor package.json files.
|
|
24
|
+
*/
|
|
25
|
+
launcherCjs: string;
|
|
26
|
+
/** Pre-.cjs installs wrote this name; install/uninstall clean it up. */
|
|
27
|
+
legacyLauncherJs: string;
|
|
28
|
+
launcherVbs: string;
|
|
29
|
+
taskXml: string;
|
|
30
|
+
pidFile: string;
|
|
31
|
+
logDir: string;
|
|
32
|
+
logFile: string;
|
|
33
|
+
/**
|
|
34
|
+
* Canonical single-env overlay entry (~/.parall-daemon/bundle/current/...).
|
|
35
|
+
* Deliberately NOT PRLL_DAEMON_CONFIG_DIR-aware — the CLI service installer
|
|
36
|
+
* is single-env by design, mirroring the launchd/systemd wrappers (see
|
|
37
|
+
* ts/daemon/AGENTS.md, config-dir scope caveat).
|
|
38
|
+
*/
|
|
39
|
+
overlayEntry: string;
|
|
40
|
+
/** Crash marker the bootstrap writes; intentional stops must clear it. */
|
|
41
|
+
runningMarker: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function winServicePaths(home: string): WinServicePaths;
|
|
44
|
+
/**
|
|
45
|
+
* VBScript string literal: wrap in quotes, escape embedded quotes by
|
|
46
|
+
* doubling. NTFS forbids `"` in file names, so real paths never exercise
|
|
47
|
+
* the escape — it exists as defense-in-depth for arbitrary strings.
|
|
48
|
+
*/
|
|
49
|
+
export declare function vbsQuote(value: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Encode a generated artifact as UTF-16LE with a BOM. wscript reads BOM-less
|
|
52
|
+
* files in the ANSI codepage (mangling non-ASCII profile paths) and schtasks'
|
|
53
|
+
* canonical XML form is UTF-16 — both .vbs and task.xml go through this.
|
|
54
|
+
*/
|
|
55
|
+
export declare function encodeUtf16LeBom(content: string): Buffer;
|
|
56
|
+
export declare function xmlEscape(value: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* The hidden-window launcher. `Run(cmd, 0, True)`: 0 hides the console
|
|
59
|
+
* window (a console app started by an interactive scheduled task would
|
|
60
|
+
* otherwise flash/persist one), True waits and yields the exit code, and
|
|
61
|
+
* WScript.Quit hands that code to Task Scheduler so RestartOnFailure sees
|
|
62
|
+
* daemon crashes and self-update exit(42)s.
|
|
63
|
+
*
|
|
64
|
+
* Caller must write this file as UTF-16LE with a BOM: wscript reads BOM-less
|
|
65
|
+
* files in the ANSI codepage, which mangles non-ASCII (e.g. Chinese
|
|
66
|
+
* user-profile) paths.
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildLauncherVbs(p: {
|
|
69
|
+
nodeExe: string;
|
|
70
|
+
launcherCjs: string;
|
|
71
|
+
}): string;
|
|
72
|
+
/**
|
|
73
|
+
* The in-process daemon launcher. Embedded paths go through JSON.stringify,
|
|
74
|
+
* which handles backslashes and quotes; the file itself is plain UTF-8 (Node
|
|
75
|
+
* always reads JS as UTF-8, so non-ASCII paths round-trip).
|
|
76
|
+
*
|
|
77
|
+
* The content is CommonJS and the artifact is written as `.cjs` so an
|
|
78
|
+
* ancestor package.json with "type": "module" (users do npm-init their home
|
|
79
|
+
* directories) can never flip it into ESM scope.
|
|
80
|
+
*/
|
|
81
|
+
export declare function buildLauncherCjs(p: {
|
|
82
|
+
npmEntry: string;
|
|
83
|
+
overlayEntry: string;
|
|
84
|
+
pidFile: string;
|
|
85
|
+
logFile: string;
|
|
86
|
+
}): string;
|
|
87
|
+
/**
|
|
88
|
+
* Task Scheduler task definition.
|
|
89
|
+
*
|
|
90
|
+
* Element-order ground rules (Microsoft Task Scheduler schema docs):
|
|
91
|
+
* - trigger children (triggerBaseType) are an xs:sequence and MUST appear as
|
|
92
|
+
* StartBoundary → EndBoundary → Enabled → Repetition → ExecutionTimeLimit
|
|
93
|
+
* (learn.microsoft.com .../taskschedulerschema-timetrigger-triggergroup-element);
|
|
94
|
+
* - Settings children (settingsType) are an xs:all — order-insensitive;
|
|
95
|
+
* - RestartOnFailure Count is xs:unsignedByte (1–255)
|
|
96
|
+
* (.../taskschedulerschema-count-restarttype-element).
|
|
97
|
+
*
|
|
98
|
+
* - LogonTrigger: start at user logon (no admin, no stored password:
|
|
99
|
+
* InteractiveToken + omitted UserId registers for the calling user).
|
|
100
|
+
* - TimeTrigger PT1H: revival tick (StartBoundary is a fixed date safely in
|
|
101
|
+
* the past — a boundary in the future would silently disarm the trigger on
|
|
102
|
+
* a machine whose clock is behind) — if RestartOnFailure's count is ever
|
|
103
|
+
* exhausted the daemon still comes back within the hour; IgnoreNew makes
|
|
104
|
+
* it a no-op while an instance is running. `stop` disables the whole task,
|
|
105
|
+
* so this trigger never resurrects an intentionally stopped daemon.
|
|
106
|
+
* - ExecutionTimeLimit PT0S: no 72h default kill.
|
|
107
|
+
* - RestartOnFailure PT1M/99: crash or self-update exit(42) restarts in
|
|
108
|
+
* ~1 minute (PT1M is the schema minimum interval; 99 stays well inside the
|
|
109
|
+
* unsignedByte cap). Note this makes Windows self-update restarts ~1min,
|
|
110
|
+
* vs ~5s under launchd.
|
|
111
|
+
*
|
|
112
|
+
* Caller must write this as UTF-16LE with BOM to match the declared
|
|
113
|
+
* encoding — the canonical form schtasks itself produces on export.
|
|
114
|
+
*/
|
|
115
|
+
export declare function buildTaskXml(p: {
|
|
116
|
+
wscriptExe: string;
|
|
117
|
+
launcherVbs: string;
|
|
118
|
+
}): string;
|
|
119
|
+
//# sourceMappingURL=win-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"win-service.d.ts","sourceRoot":"","sources":["../src/win-service.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AAEH,eAAO,MAAM,aAAa,iBAAiB,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAgB7D;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAO/C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CASpF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB,GAAG,MAAM,CA6DT;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CA+CnF"}
|