@bli-cockpit/cli 0.2.5 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -8
- package/dist/adapters/attribution-core.js +152 -31
- package/dist/adapters/claude-attribution.js +14 -7
- package/dist/adapters/codex-attribution.js +8 -2
- package/dist/adapters/raw-evidence.js +33 -12
- package/dist/agent-rules.js +24 -10
- package/dist/autostart.js +460 -20
- package/dist/commands/backfill.js +582 -87
- package/dist/commands/doctor.js +72 -60
- package/dist/commands/local-args.js +19 -2
- package/dist/commands/local.js +418 -241
- package/dist/commands/public-root.js +6 -1
- package/dist/commands/session-sync.js +259 -54
- package/dist/cursors/backfill-cursor.js +169 -1
- package/dist/cursors/raw-evidence-cursor.js +16 -2
- package/dist/evidence-upload-client.js +139 -107
- package/dist/local-state.js +108 -6
- package/dist/onboarding-roots.js +42 -17
- package/dist/process-runner.js +103 -0
- package/dist/raw-evidence-attribution-policy.js +25 -0
- package/dist/repo-identity.js +142 -26
- package/dist/root-normalization.js +29 -16
- package/dist/spool/local-spool.js +335 -58
- package/dist/upload.js +309 -66
- package/package.json +2 -2
package/dist/autostart.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
|
|
5
5
|
/** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
|
|
6
6
|
export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
|
|
7
|
+
export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
|
|
8
|
+
const WINDOWS_AUTOSTART_SCRIPT_NAME = "autostart-sync.ps1";
|
|
9
|
+
const WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME = "autostart-register.ps1";
|
|
10
|
+
const UTF8_BOM = "\uFEFF";
|
|
7
11
|
export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
|
|
8
|
-
const UNSUPPORTED_MESSAGE = "macOS
|
|
12
|
+
const UNSUPPORTED_MESSAGE = "autostart is supported on macOS and Windows only";
|
|
9
13
|
function plistPathFor(homeDir) {
|
|
10
14
|
return path.join(homeDir, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`);
|
|
11
15
|
}
|
|
@@ -26,6 +30,8 @@ function unsupportedResult(homeDir) {
|
|
|
26
30
|
*/
|
|
27
31
|
export async function installAutostartAgent(options) {
|
|
28
32
|
const platform = options.platform ?? process.platform;
|
|
33
|
+
if (platform === "win32")
|
|
34
|
+
return installWindowsTask(options);
|
|
29
35
|
if (platform !== "darwin")
|
|
30
36
|
return unsupportedResult(options.homeDir);
|
|
31
37
|
const homeDir = options.homeDir ?? os.homedir();
|
|
@@ -34,6 +40,8 @@ export async function installAutostartAgent(options) {
|
|
|
34
40
|
const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
|
|
35
41
|
const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
36
42
|
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
43
|
+
const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath);
|
|
44
|
+
const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
37
45
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
38
46
|
const plistPath = plistPathFor(homeDir);
|
|
39
47
|
const stdoutPath = path.join(paths.state_dir, "sync.log");
|
|
@@ -49,6 +57,8 @@ export async function installAutostartAgent(options) {
|
|
|
49
57
|
workDirs: resolvedWorkDirs,
|
|
50
58
|
dashboardUrl,
|
|
51
59
|
intervalSeconds,
|
|
60
|
+
nodeExecutable,
|
|
61
|
+
cliEntryPoint,
|
|
52
62
|
stdoutPath,
|
|
53
63
|
stderrPath,
|
|
54
64
|
watchPaths,
|
|
@@ -80,6 +90,8 @@ export async function installAutostartAgent(options) {
|
|
|
80
90
|
*/
|
|
81
91
|
export async function uninstallAutostartAgent(options) {
|
|
82
92
|
const platform = options.platform ?? process.platform;
|
|
93
|
+
if (platform === "win32")
|
|
94
|
+
return uninstallWindowsTask(options);
|
|
83
95
|
if (platform !== "darwin")
|
|
84
96
|
return unsupportedResult(options.homeDir);
|
|
85
97
|
const homeDir = options.homeDir ?? os.homedir();
|
|
@@ -92,12 +104,13 @@ export async function uninstallAutostartAgent(options) {
|
|
|
92
104
|
return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
93
105
|
}
|
|
94
106
|
/**
|
|
95
|
-
* Reports whether the agent is installed
|
|
96
|
-
*
|
|
97
|
-
* (0) from `not_loaded` (non-zero).
|
|
107
|
+
* Reports whether the agent is installed, loaded, and still matches the
|
|
108
|
+
* configured roots, cadence, dashboard URL, and Cockpit runtime.
|
|
98
109
|
*/
|
|
99
110
|
export async function autostartStatus(options) {
|
|
100
111
|
const platform = options.platform ?? process.platform;
|
|
112
|
+
if (platform === "win32")
|
|
113
|
+
return windowsTaskStatus(options);
|
|
101
114
|
if (platform !== "darwin")
|
|
102
115
|
return unsupportedResult(options.homeDir);
|
|
103
116
|
const homeDir = options.homeDir ?? os.homedir();
|
|
@@ -105,13 +118,396 @@ export async function autostartStatus(options) {
|
|
|
105
118
|
if (!(await fileExists(plistPath))) {
|
|
106
119
|
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
107
120
|
}
|
|
121
|
+
const workDirs = normalizeWorkDirs(options.repoRoots);
|
|
122
|
+
const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
|
|
123
|
+
const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
|
|
124
|
+
const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
125
|
+
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
126
|
+
const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath);
|
|
127
|
+
const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
128
|
+
const plist = await readFile(plistPath, "utf8").catch(() => "");
|
|
129
|
+
const registrationProblems = darwinAgentRegistrationProblems(plist, {
|
|
130
|
+
workDirs: resolvedWorkDirs,
|
|
131
|
+
dashboardUrl,
|
|
132
|
+
intervalSeconds,
|
|
133
|
+
nodeExecutable,
|
|
134
|
+
cliEntryPoint,
|
|
135
|
+
});
|
|
108
136
|
const list = await options.exec("launchctl", ["list", AUTOSTART_LABEL]);
|
|
137
|
+
if (list.code !== 0)
|
|
138
|
+
registrationProblems.unshift("agent is not loaded");
|
|
109
139
|
return {
|
|
110
|
-
status:
|
|
140
|
+
status: registrationProblems.length === 0 ? "loaded" : "not_loaded",
|
|
111
141
|
label: AUTOSTART_LABEL,
|
|
112
142
|
plist_path: plistPath,
|
|
143
|
+
interval_seconds: intervalSeconds,
|
|
144
|
+
work_dir: workDir,
|
|
145
|
+
work_dirs: resolvedWorkDirs,
|
|
146
|
+
dashboard_url: dashboardUrl,
|
|
147
|
+
...(registrationProblems.length > 0
|
|
148
|
+
? {
|
|
149
|
+
message: `launchd agent needs repair: ${registrationProblems.join("; ")}`,
|
|
150
|
+
}
|
|
151
|
+
: {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function installWindowsTask(options) {
|
|
155
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
156
|
+
const workDirs = normalizeWindowsWorkDirs(options.repoRoots);
|
|
157
|
+
const workDir = path.win32.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
|
|
158
|
+
const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
|
|
159
|
+
const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
160
|
+
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
161
|
+
const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
162
|
+
const scriptPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
163
|
+
const registrationPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
|
|
164
|
+
const nodeExecutable = path.win32.resolve(options.nodeExecutable ?? process.execPath);
|
|
165
|
+
const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
166
|
+
await mkdir(path.dirname(scriptPath), { recursive: true });
|
|
167
|
+
await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
|
|
168
|
+
workDirs: resolvedWorkDirs,
|
|
169
|
+
dashboardUrl,
|
|
170
|
+
nodeExecutable,
|
|
171
|
+
cliEntryPoint,
|
|
172
|
+
})}`, "utf8");
|
|
173
|
+
await writeFile(registrationPath, `${UTF8_BOM}${renderWindowsRegistrationScript({
|
|
174
|
+
taskName: WINDOWS_AUTOSTART_TASK_NAME,
|
|
175
|
+
scriptPath,
|
|
176
|
+
intervalMinutes,
|
|
177
|
+
})}`, "utf8");
|
|
178
|
+
const created = await options.exec("powershell.exe", [
|
|
179
|
+
"-NoProfile",
|
|
180
|
+
"-NonInteractive",
|
|
181
|
+
"-ExecutionPolicy",
|
|
182
|
+
"Bypass",
|
|
183
|
+
"-File",
|
|
184
|
+
registrationPath,
|
|
185
|
+
]);
|
|
186
|
+
const verified = created.code === 0
|
|
187
|
+
? await windowsTaskStatus({
|
|
188
|
+
...options,
|
|
189
|
+
repoRoot: workDir,
|
|
190
|
+
repoRoots: resolvedWorkDirs,
|
|
191
|
+
dashboardUrl,
|
|
192
|
+
intervalSeconds: intervalMinutes * 60,
|
|
193
|
+
nodeExecutable,
|
|
194
|
+
cliEntryPoint,
|
|
195
|
+
})
|
|
196
|
+
: null;
|
|
197
|
+
const loaded = verified?.status === "loaded";
|
|
198
|
+
return {
|
|
199
|
+
status: "installed",
|
|
200
|
+
label: AUTOSTART_LABEL,
|
|
201
|
+
plist_path: scriptPath,
|
|
202
|
+
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
203
|
+
registration_path: registrationPath,
|
|
204
|
+
loaded,
|
|
205
|
+
interval_seconds: intervalMinutes * 60,
|
|
206
|
+
work_dir: workDir,
|
|
207
|
+
work_dirs: resolvedWorkDirs,
|
|
208
|
+
dashboard_url: dashboardUrl,
|
|
209
|
+
...(loaded
|
|
210
|
+
? {}
|
|
211
|
+
: {
|
|
212
|
+
message: created.code === 0
|
|
213
|
+
? verified?.message ??
|
|
214
|
+
"Windows task registration could not be verified"
|
|
215
|
+
: `PowerShell task registration exited ${created.code}: ${created.stderr.trim() ||
|
|
216
|
+
created.stdout.trim() ||
|
|
217
|
+
"unknown error"}`,
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function uninstallWindowsTask(options) {
|
|
222
|
+
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
223
|
+
const registrationPath = windowsAutostartRegistrationScriptPath(options.homeDir);
|
|
224
|
+
const current = await windowsTaskStatus(options);
|
|
225
|
+
if (current.status === "absent") {
|
|
226
|
+
await rm(scriptPath, { force: true });
|
|
227
|
+
await rm(registrationPath, { force: true });
|
|
228
|
+
return {
|
|
229
|
+
...current,
|
|
230
|
+
plist_path: scriptPath,
|
|
231
|
+
registration_path: registrationPath,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const removed = await options.exec("schtasks.exe", [
|
|
235
|
+
"/Delete",
|
|
236
|
+
"/TN",
|
|
237
|
+
WINDOWS_AUTOSTART_TASK_NAME,
|
|
238
|
+
"/F",
|
|
239
|
+
]);
|
|
240
|
+
if (removed.code === 0) {
|
|
241
|
+
await rm(scriptPath, { force: true });
|
|
242
|
+
await rm(registrationPath, { force: true });
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
status: removed.code === 0 ? "uninstalled" : "not_loaded",
|
|
246
|
+
label: AUTOSTART_LABEL,
|
|
247
|
+
plist_path: scriptPath,
|
|
248
|
+
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
249
|
+
registration_path: registrationPath,
|
|
250
|
+
...(removed.code === 0
|
|
251
|
+
? {}
|
|
252
|
+
: {
|
|
253
|
+
message: `schtasks /Delete exited ${removed.code}: ${removed.stderr.trim() || removed.stdout.trim() || "unknown error"}`,
|
|
254
|
+
}),
|
|
113
255
|
};
|
|
114
256
|
}
|
|
257
|
+
async function windowsTaskStatus(options) {
|
|
258
|
+
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
259
|
+
const query = await options.exec("schtasks.exe", [
|
|
260
|
+
"/Query",
|
|
261
|
+
"/TN",
|
|
262
|
+
WINDOWS_AUTOSTART_TASK_NAME,
|
|
263
|
+
"/XML",
|
|
264
|
+
]);
|
|
265
|
+
if (query.code !== 0) {
|
|
266
|
+
const queryMessage = `${query.stderr}\n${query.stdout}`;
|
|
267
|
+
const taskIsKnownAbsent = query.code === 1 &&
|
|
268
|
+
/cannot find|not found|does not exist/iu.test(queryMessage);
|
|
269
|
+
return {
|
|
270
|
+
status: taskIsKnownAbsent ? "absent" : "not_loaded",
|
|
271
|
+
label: AUTOSTART_LABEL,
|
|
272
|
+
plist_path: scriptPath,
|
|
273
|
+
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
274
|
+
message: query.stderr.trim() || query.stdout.trim() || undefined,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const disabled = /<Enabled>\s*false\s*<\/Enabled>/iu.test(query.stdout);
|
|
278
|
+
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
279
|
+
const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
280
|
+
const registrationProblems = windowsTaskRegistrationProblems(query.stdout, {
|
|
281
|
+
scriptPath,
|
|
282
|
+
intervalMinutes,
|
|
283
|
+
powershellPath: windowsPowerShellPath(),
|
|
284
|
+
});
|
|
285
|
+
if (disabled)
|
|
286
|
+
registrationProblems.unshift("task is disabled");
|
|
287
|
+
const scriptExists = await fileExists(scriptPath);
|
|
288
|
+
if (!scriptExists) {
|
|
289
|
+
registrationProblems.push("sync script is missing");
|
|
290
|
+
}
|
|
291
|
+
else if (options.repoRoots && options.repoRoots.length > 0) {
|
|
292
|
+
const expectedScript = `${UTF8_BOM}${renderWindowsSyncScript({
|
|
293
|
+
workDirs: normalizeWindowsWorkDirs(options.repoRoots),
|
|
294
|
+
dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
|
|
295
|
+
nodeExecutable: path.win32.resolve(options.nodeExecutable ?? process.execPath),
|
|
296
|
+
cliEntryPoint: path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? ""),
|
|
297
|
+
})}`;
|
|
298
|
+
const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
|
|
299
|
+
if (currentScript !== expectedScript) {
|
|
300
|
+
registrationProblems.push("sync script does not match the current roots or Cockpit runtime");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
status: registrationProblems.length > 0 ? "not_loaded" : "loaded",
|
|
305
|
+
label: AUTOSTART_LABEL,
|
|
306
|
+
plist_path: scriptPath,
|
|
307
|
+
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
308
|
+
...(registrationProblems.length > 0
|
|
309
|
+
? {
|
|
310
|
+
message: `Windows task needs repair: ${registrationProblems.join("; ")}`,
|
|
311
|
+
}
|
|
312
|
+
: {}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function windowsAutostartScriptPath(homeDir) {
|
|
316
|
+
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
317
|
+
}
|
|
318
|
+
function windowsAutostartRegistrationScriptPath(homeDir) {
|
|
319
|
+
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
|
|
320
|
+
}
|
|
321
|
+
function renderWindowsRegistrationScript(options) {
|
|
322
|
+
return [
|
|
323
|
+
"$ErrorActionPreference = 'Stop'",
|
|
324
|
+
`$taskName = ${powershellLiteral(options.taskName)}`,
|
|
325
|
+
`$syncScriptPath = ${powershellLiteral(options.scriptPath)}`,
|
|
326
|
+
"$powershellPath = Join-Path $PSHOME 'powershell.exe'",
|
|
327
|
+
"$actionArgs = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"' + $syncScriptPath + '\"'",
|
|
328
|
+
"$taskCommand = '\"' + $powershellPath + '\" ' + $actionArgs",
|
|
329
|
+
`& schtasks.exe /Create /TN $taskName /TR $taskCommand /SC MINUTE /MO ${options.intervalMinutes} /IT /RL LIMITED /F | Out-Null`,
|
|
330
|
+
"if ($LASTEXITCODE -ne 0) { throw \"schtasks /Create exited $LASTEXITCODE\" }",
|
|
331
|
+
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew",
|
|
332
|
+
"Set-ScheduledTask -TaskName $taskName -Settings $settings | Out-Null",
|
|
333
|
+
"",
|
|
334
|
+
].join("\r\n");
|
|
335
|
+
}
|
|
336
|
+
function renderWindowsSyncScript(options) {
|
|
337
|
+
const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
338
|
+
? ""
|
|
339
|
+
: ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
|
|
340
|
+
const commands = options.workDirs.flatMap((root) => [
|
|
341
|
+
"try {",
|
|
342
|
+
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs} --json`,
|
|
343
|
+
" if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
|
|
344
|
+
"} catch {",
|
|
345
|
+
" [Console]::Error.WriteLine($_.Exception.Message)",
|
|
346
|
+
" $exitCode = 1",
|
|
347
|
+
"}",
|
|
348
|
+
]);
|
|
349
|
+
return [
|
|
350
|
+
"$ErrorActionPreference = 'Stop'",
|
|
351
|
+
"$exitCode = 0",
|
|
352
|
+
`$nodeExecutable = ${powershellLiteral(options.nodeExecutable)}`,
|
|
353
|
+
`$cliEntryPoint = ${powershellLiteral(options.cliEntryPoint)}`,
|
|
354
|
+
...commands,
|
|
355
|
+
"exit $exitCode",
|
|
356
|
+
"",
|
|
357
|
+
].join("\r\n");
|
|
358
|
+
}
|
|
359
|
+
function windowsTaskRegistrationProblems(taskXml, expected) {
|
|
360
|
+
const decodedXml = decodeXmlEntities(taskXml);
|
|
361
|
+
const expectedInterval = windowsTaskInterval(expected.intervalMinutes);
|
|
362
|
+
const principalsBody = decodedXml
|
|
363
|
+
.match(/<Principals(?:\s[^>]*)?>([\s\S]*?)<\/Principals>/iu)?.[1] ??
|
|
364
|
+
"";
|
|
365
|
+
const principalBlocks = [
|
|
366
|
+
...principalsBody.matchAll(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu),
|
|
367
|
+
].map((match) => match[0]);
|
|
368
|
+
const principalRemainder = principalsBody
|
|
369
|
+
.replace(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu, "")
|
|
370
|
+
.trim();
|
|
371
|
+
const exactPrincipalBlock = principalBlocks.length === 1 && principalRemainder === ""
|
|
372
|
+
? (principalBlocks[0] ?? "")
|
|
373
|
+
: "";
|
|
374
|
+
const principalId = exactPrincipalBlock
|
|
375
|
+
.match(/<Principal\s[^>]*\bid\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
|
|
376
|
+
null;
|
|
377
|
+
const triggersBody = decodedXml.match(/<Triggers(?:\s[^>]*)?>([\s\S]*?)<\/Triggers>/iu)?.[1] ??
|
|
378
|
+
"";
|
|
379
|
+
const triggerBlocks = [
|
|
380
|
+
...triggersBody.matchAll(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu),
|
|
381
|
+
].map((match) => match[0]);
|
|
382
|
+
const triggerRemainder = triggersBody
|
|
383
|
+
.replace(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu, "")
|
|
384
|
+
.trim();
|
|
385
|
+
const exactTriggerBlock = triggerBlocks.length === 1 && triggerRemainder === ""
|
|
386
|
+
? (triggerBlocks[0] ?? "")
|
|
387
|
+
: "";
|
|
388
|
+
const repetitionBlocks = exactTriggerBlock
|
|
389
|
+
? [
|
|
390
|
+
...exactTriggerBlock.matchAll(/<Repetition(?:\s[^>]*)?>[\s\S]*?<\/Repetition>/giu),
|
|
391
|
+
].map((match) => match[0])
|
|
392
|
+
: [];
|
|
393
|
+
const repetition = repetitionBlocks.length === 1 ? repetitionBlocks[0] ?? "" : "";
|
|
394
|
+
const actionsBody = decodedXml.match(/<Actions(?:\s[^>]*)?>([\s\S]*?)<\/Actions>/iu)?.[1] ?? "";
|
|
395
|
+
const actionContext = decodedXml
|
|
396
|
+
.match(/<Actions\s[^>]*\bContext\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
|
|
397
|
+
null;
|
|
398
|
+
const execBlocks = [
|
|
399
|
+
...actionsBody.matchAll(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu),
|
|
400
|
+
].map((match) => match[0]);
|
|
401
|
+
const actionRemainder = actionsBody
|
|
402
|
+
.replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
|
|
403
|
+
.trim();
|
|
404
|
+
const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
|
|
405
|
+
const command = exactExecBlock
|
|
406
|
+
?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
|
|
407
|
+
?.trim() ??
|
|
408
|
+
"";
|
|
409
|
+
const actionArguments = exactExecBlock
|
|
410
|
+
.match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
|
|
411
|
+
?.trim() ?? "";
|
|
412
|
+
const expectedActionArguments = `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${expected.scriptPath}"`;
|
|
413
|
+
const requirements = [
|
|
414
|
+
[
|
|
415
|
+
/<DisallowStartIfOnBatteries>\s*false\s*<\/DisallowStartIfOnBatteries>/iu,
|
|
416
|
+
"task is blocked on battery",
|
|
417
|
+
],
|
|
418
|
+
[
|
|
419
|
+
/<StopIfGoingOnBatteries>\s*false\s*<\/StopIfGoingOnBatteries>/iu,
|
|
420
|
+
"task stops on battery",
|
|
421
|
+
],
|
|
422
|
+
[
|
|
423
|
+
/<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/iu,
|
|
424
|
+
"overlap policy is not IgnoreNew",
|
|
425
|
+
],
|
|
426
|
+
[
|
|
427
|
+
/<StartWhenAvailable>\s*true\s*<\/StartWhenAvailable>/iu,
|
|
428
|
+
"missed-run recovery is disabled",
|
|
429
|
+
],
|
|
430
|
+
];
|
|
431
|
+
const problems = requirements
|
|
432
|
+
.filter(([pattern]) => !pattern.test(decodedXml))
|
|
433
|
+
.map(([, message]) => message);
|
|
434
|
+
if (!exactPrincipalBlock) {
|
|
435
|
+
problems.push("task must contain exactly one principal");
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
if (!/<LogonType>\s*InteractiveToken\s*<\/LogonType>/iu.test(exactPrincipalBlock)) {
|
|
439
|
+
problems.push("logon type is not InteractiveToken");
|
|
440
|
+
}
|
|
441
|
+
if (!/<RunLevel>\s*LeastPrivilege\s*<\/RunLevel>/iu.test(exactPrincipalBlock)) {
|
|
442
|
+
problems.push("task does not run with limited privileges");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (actionContext && actionContext !== principalId) {
|
|
446
|
+
problems.push("task action context does not match its principal");
|
|
447
|
+
}
|
|
448
|
+
if (!exactTriggerBlock) {
|
|
449
|
+
problems.push("task must contain exactly one TimeTrigger");
|
|
450
|
+
}
|
|
451
|
+
if (repetitionBlocks.length !== 1) {
|
|
452
|
+
problems.push("task must contain exactly one repetition schedule");
|
|
453
|
+
}
|
|
454
|
+
else if (!new RegExp(`<Interval>\\s*${escapeRegExp(expectedInterval)}\\s*</Interval>`, "iu").test(repetition)) {
|
|
455
|
+
problems.push(`repetition interval is not ${expectedInterval}`);
|
|
456
|
+
}
|
|
457
|
+
if (/<EndBoundary>/iu.test(exactTriggerBlock)) {
|
|
458
|
+
problems.push("task trigger has an end boundary");
|
|
459
|
+
}
|
|
460
|
+
const randomDelay = exactTriggerBlock
|
|
461
|
+
.match(/<RandomDelay>\s*([^<]+?)\s*<\/RandomDelay>/iu)?.[1]
|
|
462
|
+
?.trim()
|
|
463
|
+
.toUpperCase() ?? null;
|
|
464
|
+
if (randomDelay &&
|
|
465
|
+
!["PT0S", "PT0M", "PT0H"].includes(randomDelay)) {
|
|
466
|
+
problems.push("task trigger has a nonzero random delay");
|
|
467
|
+
}
|
|
468
|
+
if (!exactExecBlock) {
|
|
469
|
+
problems.push("task must contain exactly one Exec action");
|
|
470
|
+
}
|
|
471
|
+
if (!sameWindowsPath(command, expected.powershellPath)) {
|
|
472
|
+
problems.push("task action does not run the expected Windows PowerShell");
|
|
473
|
+
}
|
|
474
|
+
if (actionArguments !== expectedActionArguments) {
|
|
475
|
+
problems.push("task action arguments do not exactly run the Cockpit sync script");
|
|
476
|
+
}
|
|
477
|
+
if (/<Duration>/iu.test(repetition)) {
|
|
478
|
+
problems.push("repetition has a finite duration");
|
|
479
|
+
}
|
|
480
|
+
return problems;
|
|
481
|
+
}
|
|
482
|
+
function windowsPowerShellPath(env = process.env) {
|
|
483
|
+
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
484
|
+
return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
485
|
+
}
|
|
486
|
+
function sameWindowsPath(left, right) {
|
|
487
|
+
if (!left || !right)
|
|
488
|
+
return false;
|
|
489
|
+
return path.win32.resolve(left).toLowerCase() ===
|
|
490
|
+
path.win32.resolve(right).toLowerCase();
|
|
491
|
+
}
|
|
492
|
+
function powershellLiteral(value) {
|
|
493
|
+
return `'${value.replace(/'/gu, "''")}'`;
|
|
494
|
+
}
|
|
495
|
+
function windowsTaskInterval(minutes) {
|
|
496
|
+
const hours = Math.floor(minutes / 60);
|
|
497
|
+
const remainingMinutes = minutes % 60;
|
|
498
|
+
return `PT${hours > 0 ? `${hours}H` : ""}${remainingMinutes > 0 ? `${remainingMinutes}M` : ""}`;
|
|
499
|
+
}
|
|
500
|
+
function decodeXmlEntities(value) {
|
|
501
|
+
return value
|
|
502
|
+
.replace(/"/giu, '"')
|
|
503
|
+
.replace(/'/giu, "'")
|
|
504
|
+
.replace(/</giu, "<")
|
|
505
|
+
.replace(/>/giu, ">")
|
|
506
|
+
.replace(/&/giu, "&");
|
|
507
|
+
}
|
|
508
|
+
function escapeRegExp(value) {
|
|
509
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
510
|
+
}
|
|
115
511
|
/**
|
|
116
512
|
* The transcript directories whose changes should re-trigger a sync, in the
|
|
117
513
|
* order they appear in WatchPaths. Resolved from the same homeDir as the plist
|
|
@@ -130,15 +526,13 @@ async function existingWatchPaths(homeDir) {
|
|
|
130
526
|
return candidates.filter((_, i) => present[i]);
|
|
131
527
|
}
|
|
132
528
|
function renderPlist(options) {
|
|
133
|
-
// The work path is shell-quoted because it lands inside a `/bin/zsh -lc "…"`
|
|
134
|
-
// command string; the whole command is then XML-escaped for the <string>.
|
|
135
|
-
const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
136
|
-
? ""
|
|
137
|
-
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
138
529
|
const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
|
|
139
|
-
const command =
|
|
140
|
-
|
|
141
|
-
.
|
|
530
|
+
const command = renderDarwinSyncCommand({
|
|
531
|
+
workDirs: roots,
|
|
532
|
+
dashboardUrl: options.dashboardUrl,
|
|
533
|
+
nodeExecutable: options.nodeExecutable,
|
|
534
|
+
cliEntryPoint: options.cliEntryPoint,
|
|
535
|
+
});
|
|
142
536
|
return [
|
|
143
537
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
144
538
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -149,7 +543,7 @@ function renderPlist(options) {
|
|
|
149
543
|
" <key>ProgramArguments</key>",
|
|
150
544
|
" <array>",
|
|
151
545
|
" <string>/bin/zsh</string>",
|
|
152
|
-
" <string>-
|
|
546
|
+
" <string>-c</string>",
|
|
153
547
|
` <string>${xmlEscape(command)}</string>`,
|
|
154
548
|
" </array>",
|
|
155
549
|
" <key>StartInterval</key>",
|
|
@@ -162,11 +556,6 @@ function renderPlist(options) {
|
|
|
162
556
|
// lock, and growing-transcript re-upload is damped — so cost stays bounded
|
|
163
557
|
// and no extra debounce is needed.
|
|
164
558
|
...watchPathsBlock(options.watchPaths),
|
|
165
|
-
" <key>EnvironmentVariables</key>",
|
|
166
|
-
" <dict>",
|
|
167
|
-
" <key>PATH</key>",
|
|
168
|
-
" <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
|
|
169
|
-
" </dict>",
|
|
170
559
|
" <key>StandardOutPath</key>",
|
|
171
560
|
` <string>${xmlEscape(options.stdoutPath)}</string>`,
|
|
172
561
|
" <key>StandardErrorPath</key>",
|
|
@@ -176,6 +565,42 @@ function renderPlist(options) {
|
|
|
176
565
|
"",
|
|
177
566
|
].join("\n");
|
|
178
567
|
}
|
|
568
|
+
function renderDarwinSyncCommand(options) {
|
|
569
|
+
const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
570
|
+
? ""
|
|
571
|
+
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
572
|
+
const commands = options.workDirs.map((root) => `${shellQuote(options.nodeExecutable)} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg} --json || exit_code=1`);
|
|
573
|
+
return ["exit_code=0", ...commands, 'exit "$exit_code"'].join("; ");
|
|
574
|
+
}
|
|
575
|
+
function darwinAgentRegistrationProblems(plist, expected) {
|
|
576
|
+
if (!plist)
|
|
577
|
+
return ["plist could not be read"];
|
|
578
|
+
const problems = [];
|
|
579
|
+
if (!plist.includes(` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`)) {
|
|
580
|
+
problems.push("label does not match");
|
|
581
|
+
}
|
|
582
|
+
if (!plist.includes(` <integer>${expected.intervalSeconds}</integer>`)) {
|
|
583
|
+
problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
|
|
584
|
+
}
|
|
585
|
+
const expectedCommand = renderDarwinSyncCommand({
|
|
586
|
+
workDirs: expected.workDirs,
|
|
587
|
+
dashboardUrl: expected.dashboardUrl,
|
|
588
|
+
nodeExecutable: expected.nodeExecutable,
|
|
589
|
+
cliEntryPoint: expected.cliEntryPoint,
|
|
590
|
+
});
|
|
591
|
+
const expectedProgramArguments = [
|
|
592
|
+
" <key>ProgramArguments</key>",
|
|
593
|
+
" <array>",
|
|
594
|
+
" <string>/bin/zsh</string>",
|
|
595
|
+
" <string>-c</string>",
|
|
596
|
+
` <string>${xmlEscape(expectedCommand)}</string>`,
|
|
597
|
+
" </array>",
|
|
598
|
+
].join("\n");
|
|
599
|
+
if (!plist.includes(expectedProgramArguments)) {
|
|
600
|
+
problems.push("command does not match the current roots, dashboard URL, or Cockpit runtime");
|
|
601
|
+
}
|
|
602
|
+
return problems;
|
|
603
|
+
}
|
|
179
604
|
function normalizeWorkDirs(repoRoots) {
|
|
180
605
|
if (!repoRoots)
|
|
181
606
|
return [];
|
|
@@ -190,6 +615,21 @@ function normalizeWorkDirs(repoRoots) {
|
|
|
190
615
|
}
|
|
191
616
|
return normalized;
|
|
192
617
|
}
|
|
618
|
+
function normalizeWindowsWorkDirs(repoRoots) {
|
|
619
|
+
if (!repoRoots)
|
|
620
|
+
return [];
|
|
621
|
+
const seen = new Set();
|
|
622
|
+
const normalized = [];
|
|
623
|
+
for (const root of repoRoots) {
|
|
624
|
+
const resolved = path.win32.resolve(root);
|
|
625
|
+
const key = resolved.toLowerCase();
|
|
626
|
+
if (seen.has(key))
|
|
627
|
+
continue;
|
|
628
|
+
seen.add(key);
|
|
629
|
+
normalized.push(resolved);
|
|
630
|
+
}
|
|
631
|
+
return normalized;
|
|
632
|
+
}
|
|
193
633
|
/**
|
|
194
634
|
* Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
|
|
195
635
|
* transcript dir exists yet (an empty array would tell launchd to watch
|