@zswarm/core 0.2.4 → 0.2.5
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/dist/host/cli.js +9 -2
- package/dist/host/crew-windows.d.ts +155 -0
- package/dist/host/crew-windows.js +556 -0
- package/dist/host/crew.d.ts +32 -2
- package/dist/host/crew.js +212 -23
- package/dist/host/services.d.ts +7 -4
- package/dist/host/services.js +18 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/dist/host/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { ZellijError } from "../errors.js";
|
|
3
3
|
import { createStateStore } from "../state.js";
|
|
4
|
-
import { crewCheck, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
|
|
4
|
+
import { crewCheck, crewClient, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
|
|
5
5
|
import { hostDoctor, hostInstall, hostInstallArgs, hostScriptPath } from "./host.js";
|
|
6
6
|
import { listLeases } from "./lease.js";
|
|
7
7
|
import { cancelAllTo, cancelRelay, listRelays, pruneRelays, runRelayDir, startRelay, waitForRelay } from "./relay.js";
|
|
@@ -75,6 +75,10 @@ async function crew(argv) {
|
|
|
75
75
|
return crewStart(session);
|
|
76
76
|
case "check":
|
|
77
77
|
return crewCheck(session);
|
|
78
|
+
case "client":
|
|
79
|
+
// What the minimized console window on Windows runs: attach (and create)
|
|
80
|
+
// the session so its panes render and take input.
|
|
81
|
+
return crewClient(session);
|
|
78
82
|
default:
|
|
79
83
|
process.stderr.write(`${COMMANDS.crew.usage}\n`);
|
|
80
84
|
return 2;
|
|
@@ -227,10 +231,13 @@ const COMMANDS = {
|
|
|
227
231
|
crew: {
|
|
228
232
|
usage: [
|
|
229
233
|
"zswarm crew up <session> --layout L.kdl [--config C.kdl] [--env-file F] [--first-pane NAME] [--run-as USER]",
|
|
230
|
-
" keep the session running from its layout (service + 2-min watchdog
|
|
234
|
+
" keep the session running from its layout (service + 2-min watchdog;",
|
|
235
|
+
" on Windows: Scheduled Tasks under \\zswarm\\ + an attached client window)",
|
|
231
236
|
"zswarm crew status <session> [--run-as USER] services, session, panes",
|
|
232
237
|
"zswarm crew down <session> [--run-as USER] remove the services and kill the session",
|
|
233
238
|
"zswarm crew start|check <session> what the service and the watchdog run",
|
|
239
|
+
"zswarm crew client <session> what the minimized console window runs (Windows)",
|
|
240
|
+
"--run-as installs for another account and is Linux/macOS only; a Windows crew runs as the signed-in user",
|
|
234
241
|
].join("\n"),
|
|
235
242
|
run: crew,
|
|
236
243
|
},
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows half of `zswarm crew`.
|
|
3
|
+
*
|
|
4
|
+
* A background Zellij session started with `zellij attach --create-background`
|
|
5
|
+
* on Windows renders empty pane grids and drops pasted input, because nothing
|
|
6
|
+
* is attached in a real console. The fix is two Scheduled Tasks under
|
|
7
|
+
* `\zswarm\` plus a minimized attached-client console window:
|
|
8
|
+
*
|
|
9
|
+
* zswarm-crew-<session> starts the session (interactive, highest)
|
|
10
|
+
* zswarm-crew-<session>-watch `crew check` every two minutes, so the crew
|
|
11
|
+
* restarts only when the session or its first
|
|
12
|
+
* layout pane is gone, and the client window is
|
|
13
|
+
* reopened when it is gone but the session lives
|
|
14
|
+
*
|
|
15
|
+
* This module is pure PowerShell text (unit-tested by text) plus thin async
|
|
16
|
+
* wrappers. It deliberately mirrors `buildServeTaskScript` in
|
|
17
|
+
* `ops/serve-install.ts`: same preamble helpers, same base64 UTF-8 round-trip
|
|
18
|
+
* for the cmd.exe action body, same `-EncodedCommand` invocation. It must not
|
|
19
|
+
* import `./crew.js`; that module imports this one.
|
|
20
|
+
*/
|
|
21
|
+
/** Scheduled-task folder every crew task lives in. */
|
|
22
|
+
export declare const CREW_TASK_FOLDER = "\\zswarm\\";
|
|
23
|
+
/** Watchdog cadence, matching the Linux/macOS `crew-<s>-watch` timer. */
|
|
24
|
+
export declare const CREW_WATCH_MINUTES = 2;
|
|
25
|
+
export type PowerShellResult = {
|
|
26
|
+
code: number;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
};
|
|
30
|
+
export type PowerShellExec = (script: string, options: {
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}) => Promise<PowerShellResult>;
|
|
34
|
+
export type CrewTaskNames = {
|
|
35
|
+
folder: string;
|
|
36
|
+
start: string;
|
|
37
|
+
watch: string;
|
|
38
|
+
};
|
|
39
|
+
export type CrewTaskState = {
|
|
40
|
+
/** Which of the two crew tasks this is. */
|
|
41
|
+
name: "start" | "watch";
|
|
42
|
+
/** Task name only, e.g. `zswarm-crew-crew`. */
|
|
43
|
+
task: string;
|
|
44
|
+
/** `\zswarm\zswarm-crew-crew`. */
|
|
45
|
+
path: string;
|
|
46
|
+
/** `Ready` | `Running` | `Disabled` | `missing` ... */
|
|
47
|
+
state: string;
|
|
48
|
+
/** Registered at all: Ready/Running/Disabled count; `missing` does not. */
|
|
49
|
+
active: boolean;
|
|
50
|
+
lastResult: number | null;
|
|
51
|
+
lastRun: string | null;
|
|
52
|
+
};
|
|
53
|
+
export type CrewClientWindow = {
|
|
54
|
+
present: boolean;
|
|
55
|
+
pid: number | null;
|
|
56
|
+
count: number;
|
|
57
|
+
};
|
|
58
|
+
/** `zswarm-crew-<session>`: task names, and the client window's title marker. */
|
|
59
|
+
export declare function crewTaskNames(session: string): CrewTaskNames;
|
|
60
|
+
/** The token that proves a console window is this crew's client: its title and
|
|
61
|
+
* the string in its launcher's command line. */
|
|
62
|
+
export declare function crewClientMarker(session: string): string;
|
|
63
|
+
/** The cmd.exe action body for a task: `set "K=V"&& ...` then the CLI argv. */
|
|
64
|
+
export declare function crewTaskCommand(input: {
|
|
65
|
+
launch: {
|
|
66
|
+
execPath: string;
|
|
67
|
+
scriptPath: string;
|
|
68
|
+
};
|
|
69
|
+
mode: "start" | "check";
|
|
70
|
+
session: string;
|
|
71
|
+
env: NodeJS.ProcessEnv;
|
|
72
|
+
}): string;
|
|
73
|
+
/** Pure PowerShell text, unit-tested by text. */
|
|
74
|
+
export declare function buildCrewTaskScript(action: "register" | "inspect" | "start" | "unregister", params: {
|
|
75
|
+
session: string;
|
|
76
|
+
startCommand?: string;
|
|
77
|
+
watchCommand?: string;
|
|
78
|
+
intervalMinutes?: number;
|
|
79
|
+
}): string;
|
|
80
|
+
/**
|
|
81
|
+
* The command line the client window runs. The marker is both the window title
|
|
82
|
+
* and the token `ensure`/`close` look for in a process command line; the crew
|
|
83
|
+
* CLI does the actual `zellij attach --create`, so the window can read the crew
|
|
84
|
+
* dir (and its env file) itself instead of taking secrets on a command line.
|
|
85
|
+
*/
|
|
86
|
+
export declare function crewClientCommand(input: {
|
|
87
|
+
session: string;
|
|
88
|
+
launch: {
|
|
89
|
+
execPath: string;
|
|
90
|
+
scriptPath: string;
|
|
91
|
+
};
|
|
92
|
+
}): string;
|
|
93
|
+
/** The minimized attached-client window that makes a Windows Zellij session render. */
|
|
94
|
+
export declare function buildCrewClientScript(action: "ensure" | "close", params: {
|
|
95
|
+
session: string;
|
|
96
|
+
clientCommand?: string;
|
|
97
|
+
}): string;
|
|
98
|
+
/** Last line that parses as a JSON object, else the first; null when none does. */
|
|
99
|
+
export declare function parseCrewJson(stdout: string): Record<string, unknown> | null;
|
|
100
|
+
/** Powershell runner used in production; injected in tests. Never throws. */
|
|
101
|
+
export declare const defaultPowerShellExec: PowerShellExec;
|
|
102
|
+
export declare function inspectCrewTasks(input: {
|
|
103
|
+
session: string;
|
|
104
|
+
runPowerShell: PowerShellExec;
|
|
105
|
+
timeoutMs?: number;
|
|
106
|
+
signal?: AbortSignal;
|
|
107
|
+
}): Promise<{
|
|
108
|
+
tasks: CrewTaskState[];
|
|
109
|
+
client: CrewClientWindow;
|
|
110
|
+
}>;
|
|
111
|
+
export declare function installCrewTasks(input: {
|
|
112
|
+
session: string;
|
|
113
|
+
startCommand: string;
|
|
114
|
+
watchCommand: string;
|
|
115
|
+
intervalMinutes?: number;
|
|
116
|
+
runPowerShell: PowerShellExec;
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
signal?: AbortSignal;
|
|
119
|
+
}): Promise<{
|
|
120
|
+
lines: string[];
|
|
121
|
+
notes: string[];
|
|
122
|
+
tasks: CrewTaskState[];
|
|
123
|
+
client: CrewClientWindow;
|
|
124
|
+
}>;
|
|
125
|
+
export declare function removeCrewTasks(input: {
|
|
126
|
+
session: string;
|
|
127
|
+
runPowerShell: PowerShellExec;
|
|
128
|
+
timeoutMs?: number;
|
|
129
|
+
signal?: AbortSignal;
|
|
130
|
+
}): Promise<{
|
|
131
|
+
lines: string[];
|
|
132
|
+
tasks: CrewTaskState[];
|
|
133
|
+
}>;
|
|
134
|
+
export declare function ensureCrewClient(input: {
|
|
135
|
+
session: string;
|
|
136
|
+
clientCommand: string;
|
|
137
|
+
runPowerShell: PowerShellExec;
|
|
138
|
+
timeoutMs?: number;
|
|
139
|
+
signal?: AbortSignal;
|
|
140
|
+
}): Promise<{
|
|
141
|
+
opened: boolean;
|
|
142
|
+
pid: number | null;
|
|
143
|
+
title: string;
|
|
144
|
+
present: boolean;
|
|
145
|
+
count: number;
|
|
146
|
+
}>;
|
|
147
|
+
export declare function closeCrewClient(input: {
|
|
148
|
+
session: string;
|
|
149
|
+
runPowerShell: PowerShellExec;
|
|
150
|
+
timeoutMs?: number;
|
|
151
|
+
signal?: AbortSignal;
|
|
152
|
+
}): Promise<{
|
|
153
|
+
closed: number[];
|
|
154
|
+
count: number;
|
|
155
|
+
}>;
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { ZellijError } from "../errors.js";
|
|
3
|
+
import { encodePowerShellCommand } from "../zellij/ipc.js";
|
|
4
|
+
/**
|
|
5
|
+
* Windows half of `zswarm crew`.
|
|
6
|
+
*
|
|
7
|
+
* A background Zellij session started with `zellij attach --create-background`
|
|
8
|
+
* on Windows renders empty pane grids and drops pasted input, because nothing
|
|
9
|
+
* is attached in a real console. The fix is two Scheduled Tasks under
|
|
10
|
+
* `\zswarm\` plus a minimized attached-client console window:
|
|
11
|
+
*
|
|
12
|
+
* zswarm-crew-<session> starts the session (interactive, highest)
|
|
13
|
+
* zswarm-crew-<session>-watch `crew check` every two minutes, so the crew
|
|
14
|
+
* restarts only when the session or its first
|
|
15
|
+
* layout pane is gone, and the client window is
|
|
16
|
+
* reopened when it is gone but the session lives
|
|
17
|
+
*
|
|
18
|
+
* This module is pure PowerShell text (unit-tested by text) plus thin async
|
|
19
|
+
* wrappers. It deliberately mirrors `buildServeTaskScript` in
|
|
20
|
+
* `ops/serve-install.ts`: same preamble helpers, same base64 UTF-8 round-trip
|
|
21
|
+
* for the cmd.exe action body, same `-EncodedCommand` invocation. It must not
|
|
22
|
+
* import `./crew.js`; that module imports this one.
|
|
23
|
+
*/
|
|
24
|
+
/** Scheduled-task folder every crew task lives in. */
|
|
25
|
+
export const CREW_TASK_FOLDER = "\\zswarm\\";
|
|
26
|
+
/** Watchdog cadence, matching the Linux/macOS `crew-<s>-watch` timer. */
|
|
27
|
+
export const CREW_WATCH_MINUTES = 2;
|
|
28
|
+
/** Same shape the `zswarm crew` session validator uses; duplicated to avoid a cycle. */
|
|
29
|
+
const CREW_SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
30
|
+
/** Overall helper deadline when `timeoutMs` is omitted. Registering two tasks is two calls. */
|
|
31
|
+
const DEFAULT_CREW_TIMEOUT_MS = 20_000;
|
|
32
|
+
function checkSession(session) {
|
|
33
|
+
const text = session?.trim() ?? "";
|
|
34
|
+
if (!CREW_SESSION.test(text)) {
|
|
35
|
+
throw new ZellijError("bad_arg", `invalid session name ${JSON.stringify(session)}`);
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
/** `zswarm-crew-<session>`: task names, and the client window's title marker. */
|
|
40
|
+
export function crewTaskNames(session) {
|
|
41
|
+
const start = `zswarm-crew-${checkSession(session)}`;
|
|
42
|
+
return { folder: CREW_TASK_FOLDER, start, watch: `${start}-watch` };
|
|
43
|
+
}
|
|
44
|
+
/** The token that proves a console window is this crew's client: its title and
|
|
45
|
+
* the string in its launcher's command line. */
|
|
46
|
+
export function crewClientMarker(session) {
|
|
47
|
+
return `zswarm-crew-${checkSession(session)}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A task registration is a readable, long-lived artifact, so only the two
|
|
51
|
+
* variables a crew task cannot discover on its own are serialized — never the
|
|
52
|
+
* caller's whole environment, which can hold tokens. PATH is deliberately not
|
|
53
|
+
* one of them: the task runs as the signed-in user and inherits their PATH, and
|
|
54
|
+
* a host PATH (kilometres of it on Windows) would blow the ~32k Windows
|
|
55
|
+
* command-line limit once the action is base64-encoded inside this script.
|
|
56
|
+
*/
|
|
57
|
+
const CREW_TASK_ENV_KEYS = ["ZSWARM_BIN", "ZSWARM_STATE_DIR"];
|
|
58
|
+
function crewEnvAssignments(env) {
|
|
59
|
+
const assignments = [];
|
|
60
|
+
for (const key of CREW_TASK_ENV_KEYS) {
|
|
61
|
+
const value = env[key]?.trim();
|
|
62
|
+
if (!value)
|
|
63
|
+
continue;
|
|
64
|
+
if (/[\0\r\n"%]/.test(value)) {
|
|
65
|
+
throw new ZellijError("bad_arg", `${key} must not contain quotes, newlines, or %`);
|
|
66
|
+
}
|
|
67
|
+
assignments.push(`set "${key}=${value}"`);
|
|
68
|
+
}
|
|
69
|
+
return assignments;
|
|
70
|
+
}
|
|
71
|
+
/** The cmd.exe action body for a task: `set "K=V"&& ...` then the CLI argv. */
|
|
72
|
+
export function crewTaskCommand(input) {
|
|
73
|
+
if (input.mode !== "start" && input.mode !== "check") {
|
|
74
|
+
throw new ZellijError("bad_arg", `unknown crew task mode ${String(input.mode)}`);
|
|
75
|
+
}
|
|
76
|
+
const session = checkSession(input.session);
|
|
77
|
+
for (const [label, value] of [
|
|
78
|
+
["execPath", input.launch.execPath],
|
|
79
|
+
["scriptPath", input.launch.scriptPath],
|
|
80
|
+
]) {
|
|
81
|
+
if (!value || /[\0\r\n"]/.test(value)) {
|
|
82
|
+
throw new ZellijError("bad_arg", `crew task ${label} must not contain quotes or newlines`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const argv = `"${input.launch.execPath}" "${input.launch.scriptPath}" crew ${input.mode} ${session}`;
|
|
86
|
+
const assignments = crewEnvAssignments(input.env);
|
|
87
|
+
const line = assignments.length === 0 ? argv : `${assignments.join("&& ")}&& ${argv}`;
|
|
88
|
+
// The task action is `cmd.exe /c <line>`, and cmd strips the first and last
|
|
89
|
+
// quote of a `/c "..."` tail: on a line that starts with the program's own
|
|
90
|
+
// quote that costs the program its opening quote ("The filename, directory
|
|
91
|
+
// name, or volume label syntax is incorrect"). Wrapping the whole line in one
|
|
92
|
+
// more pair makes the stripping harmless — the line still ends with the
|
|
93
|
+
// unquoted session name, so nothing after it is lost.
|
|
94
|
+
return `"${line}"`;
|
|
95
|
+
}
|
|
96
|
+
const PS_PREAMBLE = [
|
|
97
|
+
"$ErrorActionPreference = 'Stop'",
|
|
98
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
99
|
+
"[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false",
|
|
100
|
+
"function Get-ZswarmIdentity {",
|
|
101
|
+
" $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()",
|
|
102
|
+
" if (-not $id -or -not $id.User -or [string]::IsNullOrWhiteSpace($id.User.Value)) {",
|
|
103
|
+
" throw 'cannot resolve the current Windows SID for the Interactive crew task'",
|
|
104
|
+
" }",
|
|
105
|
+
" $name = [string]$id.Name",
|
|
106
|
+
" if ([string]::IsNullOrWhiteSpace($name)) {",
|
|
107
|
+
" throw 'cannot resolve the current Windows account name for the Interactive crew task'",
|
|
108
|
+
" }",
|
|
109
|
+
" return @{ name = $name; sid = [string]$id.User.Value }",
|
|
110
|
+
"}",
|
|
111
|
+
"function Resolve-ZswarmSid($account) {",
|
|
112
|
+
" if ($null -eq $account) { return $null }",
|
|
113
|
+
" $text = [string]$account",
|
|
114
|
+
" if ([string]::IsNullOrWhiteSpace($text)) { return $null }",
|
|
115
|
+
" if ($text -match '^S-\\d-') { return $text }",
|
|
116
|
+
" try {",
|
|
117
|
+
" $nt = New-Object System.Security.Principal.NTAccount($text)",
|
|
118
|
+
" $sid = $nt.Translate([System.Security.Principal.SecurityIdentifier])",
|
|
119
|
+
" if (-not $sid -or [string]::IsNullOrWhiteSpace($sid.Value)) { return $null }",
|
|
120
|
+
" return [string]$sid.Value",
|
|
121
|
+
" } catch {",
|
|
122
|
+
" return $null",
|
|
123
|
+
" }",
|
|
124
|
+
"}",
|
|
125
|
+
"function Assert-ZswarmCurrentOwner($task) {",
|
|
126
|
+
" $identity = Get-ZswarmIdentity",
|
|
127
|
+
" $existingSid = Resolve-ZswarmSid ([string]$task.Principal.UserId)",
|
|
128
|
+
" if ([string]::IsNullOrWhiteSpace($existingSid) -or $existingSid -ne $identity.sid) {",
|
|
129
|
+
" throw 'a zswarm crew task is owned by a different or unverified Windows account'",
|
|
130
|
+
" }",
|
|
131
|
+
" return $identity",
|
|
132
|
+
"}",
|
|
133
|
+
"function Write-ZswarmJson($obj) {",
|
|
134
|
+
" $json = $obj | ConvertTo-Json -Compress -Depth 6",
|
|
135
|
+
" [Console]::Out.Write($json)",
|
|
136
|
+
"}",
|
|
137
|
+
`function Get-ZswarmTask($name) { Get-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}' -ErrorAction Stop }`,
|
|
138
|
+
"function Test-ZswarmMissing($err) {",
|
|
139
|
+
" return $err.CategoryInfo.Category -eq 'ObjectNotFound' -or ([string]$err.FullyQualifiedErrorId -match 'ObjectNotFound')",
|
|
140
|
+
"}",
|
|
141
|
+
].join("\n");
|
|
142
|
+
/** The client scripts do not schedule anything, so they only need JSON output. */
|
|
143
|
+
const PS_CLIENT_PREAMBLE = [
|
|
144
|
+
"$ErrorActionPreference = 'Stop'",
|
|
145
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
146
|
+
"[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false",
|
|
147
|
+
"function Write-ZswarmJson($obj) {",
|
|
148
|
+
" $json = $obj | ConvertTo-Json -Compress -Depth 6",
|
|
149
|
+
" [Console]::Out.Write($json)",
|
|
150
|
+
"}",
|
|
151
|
+
].join("\n");
|
|
152
|
+
/**
|
|
153
|
+
* PIDs of our client windows. Image name alone is never identity: the marker
|
|
154
|
+
* must be in the command line, because every other `powershell.exe` (including
|
|
155
|
+
* this helper's own `-EncodedCommand` process) has an unrelated command line.
|
|
156
|
+
*/
|
|
157
|
+
function psClientPidLine(marker) {
|
|
158
|
+
// Match the whole `title <marker> &&` token crewClientCommand writes, not the
|
|
159
|
+
// bare marker: crew "crew" must never match crew "crew-2"'s window.
|
|
160
|
+
return `$clientPids = @(Get-CimInstance Win32_Process | Where-Object { ($_.Name -eq 'cmd.exe' -or $_.Name -eq 'powershell.exe') -and $_.CommandLine -and $_.CommandLine.Contains('title ${marker} &&') } | ForEach-Object { [int]$_.ProcessId })`;
|
|
161
|
+
}
|
|
162
|
+
/** Pure PowerShell text, unit-tested by text. */
|
|
163
|
+
export function buildCrewTaskScript(action, params) {
|
|
164
|
+
const names = crewTaskNames(params.session);
|
|
165
|
+
if (action === "register") {
|
|
166
|
+
// Base64 UTF-8 round-trip, exactly like serve's register: neither the
|
|
167
|
+
// command nor its quoting ever appears in the script text.
|
|
168
|
+
const startB64 = Buffer.from(params.startCommand ?? "", "utf8").toString("base64");
|
|
169
|
+
const watchB64 = Buffer.from(params.watchCommand ?? "", "utf8").toString("base64");
|
|
170
|
+
const interval = Math.max(1, Math.floor(params.intervalMinutes ?? CREW_WATCH_MINUTES));
|
|
171
|
+
return [
|
|
172
|
+
PS_PREAMBLE,
|
|
173
|
+
`$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${startB64}'))`,
|
|
174
|
+
`$startName = '${names.start}'`,
|
|
175
|
+
`$watchName = '${names.watch}'`,
|
|
176
|
+
"$identity = Get-ZswarmIdentity",
|
|
177
|
+
"foreach ($name in @($startName, $watchName)) {",
|
|
178
|
+
" try {",
|
|
179
|
+
" $existing = Get-ZswarmTask $name",
|
|
180
|
+
" [void](Assert-ZswarmCurrentOwner $existing)",
|
|
181
|
+
" } catch {",
|
|
182
|
+
" if (-not (Test-ZswarmMissing $_)) { throw }",
|
|
183
|
+
" }",
|
|
184
|
+
"}",
|
|
185
|
+
"$userId = $identity.name",
|
|
186
|
+
// Highest, unlike serve's Limited: the crew must own the desktop session
|
|
187
|
+
// and its Zellij console window.
|
|
188
|
+
"$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Highest",
|
|
189
|
+
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable",
|
|
190
|
+
"$startAction = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c ' + $cmd)",
|
|
191
|
+
"$startTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId",
|
|
192
|
+
`Register-ScheduledTask -TaskName $startName -TaskPath '${CREW_TASK_FOLDER}' -Action $startAction -Principal $principal -Trigger $startTrigger -Settings $settings -Force | Out-Null`,
|
|
193
|
+
`$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${watchB64}'))`,
|
|
194
|
+
"$watchAction = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c ' + $cmd)",
|
|
195
|
+
// A once-trigger with a long repetition is the supported way to run an
|
|
196
|
+
// interval task for a user; an AtLogOn trigger would only run once.
|
|
197
|
+
`$watchTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes ${interval}) -RepetitionDuration (New-TimeSpan -Days 3650)`,
|
|
198
|
+
`Register-ScheduledTask -TaskName $watchName -TaskPath '${CREW_TASK_FOLDER}' -Action $watchAction -Principal $principal -Trigger $watchTrigger -Settings $settings -Force | Out-Null`,
|
|
199
|
+
"Write-ZswarmJson @{",
|
|
200
|
+
" registered = $true",
|
|
201
|
+
" userId = $userId",
|
|
202
|
+
" userSid = $identity.sid",
|
|
203
|
+
" logonType = 'Interactive'",
|
|
204
|
+
" runLevel = 'Highest'",
|
|
205
|
+
` folder = '${CREW_TASK_FOLDER}'`,
|
|
206
|
+
" startTask = $startName",
|
|
207
|
+
" watchTask = $watchName",
|
|
208
|
+
"}",
|
|
209
|
+
].join("\n");
|
|
210
|
+
}
|
|
211
|
+
if (action === "inspect") {
|
|
212
|
+
const marker = crewClientMarker(params.session);
|
|
213
|
+
return [
|
|
214
|
+
PS_PREAMBLE,
|
|
215
|
+
`$startName = '${names.start}'`,
|
|
216
|
+
`$watchName = '${names.watch}'`,
|
|
217
|
+
`$marker = '${marker}'`,
|
|
218
|
+
"$tasks = @{}",
|
|
219
|
+
"foreach ($pair in @(@{ key = 'start'; name = $startName }, @{ key = 'watch'; name = $watchName })) {",
|
|
220
|
+
" try {",
|
|
221
|
+
" $task = Get-ZswarmTask $pair.name",
|
|
222
|
+
" } catch {",
|
|
223
|
+
" if (Test-ZswarmMissing $_) {",
|
|
224
|
+
" $tasks[$pair.key] = @{ exists = $false; state = 'missing'; lastResult = $null; lastRunTime = $null; userId = $null; userSid = $null; logonType = $null; runLevel = $null; execute = $null; arguments = $null }",
|
|
225
|
+
" continue",
|
|
226
|
+
" }",
|
|
227
|
+
" throw",
|
|
228
|
+
" }",
|
|
229
|
+
" $action = @($task.Actions)[0]",
|
|
230
|
+
` $info = Get-ScheduledTaskInfo -TaskName $pair.name -TaskPath '${CREW_TASK_FOLDER}'`,
|
|
231
|
+
" $lastRun = $null",
|
|
232
|
+
" if ($info.LastRunTime -and $info.LastRunTime.Year -gt 1900) { $lastRun = $info.LastRunTime.ToString('o') }",
|
|
233
|
+
" $tasks[$pair.key] = @{",
|
|
234
|
+
" exists = $true",
|
|
235
|
+
" state = [string]$task.State",
|
|
236
|
+
" lastResult = $info.LastTaskResult",
|
|
237
|
+
" lastRunTime = $lastRun",
|
|
238
|
+
" userId = [string]$task.Principal.UserId",
|
|
239
|
+
" userSid = (Resolve-ZswarmSid ([string]$task.Principal.UserId))",
|
|
240
|
+
" logonType = [string]$task.Principal.LogonType",
|
|
241
|
+
" runLevel = [string]$task.Principal.RunLevel",
|
|
242
|
+
" execute = [string]$action.Execute",
|
|
243
|
+
" arguments = [string]$action.Arguments",
|
|
244
|
+
" }",
|
|
245
|
+
"}",
|
|
246
|
+
psClientPidLine(marker),
|
|
247
|
+
"Write-ZswarmJson @{",
|
|
248
|
+
" start = $tasks['start']",
|
|
249
|
+
" watch = $tasks['watch']",
|
|
250
|
+
" clientPresent = ($clientPids.Count -gt 0)",
|
|
251
|
+
" clientPids = @($clientPids)",
|
|
252
|
+
" clientCount = $clientPids.Count",
|
|
253
|
+
"}",
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
|
256
|
+
if (action === "start") {
|
|
257
|
+
return [
|
|
258
|
+
PS_PREAMBLE,
|
|
259
|
+
`$startName = '${names.start}'`,
|
|
260
|
+
`$watchName = '${names.watch}'`,
|
|
261
|
+
"foreach ($name in @($startName, $watchName)) {",
|
|
262
|
+
" try {",
|
|
263
|
+
" $task = Get-ZswarmTask $name",
|
|
264
|
+
" [void](Assert-ZswarmCurrentOwner $task)",
|
|
265
|
+
` Start-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}'`,
|
|
266
|
+
" } catch {",
|
|
267
|
+
" if (-not (Test-ZswarmMissing $_)) { throw }",
|
|
268
|
+
" }",
|
|
269
|
+
"}",
|
|
270
|
+
"Write-ZswarmJson @{ started = $true; startTask = $startName; watchTask = $watchName }",
|
|
271
|
+
].join("\n");
|
|
272
|
+
}
|
|
273
|
+
return [
|
|
274
|
+
PS_PREAMBLE,
|
|
275
|
+
`$startName = '${names.start}'`,
|
|
276
|
+
`$watchName = '${names.watch}'`,
|
|
277
|
+
"foreach ($name in @($startName, $watchName)) {",
|
|
278
|
+
" try {",
|
|
279
|
+
" $task = Get-ZswarmTask $name",
|
|
280
|
+
" [void](Assert-ZswarmCurrentOwner $task)",
|
|
281
|
+
" try {",
|
|
282
|
+
` Stop-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}'`,
|
|
283
|
+
" } catch {",
|
|
284
|
+
" if (-not (Test-ZswarmMissing $_)) { throw }",
|
|
285
|
+
" }",
|
|
286
|
+
` Unregister-ScheduledTask -TaskName $name -TaskPath '${CREW_TASK_FOLDER}' -Confirm:$false`,
|
|
287
|
+
" } catch {",
|
|
288
|
+
" if (-not (Test-ZswarmMissing $_)) { throw }",
|
|
289
|
+
" }",
|
|
290
|
+
"}",
|
|
291
|
+
"Write-ZswarmJson @{ cleared = $true; startTask = $startName; watchTask = $watchName }",
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The command line the client window runs. The marker is both the window title
|
|
296
|
+
* and the token `ensure`/`close` look for in a process command line; the crew
|
|
297
|
+
* CLI does the actual `zellij attach --create`, so the window can read the crew
|
|
298
|
+
* dir (and its env file) itself instead of taking secrets on a command line.
|
|
299
|
+
*/
|
|
300
|
+
export function crewClientCommand(input) {
|
|
301
|
+
const session = checkSession(input.session);
|
|
302
|
+
const quote = (value) => `"${value.replace(/"/g, '""')}"`;
|
|
303
|
+
return `title ${crewClientMarker(session)} && ${quote(input.launch.execPath)} ${quote(input.launch.scriptPath)} crew client ${session}`;
|
|
304
|
+
}
|
|
305
|
+
/** The minimized attached-client window that makes a Windows Zellij session render. */
|
|
306
|
+
export function buildCrewClientScript(action, params) {
|
|
307
|
+
const session = checkSession(params.session);
|
|
308
|
+
const marker = crewClientMarker(session);
|
|
309
|
+
if (action === "ensure") {
|
|
310
|
+
// `cmd.exe /c <line>`: Start-Process joins -ArgumentList with spaces and
|
|
311
|
+
// does not re-quote the elements, so cmd.exe gets the whole line as its /c
|
|
312
|
+
// command with no quoting layers of our own. `title` names the window and
|
|
313
|
+
// puts the marker in the launcher's command line, which is what later finds
|
|
314
|
+
// and closes this window. The launcher stays alive while its child runs, so
|
|
315
|
+
// its process is also the tree `close` kills.
|
|
316
|
+
const line = params.clientCommand ?? crewClientCommand({
|
|
317
|
+
session,
|
|
318
|
+
launch: { execPath: "node", scriptPath: "zswarm" },
|
|
319
|
+
});
|
|
320
|
+
return [
|
|
321
|
+
PS_CLIENT_PREAMBLE,
|
|
322
|
+
`$marker = '${marker}'`,
|
|
323
|
+
`$line = '${line.replace(/'/g, "''")}'`,
|
|
324
|
+
psClientPidLine(marker),
|
|
325
|
+
"if ($clientPids.Count -gt 0) {",
|
|
326
|
+
" Write-ZswarmJson @{ opened = $false; present = $true; pid = $clientPids[0]; title = $marker; count = $clientPids.Count }",
|
|
327
|
+
" exit 0",
|
|
328
|
+
"}",
|
|
329
|
+
"$proc = Start-Process -FilePath 'cmd.exe' -WindowStyle Minimized -PassThru -ArgumentList @('/c', $line)",
|
|
330
|
+
"Write-ZswarmJson @{ opened = $true; present = $true; pid = [int]$proc.Id; title = $marker; count = 1 }",
|
|
331
|
+
].join("\n");
|
|
332
|
+
}
|
|
333
|
+
return [
|
|
334
|
+
PS_CLIENT_PREAMBLE,
|
|
335
|
+
`$marker = '${marker}'`,
|
|
336
|
+
psClientPidLine(marker),
|
|
337
|
+
"$closed = @()",
|
|
338
|
+
"foreach ($clientPid in $clientPids) {",
|
|
339
|
+
" # The window is the launcher's console, so the tree has to go with it:",
|
|
340
|
+
" # /T reaches the attached `zellij` client that shares that console.",
|
|
341
|
+
" & taskkill.exe /PID $clientPid /T /F 2>$null | Out-Null",
|
|
342
|
+
" $closed += $clientPid",
|
|
343
|
+
"}",
|
|
344
|
+
"Write-ZswarmJson @{ closed = @($closed); count = $closed.Count }",
|
|
345
|
+
].join("\n");
|
|
346
|
+
}
|
|
347
|
+
/** Last line that parses as a JSON object, else the first; null when none does. */
|
|
348
|
+
export function parseCrewJson(stdout) {
|
|
349
|
+
let found = null;
|
|
350
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
351
|
+
const text = line.trim();
|
|
352
|
+
if (!text.startsWith("{") || !text.endsWith("}"))
|
|
353
|
+
continue;
|
|
354
|
+
let value;
|
|
355
|
+
try {
|
|
356
|
+
value = JSON.parse(text);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
362
|
+
found = value;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (found)
|
|
366
|
+
return found;
|
|
367
|
+
// A single pretty-printed object spans lines and never parses per line.
|
|
368
|
+
const text = stdout.trim();
|
|
369
|
+
if (!text.startsWith("{") || !text.endsWith("}"))
|
|
370
|
+
return null;
|
|
371
|
+
try {
|
|
372
|
+
const value = JSON.parse(text);
|
|
373
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* `powershell -EncodedCommand` costs two bytes of UTF-16 per script character,
|
|
384
|
+
* then base64 inflates that by 4/3; Windows refuses a command line over ~32k
|
|
385
|
+
* characters with a bare `ENAMETOOLONG`, so a script that cannot fit is
|
|
386
|
+
* reported as itself instead of as a spawn failure.
|
|
387
|
+
*/
|
|
388
|
+
const MAX_ENCODED_SCRIPT = 30_000;
|
|
389
|
+
/** Powershell runner used in production; injected in tests. Never throws. */
|
|
390
|
+
export const defaultPowerShellExec = (script, options) => {
|
|
391
|
+
const timeout = Math.floor(options.timeoutMs);
|
|
392
|
+
if (timeout <= 0) {
|
|
393
|
+
return Promise.resolve({ code: 1, stdout: "", stderr: "no remaining budget" });
|
|
394
|
+
}
|
|
395
|
+
const encoded = encodePowerShellCommand(script);
|
|
396
|
+
if (encoded.length > MAX_ENCODED_SCRIPT) {
|
|
397
|
+
return Promise.resolve({
|
|
398
|
+
code: 1,
|
|
399
|
+
stdout: "",
|
|
400
|
+
stderr: `the generated PowerShell is ${encoded.length} characters, too long for a Windows command line`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
execFile("powershell.exe", [
|
|
405
|
+
"-NoProfile",
|
|
406
|
+
"-ExecutionPolicy",
|
|
407
|
+
"Bypass",
|
|
408
|
+
"-EncodedCommand",
|
|
409
|
+
encoded,
|
|
410
|
+
], {
|
|
411
|
+
windowsHide: true,
|
|
412
|
+
timeout,
|
|
413
|
+
signal: options.signal,
|
|
414
|
+
maxBuffer: 1024 * 1024,
|
|
415
|
+
}, (error, stdout, stderr) => {
|
|
416
|
+
if (error) {
|
|
417
|
+
const err = error;
|
|
418
|
+
const text = String(stderr ?? "").trim();
|
|
419
|
+
resolve({ code: 1, stdout: String(stdout ?? ""), stderr: text || err.message });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
resolve({ code: 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
function crewTimeout(timeoutMs) {
|
|
427
|
+
return Math.max(1, Math.floor(timeoutMs ?? DEFAULT_CREW_TIMEOUT_MS));
|
|
428
|
+
}
|
|
429
|
+
async function crewRun(run, script, timeoutMs, signal) {
|
|
430
|
+
const result = await run(script, { timeoutMs, signal });
|
|
431
|
+
if (result.code !== 0) {
|
|
432
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 300);
|
|
433
|
+
throw new ZellijError("failed", detail || "zswarm crew helper failed");
|
|
434
|
+
}
|
|
435
|
+
const parsed = parseCrewJson(result.stdout);
|
|
436
|
+
if (!parsed)
|
|
437
|
+
throw new ZellijError("failed", "zswarm crew helper returned no JSON");
|
|
438
|
+
return parsed;
|
|
439
|
+
}
|
|
440
|
+
function asTaskState(name, task, raw) {
|
|
441
|
+
const info = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
442
|
+
const exists = info.exists === true;
|
|
443
|
+
return {
|
|
444
|
+
name,
|
|
445
|
+
task,
|
|
446
|
+
path: `${CREW_TASK_FOLDER}${task}`,
|
|
447
|
+
state: exists ? String(info.state ?? "Unknown") : "missing",
|
|
448
|
+
active: exists,
|
|
449
|
+
lastResult: typeof info.lastResult === "number" ? info.lastResult : null,
|
|
450
|
+
lastRun: typeof info.lastRunTime === "string" && info.lastRunTime ? info.lastRunTime : null,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function clientPids(value) {
|
|
454
|
+
// A one-element `@(...)` can serialize as a scalar, not an array.
|
|
455
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
456
|
+
return [value];
|
|
457
|
+
if (!Array.isArray(value))
|
|
458
|
+
return [];
|
|
459
|
+
return value.filter((item) => typeof item === "number" && Number.isFinite(item));
|
|
460
|
+
}
|
|
461
|
+
export async function inspectCrewTasks(input) {
|
|
462
|
+
const session = checkSession(input.session);
|
|
463
|
+
const script = buildCrewTaskScript("inspect", { session });
|
|
464
|
+
const data = await crewRun(input.runPowerShell, script, crewTimeout(input.timeoutMs), input.signal);
|
|
465
|
+
const names = crewTaskNames(session);
|
|
466
|
+
const pids = clientPids(data.clientPids);
|
|
467
|
+
const count = typeof data.clientCount === "number" && Number.isFinite(data.clientCount)
|
|
468
|
+
? data.clientCount
|
|
469
|
+
: pids.length;
|
|
470
|
+
return {
|
|
471
|
+
tasks: [
|
|
472
|
+
asTaskState("start", names.start, data.start),
|
|
473
|
+
asTaskState("watch", names.watch, data.watch),
|
|
474
|
+
],
|
|
475
|
+
client: {
|
|
476
|
+
present: data.clientPresent === true,
|
|
477
|
+
pid: pids.length > 0 ? pids[0] : null,
|
|
478
|
+
count,
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
export async function installCrewTasks(input) {
|
|
483
|
+
const session = checkSession(input.session);
|
|
484
|
+
const names = crewTaskNames(session);
|
|
485
|
+
const intervalMinutes = Math.max(1, Math.floor(input.intervalMinutes ?? CREW_WATCH_MINUTES));
|
|
486
|
+
const timeoutMs = crewTimeout(input.timeoutMs);
|
|
487
|
+
const script = buildCrewTaskScript("register", {
|
|
488
|
+
session,
|
|
489
|
+
startCommand: input.startCommand,
|
|
490
|
+
watchCommand: input.watchCommand,
|
|
491
|
+
intervalMinutes,
|
|
492
|
+
});
|
|
493
|
+
await crewRun(input.runPowerShell, script, timeoutMs, input.signal);
|
|
494
|
+
// Start both now, the way the Linux oneshot unit is started on install: the
|
|
495
|
+
// start task builds the session and opens the client window; the watchdog's
|
|
496
|
+
// first run is a no-op while that session is healthy.
|
|
497
|
+
await crewRun(input.runPowerShell, buildCrewTaskScript("start", { session }), timeoutMs, input.signal);
|
|
498
|
+
const inspected = await inspectCrewTasks({
|
|
499
|
+
session,
|
|
500
|
+
runPowerShell: input.runPowerShell,
|
|
501
|
+
timeoutMs,
|
|
502
|
+
signal: input.signal,
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
lines: [
|
|
506
|
+
`${names.folder}${names.start} registered and started`,
|
|
507
|
+
`${names.folder}${names.watch} registered and started`,
|
|
508
|
+
],
|
|
509
|
+
notes: [
|
|
510
|
+
"A Windows scheduled task runs in the signed-in interactive desktop session, so the crew needs a logged-in user: a logoff ends the client window and the session it renders.",
|
|
511
|
+
`The start task builds the session and keeps an attached client window titled ${names.start}; the watchdog runs crew check every ${intervalMinutes} minute(s) and reopens that window when it is gone.`,
|
|
512
|
+
],
|
|
513
|
+
tasks: inspected.tasks,
|
|
514
|
+
client: inspected.client,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
export async function removeCrewTasks(input) {
|
|
518
|
+
const session = checkSession(input.session);
|
|
519
|
+
const names = crewTaskNames(session);
|
|
520
|
+
const timeoutMs = crewTimeout(input.timeoutMs);
|
|
521
|
+
const script = buildCrewTaskScript("unregister", { session });
|
|
522
|
+
await crewRun(input.runPowerShell, script, timeoutMs, input.signal);
|
|
523
|
+
const inspected = await inspectCrewTasks({
|
|
524
|
+
session,
|
|
525
|
+
runPowerShell: input.runPowerShell,
|
|
526
|
+
timeoutMs,
|
|
527
|
+
signal: input.signal,
|
|
528
|
+
});
|
|
529
|
+
return {
|
|
530
|
+
lines: [`${names.folder}${names.start} removed`, `${names.folder}${names.watch} removed`],
|
|
531
|
+
tasks: inspected.tasks,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
export async function ensureCrewClient(input) {
|
|
535
|
+
const session = checkSession(input.session);
|
|
536
|
+
const title = crewClientMarker(session);
|
|
537
|
+
const script = buildCrewClientScript("ensure", { session, clientCommand: input.clientCommand });
|
|
538
|
+
const data = await crewRun(input.runPowerShell, script, crewTimeout(input.timeoutMs), input.signal);
|
|
539
|
+
const pid = typeof data.pid === "number" && Number.isFinite(data.pid) ? data.pid : null;
|
|
540
|
+
const count = typeof data.count === "number" && Number.isFinite(data.count) ? data.count : pid === null ? 0 : 1;
|
|
541
|
+
return {
|
|
542
|
+
opened: data.opened === true,
|
|
543
|
+
pid,
|
|
544
|
+
title: typeof data.title === "string" && data.title ? data.title : title,
|
|
545
|
+
present: data.present === true,
|
|
546
|
+
count,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
export async function closeCrewClient(input) {
|
|
550
|
+
const session = checkSession(input.session);
|
|
551
|
+
const script = buildCrewClientScript("close", { session });
|
|
552
|
+
const data = await crewRun(input.runPowerShell, script, crewTimeout(input.timeoutMs), input.signal);
|
|
553
|
+
const closed = clientPids(data.closed);
|
|
554
|
+
const count = typeof data.count === "number" && Number.isFinite(data.count) ? data.count : closed.length;
|
|
555
|
+
return { closed, count };
|
|
556
|
+
}
|
package/dist/host/crew.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PowerShellExec } from "./crew-windows.js";
|
|
1
2
|
import { type Runner, type ServiceAccount, type ServiceHost, type ServiceSpec } from "./services.js";
|
|
2
3
|
export declare const CREW_WATCH_SEC = 120;
|
|
3
4
|
export type CrewDeps = {
|
|
@@ -23,6 +24,24 @@ export type CrewDeps = {
|
|
|
23
24
|
log?: (line: string) => void;
|
|
24
25
|
/** Restarts a systemd user unit (the watchdog on Linux); injected in tests. */
|
|
25
26
|
restartUnit?: (unit: string) => number;
|
|
27
|
+
/** The account the crew runs for; injected in tests (a win32 account cannot run real tasks in CI). */
|
|
28
|
+
account?: ServiceAccount;
|
|
29
|
+
/** Runs the crew's scheduled-task PowerShell on Windows; injected in tests. */
|
|
30
|
+
runPowerShell?: PowerShellExec;
|
|
31
|
+
/** The CLI launcher the client window runs; defaults to this process's. */
|
|
32
|
+
launch?: {
|
|
33
|
+
execPath: string;
|
|
34
|
+
scriptPath: string;
|
|
35
|
+
};
|
|
36
|
+
/** The attached client window on Windows; injected in tests. */
|
|
37
|
+
client?: {
|
|
38
|
+
ensure: (session: string) => Promise<{
|
|
39
|
+
opened: boolean;
|
|
40
|
+
pid: number | null;
|
|
41
|
+
count: number;
|
|
42
|
+
}>;
|
|
43
|
+
close: (session: string) => Promise<number[]>;
|
|
44
|
+
};
|
|
26
45
|
};
|
|
27
46
|
/** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
|
|
28
47
|
export declare const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
|
|
@@ -34,6 +53,13 @@ export declare function firstLayoutPane(kdl: string): string | undefined;
|
|
|
34
53
|
export declare function parseEnvFile(text: string): Record<string, string>;
|
|
35
54
|
/** Start the session from its layout and prove the first pane exists; up to three tries. */
|
|
36
55
|
export declare function crewStart(session: string, deps?: CrewDeps): Promise<number>;
|
|
56
|
+
/**
|
|
57
|
+
* `crew client`: what the minimized console window runs. It reads the crew dir
|
|
58
|
+
* (so an env file's variables reach Zellij without being written into a task or
|
|
59
|
+
* a command line) and attaches with `--create`, which is what makes a Windows
|
|
60
|
+
* session render — a background-only session leaves pane grids empty.
|
|
61
|
+
*/
|
|
62
|
+
export declare function crewClient(session: string, deps?: CrewDeps): number;
|
|
37
63
|
/** Watchdog: restart the session if its server died or its first pane is gone. */
|
|
38
64
|
export declare function crewCheck(session: string, deps?: CrewDeps): Promise<number>;
|
|
39
65
|
export type CrewUpInput = {
|
|
@@ -54,6 +80,8 @@ export type CrewUpInput = {
|
|
|
54
80
|
/** Copies crew files into the crew dir; injected in tests. */
|
|
55
81
|
copy?: (account: ServiceAccount, from: string, to: string, secret: boolean) => void;
|
|
56
82
|
writeMeta?: (account: ServiceAccount, path: string, text: string) => void;
|
|
83
|
+
/** Runs the crew's scheduled-task PowerShell on Windows; injected in tests. */
|
|
84
|
+
runPowerShell?: PowerShellExec;
|
|
57
85
|
};
|
|
58
86
|
export declare function crewServiceSpecs(input: {
|
|
59
87
|
account: ServiceAccount;
|
|
@@ -64,7 +92,7 @@ export declare function crewServiceSpecs(input: {
|
|
|
64
92
|
};
|
|
65
93
|
env: NodeJS.ProcessEnv;
|
|
66
94
|
}): ServiceSpec[];
|
|
67
|
-
export declare function crewUp(input: CrewUpInput): Record<string, unknown
|
|
95
|
+
export declare function crewUp(input: CrewUpInput): Promise<Record<string, unknown>>;
|
|
68
96
|
export declare function crewStatus(input: {
|
|
69
97
|
session: string;
|
|
70
98
|
runAs?: string;
|
|
@@ -72,6 +100,7 @@ export declare function crewStatus(input: {
|
|
|
72
100
|
account?: ServiceAccount;
|
|
73
101
|
run?: Runner;
|
|
74
102
|
deps?: CrewDeps;
|
|
103
|
+
runPowerShell?: PowerShellExec;
|
|
75
104
|
}): Promise<Record<string, unknown>>;
|
|
76
105
|
export declare function crewDown(input: {
|
|
77
106
|
session: string;
|
|
@@ -80,4 +109,5 @@ export declare function crewDown(input: {
|
|
|
80
109
|
account?: ServiceAccount;
|
|
81
110
|
run?: Runner;
|
|
82
111
|
fs?: ServiceHost["fs"];
|
|
83
|
-
|
|
112
|
+
runPowerShell?: PowerShellExec;
|
|
113
|
+
}): Promise<Record<string, unknown>>;
|
package/dist/host/crew.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { posix } from "node:path";
|
|
3
|
+
import { dirname, posix, win32 } from "node:path";
|
|
4
4
|
import { ZellijError } from "../errors.js";
|
|
5
5
|
import { dispatchZswarm } from "../ops/dispatch.js";
|
|
6
6
|
import { resolveServeCliLaunch } from "../ops/serve-install.js";
|
|
7
7
|
import { defaultStateDir } from "../state.js";
|
|
8
8
|
import { resolveZellijBinary } from "../zellij/binary.js";
|
|
9
|
+
import { closeCrewClient, crewClientCommand, crewClientMarker, crewTaskCommand, defaultPowerShellExec, ensureCrewClient, inspectCrewTasks, installCrewTasks, removeCrewTasks, } from "./crew-windows.js";
|
|
9
10
|
import { defaultRunner, ensureAccountDir, installServices, removeServices, serviceAccount, serviceStates, writeAccountFile, writeSecretFile, } from "./services.js";
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// Linux and macOS accounts carry POSIX paths even when this code runs (in
|
|
12
|
+
// tests) on Windows; a Windows crew account carries native Windows paths.
|
|
12
13
|
const { join } = posix;
|
|
13
14
|
/**
|
|
14
15
|
* `zswarm crew`: keep a Zellij session built from a layout running on a host.
|
|
@@ -23,12 +24,37 @@ const { join } = posix;
|
|
|
23
24
|
* status services, session and panes
|
|
24
25
|
* down remove the services and kill the session
|
|
25
26
|
*
|
|
27
|
+
* On Windows there is no systemd or launchd: `up` registers two Scheduled
|
|
28
|
+
* Tasks under `\zswarm\` (a start task and a two-minute watchdog running
|
|
29
|
+
* `crew check`) and the start path keeps an attached client in a minimized
|
|
30
|
+
* console window, because a background-only Windows session renders empty
|
|
31
|
+
* panes and drops pasted input.
|
|
32
|
+
*
|
|
26
33
|
* Zellij now and then starts `attach --create-background --layout` as a
|
|
27
34
|
* plain one-pane session; the first-pane check is what catches that.
|
|
28
35
|
*/
|
|
29
36
|
const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
30
37
|
const FOREIGN_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin";
|
|
31
38
|
export const CREW_WATCH_SEC = 120;
|
|
39
|
+
/** The Windows client window: real PowerShell, or the test seam. */
|
|
40
|
+
function windowsClient(deps, env) {
|
|
41
|
+
if (deps.client)
|
|
42
|
+
return deps.client;
|
|
43
|
+
const runPowerShell = deps.runPowerShell ?? defaultPowerShellExec;
|
|
44
|
+
const launch = deps.launch ?? resolveServeCliLaunch({ env });
|
|
45
|
+
return {
|
|
46
|
+
ensure: (session) => ensureCrewClient({
|
|
47
|
+
session,
|
|
48
|
+
clientCommand: crewClientCommand({ session, launch }),
|
|
49
|
+
runPowerShell,
|
|
50
|
+
}),
|
|
51
|
+
close: async (session) => (await closeCrewClient({ session, runPowerShell })).closed,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** A task row shaped like the systemd/launchd `services` entries. */
|
|
55
|
+
function taskStateView(task) {
|
|
56
|
+
return { name: task.name, unit: task.path, state: task.state, active: task.active };
|
|
57
|
+
}
|
|
32
58
|
/** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
|
|
33
59
|
export const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
|
|
34
60
|
export function checkCrewSession(session) {
|
|
@@ -38,8 +64,13 @@ export function checkCrewSession(session) {
|
|
|
38
64
|
return s;
|
|
39
65
|
}
|
|
40
66
|
export function crewDir(account, session, env = process.env) {
|
|
41
|
-
|
|
42
|
-
|
|
67
|
+
// A Windows account's home and state dir are native paths; joining them with
|
|
68
|
+
// posix.join would leave a mixed `C:\Users\x/.zswarm` for Zellij to open.
|
|
69
|
+
const path = account.platform === "win32" ? win32 : posix;
|
|
70
|
+
const base = !account.foreign && env.ZSWARM_STATE_DIR?.trim()
|
|
71
|
+
? defaultStateDir(env)
|
|
72
|
+
: path.join(account.home, ".zswarm");
|
|
73
|
+
return path.join(base, "crews", session);
|
|
43
74
|
}
|
|
44
75
|
/** The first `pane ... name="X"` in a KDL layout: the pane whose presence proves the layout took. */
|
|
45
76
|
export function firstLayoutPane(kdl) {
|
|
@@ -72,7 +103,7 @@ export function parseEnvFile(text) {
|
|
|
72
103
|
return out;
|
|
73
104
|
}
|
|
74
105
|
function readCrewFiles(dir) {
|
|
75
|
-
const meta =
|
|
106
|
+
const meta = crewFile(dir, "crew.json");
|
|
76
107
|
if (!existsSync(meta))
|
|
77
108
|
throw new ZellijError("usage", `no crew in ${dir}; run zswarm crew up first`);
|
|
78
109
|
return JSON.parse(readFileSync(meta, "utf8"));
|
|
@@ -102,15 +133,25 @@ function defaultZellij(args, env) {
|
|
|
102
133
|
const res = spawnSync(resolveZellijBinary(env), args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 });
|
|
103
134
|
return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
104
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Zellij, sharing this process's console. `attach --create` needs a real
|
|
138
|
+
* terminal — with a pipe on stdin it exits 0 having created nothing, which is
|
|
139
|
+
* exactly the "session that never renders" a Windows crew must avoid.
|
|
140
|
+
*/
|
|
141
|
+
function defaultZellijInteractive(args, env) {
|
|
142
|
+
const res = spawnSync(resolveZellijBinary(env), args, { env, stdio: "inherit" });
|
|
143
|
+
return { status: res.status ?? 1, stdout: "", stderr: res.error?.message ?? "" };
|
|
144
|
+
}
|
|
105
145
|
function crewEnv(dir, files, env) {
|
|
106
|
-
const extra = files.env ? parseEnvFile(readFileSync(
|
|
146
|
+
const extra = files.env ? parseEnvFile(readFileSync(crewFile(dir, files.env), "utf8")) : {};
|
|
107
147
|
// No bus inside the start/check helpers: they list panes once and exit.
|
|
108
148
|
return { ...env, TERM: env.TERM || "xterm-256color", ...extra, ZSWARM_BUS: "0" };
|
|
109
149
|
}
|
|
110
150
|
/** Start the session from its layout and prove the first pane exists; up to three tries. */
|
|
111
151
|
export async function crewStart(session, deps = {}) {
|
|
112
152
|
const env0 = deps.env ?? process.env;
|
|
113
|
-
const
|
|
153
|
+
const account = deps.account ?? serviceAccount(undefined);
|
|
154
|
+
const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
|
|
114
155
|
const files = readCrewFiles(dir);
|
|
115
156
|
const env = crewEnv(dir, files, env0);
|
|
116
157
|
const zellij = deps.zellij ?? defaultZellij;
|
|
@@ -118,10 +159,37 @@ export async function crewStart(session, deps = {}) {
|
|
|
118
159
|
const panes = deps.panes ?? defaultPanes;
|
|
119
160
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
120
161
|
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
121
|
-
const config = files.config ? ["--config",
|
|
162
|
+
const config = files.config ? ["--config", crewFile(dir, files.config)] : [];
|
|
163
|
+
if (account.platform === "win32") {
|
|
164
|
+
// Windows: a session created with `attach --create-background` renders
|
|
165
|
+
// empty panes and drops pasted input, so the session is created BY an
|
|
166
|
+
// attached client instead — a minimized console window running
|
|
167
|
+
// `zellij --layout ... attach --create`, which `crew client` starts. That
|
|
168
|
+
// window is also what the watchdog reopens when it dies.
|
|
169
|
+
const client = windowsClient(deps, env);
|
|
170
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
171
|
+
zellij(["delete-session", session], env); // clears only a dead session of this name
|
|
172
|
+
await client.close(session); // a window left over from the previous attempt
|
|
173
|
+
const opened = await client.ensure(session);
|
|
174
|
+
log(`client window ${opened.opened ? "opened" : "already open"} for ${session}`);
|
|
175
|
+
for (let i = 0; i < 30 && !(await sessions(env)).includes(session); i++)
|
|
176
|
+
await sleep(1_000);
|
|
177
|
+
await sleep(3_000);
|
|
178
|
+
if ((await panes(session, env)).some((p) => p.title === files.first)) {
|
|
179
|
+
log(`session ${session} up in window ${crewClientMarker(session)} (attempt ${attempt})`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
|
|
183
|
+
zellij(["kill-session", session], env);
|
|
184
|
+
await client.close(session);
|
|
185
|
+
await sleep(1_000);
|
|
186
|
+
}
|
|
187
|
+
log(`session ${session} never took its layout`);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
122
190
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
123
191
|
zellij(["delete-session", session], env); // clears only a dead session of this name
|
|
124
|
-
zellij([...config, "--layout",
|
|
192
|
+
zellij([...config, "--layout", crewFile(dir, files.layout), "attach", "--create-background", session], env);
|
|
125
193
|
for (let i = 0; i < 20 && !(await sessions(env)).includes(session); i++)
|
|
126
194
|
await sleep(500);
|
|
127
195
|
await sleep(2_000);
|
|
@@ -136,16 +204,63 @@ export async function crewStart(session, deps = {}) {
|
|
|
136
204
|
log(`session ${session} never took its layout`);
|
|
137
205
|
return 1;
|
|
138
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Zellij's own client variables. A client started from inside a pane inherits
|
|
209
|
+
* `ZELLIJ=0`, `ZELLIJ_SESSION_NAME`, ...; a nested client then exits without
|
|
210
|
+
* creating anything. The console window is opened from whatever process asked
|
|
211
|
+
* for the crew (an agent pane, a scheduled task), so strip them all.
|
|
212
|
+
*/
|
|
213
|
+
const ZELLIJ_CLIENT_ENV = /^ZELLIJ(_|$)/;
|
|
214
|
+
function withoutZellijClientEnv(env) {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const [key, value] of Object.entries(env)) {
|
|
217
|
+
if (ZELLIJ_CLIENT_ENV.test(key))
|
|
218
|
+
continue;
|
|
219
|
+
out[key] = value;
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* `crew client`: what the minimized console window runs. It reads the crew dir
|
|
225
|
+
* (so an env file's variables reach Zellij without being written into a task or
|
|
226
|
+
* a command line) and attaches with `--create`, which is what makes a Windows
|
|
227
|
+
* session render — a background-only session leaves pane grids empty.
|
|
228
|
+
*/
|
|
229
|
+
export function crewClient(session, deps = {}) {
|
|
230
|
+
const env0 = deps.env ?? process.env;
|
|
231
|
+
const account = deps.account ?? serviceAccount(undefined);
|
|
232
|
+
const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
|
|
233
|
+
const files = readCrewFiles(dir);
|
|
234
|
+
const env = withoutZellijClientEnv(crewEnv(dir, files, env0));
|
|
235
|
+
const args = [
|
|
236
|
+
...(files.config ? ["--config", crewFile(dir, files.config)] : []),
|
|
237
|
+
"--layout", crewFile(dir, files.layout),
|
|
238
|
+
"attach", "--create", session,
|
|
239
|
+
];
|
|
240
|
+
// OSC 0 titles the console window; the marker is how status and down find it.
|
|
241
|
+
// Only on a real console — a piped run (tests, logs) has no window to title.
|
|
242
|
+
if (process.stdout.isTTY)
|
|
243
|
+
process.stdout.write(`\u001b]0;${crewClientMarker(session)}\u0007`);
|
|
244
|
+
return (deps.zellij ?? defaultZellijInteractive)(args, env).status;
|
|
245
|
+
}
|
|
139
246
|
/** Watchdog: restart the session if its server died or its first pane is gone. */
|
|
140
247
|
export async function crewCheck(session, deps = {}) {
|
|
141
248
|
const env0 = deps.env ?? process.env;
|
|
142
|
-
const
|
|
249
|
+
const account = deps.account ?? serviceAccount(undefined);
|
|
250
|
+
const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
|
|
143
251
|
const files = readCrewFiles(dir);
|
|
144
252
|
const env = crewEnv(dir, files, env0);
|
|
145
253
|
const sessions = deps.sessions ?? defaultSessions;
|
|
146
254
|
const panes = deps.panes ?? defaultPanes;
|
|
147
255
|
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
148
256
|
if ((await sessions(env)).includes(session) && (await panes(session, env)).some((p) => p.title === files.first)) {
|
|
257
|
+
if (account.platform === "win32") {
|
|
258
|
+
// The session surviving is not enough on Windows: without the attached
|
|
259
|
+
// client window its panes render empty, so the watchdog reopens it.
|
|
260
|
+
const opened = await windowsClient(deps, env).ensure(session);
|
|
261
|
+
if (opened.opened)
|
|
262
|
+
log(`client window reopened for ${session}`);
|
|
263
|
+
}
|
|
149
264
|
return 0;
|
|
150
265
|
}
|
|
151
266
|
log(`session ${session} is gone or lost its layout; restarting`);
|
|
@@ -159,8 +274,26 @@ export async function crewCheck(session, deps = {}) {
|
|
|
159
274
|
}
|
|
160
275
|
return await crewStart(session, deps);
|
|
161
276
|
}
|
|
277
|
+
/** A file inside a crew dir, which is a Windows path for a win32 account. */
|
|
278
|
+
function crewFile(dir, name) {
|
|
279
|
+
return dir.includes("\\") ? win32.join(dir, name) : join(dir, name);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The env a Windows crew task action reproduces. Not the whole caller env (it
|
|
283
|
+
* can hold tokens) and not PATH (the interactive task inherits the user's, and
|
|
284
|
+
* a host PATH would not fit in a Windows command line).
|
|
285
|
+
*/
|
|
286
|
+
function crewTaskEnv(env) {
|
|
287
|
+
const out = {};
|
|
288
|
+
for (const key of ["ZSWARM_BIN", "ZSWARM_STATE_DIR"]) {
|
|
289
|
+
const value = env[key]?.trim();
|
|
290
|
+
if (value)
|
|
291
|
+
out[key] = value;
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
162
295
|
function copyForAccount(account, from, to, secret) {
|
|
163
|
-
ensureAccountDir(account,
|
|
296
|
+
ensureAccountDir(account, dirname(to));
|
|
164
297
|
if (secret) {
|
|
165
298
|
writeSecretFile(account, to, readFileSync(from, "utf8"));
|
|
166
299
|
return;
|
|
@@ -203,7 +336,7 @@ export function crewServiceSpecs(input) {
|
|
|
203
336
|
},
|
|
204
337
|
];
|
|
205
338
|
}
|
|
206
|
-
export function crewUp(input) {
|
|
339
|
+
export async function crewUp(input) {
|
|
207
340
|
const env = input.env ?? process.env;
|
|
208
341
|
const run = input.run ?? defaultRunner;
|
|
209
342
|
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
@@ -219,17 +352,42 @@ export function crewUp(input) {
|
|
|
219
352
|
const dir = crewDir(account, session, env);
|
|
220
353
|
const copy = input.copy ?? copyForAccount;
|
|
221
354
|
const files = { layout: "layout.kdl", first };
|
|
222
|
-
copy(account, input.layout,
|
|
355
|
+
copy(account, input.layout, crewFile(dir, "layout.kdl"), false);
|
|
223
356
|
if (input.config) {
|
|
224
|
-
copy(account, input.config,
|
|
357
|
+
copy(account, input.config, crewFile(dir, "config.kdl"), false);
|
|
225
358
|
files.config = "config.kdl";
|
|
226
359
|
}
|
|
227
360
|
if (input.envFile) {
|
|
228
|
-
copy(account, input.envFile,
|
|
361
|
+
copy(account, input.envFile, crewFile(dir, "env"), true);
|
|
229
362
|
files.env = "env";
|
|
230
363
|
}
|
|
231
364
|
// crew.json is written last, so a half-copied crew never starts.
|
|
232
|
-
(input.writeMeta ?? writeSecretFile)(account,
|
|
365
|
+
(input.writeMeta ?? writeSecretFile)(account, crewFile(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
|
|
366
|
+
if (account.platform === "win32") {
|
|
367
|
+
// Windows has no systemd/launchd: two Scheduled Tasks (a start task and a
|
|
368
|
+
// two-minute watchdog) plus the minimized client window the start task
|
|
369
|
+
// opens, because a background-only session renders empty panes.
|
|
370
|
+
const taskEnv = crewTaskEnv(env);
|
|
371
|
+
const installed = await installCrewTasks({
|
|
372
|
+
session,
|
|
373
|
+
startCommand: crewTaskCommand({ launch, mode: "start", session, env: taskEnv }),
|
|
374
|
+
watchCommand: crewTaskCommand({ launch, mode: "check", session, env: taskEnv }),
|
|
375
|
+
runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
|
|
376
|
+
});
|
|
377
|
+
const states = installed.tasks.map(taskStateView);
|
|
378
|
+
return {
|
|
379
|
+
session,
|
|
380
|
+
user: account.user,
|
|
381
|
+
dir,
|
|
382
|
+
firstPane: first,
|
|
383
|
+
services: states,
|
|
384
|
+
ready: states.every((s) => s.active),
|
|
385
|
+
tasks: installed.tasks,
|
|
386
|
+
clientWindow: installed.client,
|
|
387
|
+
actions: installed.lines,
|
|
388
|
+
notes: installed.notes,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
233
391
|
const specs = crewServiceSpecs({ account, session, launch, env });
|
|
234
392
|
const done = installServices({ account, run, fs: input.fs }, specs);
|
|
235
393
|
const states = serviceStates({ account, run }, specs);
|
|
@@ -249,14 +407,28 @@ export async function crewStatus(input) {
|
|
|
249
407
|
const run = input.run ?? defaultRunner;
|
|
250
408
|
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
251
409
|
const session = checkCrewSession(input.session);
|
|
252
|
-
const
|
|
253
|
-
const services = serviceStates({ account, run }, specs);
|
|
254
|
-
const out = { session, user: account.user, services };
|
|
410
|
+
const out = { session, user: account.user };
|
|
255
411
|
if (account.foreign) {
|
|
412
|
+
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
413
|
+
out.services = serviceStates({ account, run }, specs);
|
|
256
414
|
out.note = `session and panes belong to ${account.user}: run zswarm crew status ${session} as that user`;
|
|
257
415
|
return out;
|
|
258
416
|
}
|
|
259
|
-
|
|
417
|
+
let client = null;
|
|
418
|
+
if (account.platform === "win32") {
|
|
419
|
+
const inspected = await inspectCrewTasks({
|
|
420
|
+
session,
|
|
421
|
+
runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
|
|
422
|
+
});
|
|
423
|
+
out.services = inspected.tasks.map(taskStateView);
|
|
424
|
+
client = inspected.client;
|
|
425
|
+
out.clientWindow = inspected.client;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
429
|
+
out.services = serviceStates({ account, run }, specs);
|
|
430
|
+
}
|
|
431
|
+
const files = existsSync(crewFile(crewDir(account, session, env), "crew.json"))
|
|
260
432
|
? readCrewFiles(crewDir(account, session, env))
|
|
261
433
|
: undefined;
|
|
262
434
|
const live = (await (input.deps?.sessions ?? defaultSessions)(env)).includes(session);
|
|
@@ -267,17 +439,34 @@ export async function crewStatus(input) {
|
|
|
267
439
|
out.firstPane = files?.first ?? null;
|
|
268
440
|
out.layoutTook = files ? panes.some((p) => p.title === files.first) : null;
|
|
269
441
|
out.panes = panes;
|
|
270
|
-
out.healthy =
|
|
442
|
+
out.healthy =
|
|
443
|
+
out.services.every((s) => s.active) &&
|
|
444
|
+
live &&
|
|
445
|
+
out.layoutTook !== false &&
|
|
446
|
+
panes.every((p) => !p.exited) &&
|
|
447
|
+
(client ? client.present : true);
|
|
271
448
|
if (live && exited) {
|
|
272
449
|
out.note = `an exited session named ${session} is also listed (resurrectable); remove it with zswarm sessions --prune-exited`;
|
|
273
450
|
}
|
|
274
451
|
return out;
|
|
275
452
|
}
|
|
276
|
-
export function crewDown(input) {
|
|
453
|
+
export async function crewDown(input) {
|
|
277
454
|
const env = input.env ?? process.env;
|
|
278
455
|
const run = input.run ?? defaultRunner;
|
|
279
456
|
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
280
457
|
const session = checkCrewSession(input.session);
|
|
458
|
+
if (account.platform === "win32") {
|
|
459
|
+
const runPowerShell = input.runPowerShell ?? defaultPowerShellExec;
|
|
460
|
+
const removed = await removeCrewTasks({ session, runPowerShell });
|
|
461
|
+
const lines = [...removed.lines];
|
|
462
|
+
const closed = await closeCrewClient({ session, runPowerShell });
|
|
463
|
+
lines.push(closed.closed.length > 0
|
|
464
|
+
? `client window closed (pids ${closed.closed.join(", ")})`
|
|
465
|
+
: "no client window was open");
|
|
466
|
+
const kill = run(resolveZellijBinary(env), ["kill-session", session]);
|
|
467
|
+
lines.push(kill.status === 0 ? `session ${session} killed` : `session ${session} was not running`);
|
|
468
|
+
return { session, user: account.user, actions: lines };
|
|
469
|
+
}
|
|
281
470
|
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
282
471
|
const lines = removeServices({ account, run, fs: input.fs }, specs);
|
|
283
472
|
// The Zellij server belongs to the account: kill it as that user.
|
package/dist/host/services.d.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* Background services for host commands (serve, crew): systemd user units
|
|
3
3
|
* on Linux (with linger, so they run without a login), launchd on macOS (a
|
|
4
4
|
* LaunchAgent for the current user, or, from root with `runAs`, a
|
|
5
|
-
* LaunchDaemon that runs as that user, for accounts that never log in).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* LaunchDaemon that runs as that user, for accounts that never log in). On
|
|
6
|
+
* Windows there is no systemd or launchd here: `serviceAccount` returns the
|
|
7
|
+
* signed-in user (`platform: "win32"`, uid/gid 0) so crew can install a
|
|
8
|
+
* scheduled task instead, and `--run-as` is refused. Secrets never go into
|
|
9
|
+
* unit files or plists: services read them from mode-600 files named in
|
|
10
|
+
* their environment.
|
|
8
11
|
*/
|
|
9
12
|
export type ServiceKind =
|
|
10
13
|
/** Long-running; restarted when it exits. */
|
|
@@ -31,7 +34,7 @@ export type ServiceSpec = {
|
|
|
31
34
|
workingDir?: string;
|
|
32
35
|
};
|
|
33
36
|
export type ServiceAccount = {
|
|
34
|
-
platform: "linux" | "darwin";
|
|
37
|
+
platform: "linux" | "darwin" | "win32";
|
|
35
38
|
user: string;
|
|
36
39
|
uid: number;
|
|
37
40
|
gid: number;
|
package/dist/host/services.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, userInfo } from "node:os";
|
|
4
|
-
import { posix } from "node:path";
|
|
4
|
+
import { dirname, posix } from "node:path";
|
|
5
5
|
import { ZellijError } from "../errors.js";
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// Linux and macOS accounts carry POSIX paths even when this code runs (in
|
|
7
|
+
// tests) on Windows; a Windows crew account carries native Windows paths.
|
|
8
8
|
const { join } = posix;
|
|
9
9
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
10
10
|
export const defaultRunner = (cmd, args) => {
|
|
@@ -22,6 +22,12 @@ function hostSleep(host, ms) {
|
|
|
22
22
|
/** The account services are installed for: the current user, or `runAs` when root. */
|
|
23
23
|
export function serviceAccount(runAs, run = defaultRunner) {
|
|
24
24
|
const platform = process.platform;
|
|
25
|
+
if (platform === "win32") {
|
|
26
|
+
if (runAs?.trim()) {
|
|
27
|
+
throw new ZellijError("usage", "--run-as is Linux and macOS only; a Windows crew task runs as the signed-in user");
|
|
28
|
+
}
|
|
29
|
+
return { platform: "win32", user: userInfo().username, uid: 0, gid: 0, home: process.env.USERPROFILE || homedir(), foreign: false };
|
|
30
|
+
}
|
|
25
31
|
if (platform !== "linux" && platform !== "darwin") {
|
|
26
32
|
throw new ZellijError("usage", `host services are for Linux and macOS (this is ${platform}); on Windows use serve --install`);
|
|
27
33
|
}
|
|
@@ -72,6 +78,10 @@ function runAsAccount(account, argv, input) {
|
|
|
72
78
|
}
|
|
73
79
|
/** Write a file in the account's home as the account (see accountArgv). */
|
|
74
80
|
export function writeAccountFile(account, path, text, mode) {
|
|
81
|
+
if (account.platform === "win32") {
|
|
82
|
+
writeFileSync(path, text);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
75
85
|
if (!installingFor(account)) {
|
|
76
86
|
writeFileSync(path, text, { mode });
|
|
77
87
|
chmodSync(path, mode);
|
|
@@ -416,6 +426,10 @@ function must(res, what) {
|
|
|
416
426
|
}
|
|
417
427
|
/** Create dir and any missing parents, owned by the account when installing for it. */
|
|
418
428
|
export function ensureAccountDir(account, dir, mode = 0o700) {
|
|
429
|
+
if (account.platform === "win32") {
|
|
430
|
+
mkdirSync(dir, { recursive: true });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
419
433
|
const missing = [];
|
|
420
434
|
for (let d = dir; d && d !== "/" && !existsSync(d); d = d.slice(0, d.lastIndexOf("/")) || "/")
|
|
421
435
|
missing.unshift(d);
|
|
@@ -428,7 +442,7 @@ export function ensureAccountDir(account, dir, mode = 0o700) {
|
|
|
428
442
|
}
|
|
429
443
|
/** Write a mode-600 file for the account, creating its directory (700) as needed. */
|
|
430
444
|
export function writeSecretFile(account, path, text) {
|
|
431
|
-
ensureAccountDir(account,
|
|
445
|
+
ensureAccountDir(account, dirname(path));
|
|
432
446
|
writeAccountFile(account, path, text, 0o600);
|
|
433
447
|
}
|
|
434
448
|
export function readSecretFile(path) {
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,6 @@ export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/
|
|
|
34
34
|
export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
35
35
|
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
|
|
36
36
|
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
|
|
37
|
-
export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
|
|
37
|
+
export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
|
|
38
38
|
export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
|
|
39
39
|
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, type HostCheck, type HostDoctorInput, type HostInstallInput, type SshRunner, } from "./host/host.js";
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,6 @@ export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/
|
|
|
32
32
|
export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
|
|
33
33
|
export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
|
|
34
34
|
export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
|
|
35
|
-
export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
|
|
35
|
+
export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
|
|
36
36
|
export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
|
|
37
37
|
export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
|