@bli-cockpit/cli 0.2.47 → 0.2.49

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.
Files changed (42) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/heartbeat.js +18 -0
  18. package/dist/commands/install-receipts.js +34 -0
  19. package/dist/commands/jarvis.js +179 -3
  20. package/dist/commands/local-arg-values.js +169 -0
  21. package/dist/commands/local-args-collector.js +578 -0
  22. package/dist/commands/local-args-tower.js +870 -0
  23. package/dist/commands/local-args.js +8 -1549
  24. package/dist/commands/local-help.js +11 -3
  25. package/dist/commands/local.js +18 -1786
  26. package/dist/commands/login.js +53 -0
  27. package/dist/commands/logout.js +66 -0
  28. package/dist/commands/onboard-receipts.js +66 -0
  29. package/dist/commands/onboard-report.js +274 -0
  30. package/dist/commands/onboard.js +449 -0
  31. package/dist/commands/ops-render.js +36 -0
  32. package/dist/commands/public-root.js +1 -1
  33. package/dist/commands/serve.js +13 -0
  34. package/dist/commands/session-sync.js +513 -534
  35. package/dist/commands/settings-render.js +28 -0
  36. package/dist/commands/settings.js +66 -2
  37. package/dist/commands/start.js +47 -0
  38. package/dist/commands/sync-followups.js +203 -0
  39. package/dist/commands/sync.js +381 -0
  40. package/dist/dev-build.js +186 -0
  41. package/dist/tower-stream.js +20 -4
  42. package/package.json +2 -2
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `cockpit agent-rules install|uninstall|status` — the ticket-binding guidance
3
+ * Tower writes into a machine's `AGENTS.md` and `CLAUDE.md`.
4
+ *
5
+ * Split out of commands/local.ts (BLI-3578); the files themselves are written
6
+ * by `../agent-rules.ts`. This module is the command: which hosts a verb
7
+ * touches, and the lines it prints. Moved verbatim. `agentRuleHostLabel` is
8
+ * shared with onboarding, which installs the same rules on its way through.
9
+ */
10
+ import path from "node:path";
11
+ import { writeLine } from "./cli-io.js";
12
+ import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
13
+ export async function runAgentRules(command, io) {
14
+ const hosts = agentRuleHosts(command.host);
15
+ const scopePath = path.resolve(command.repoRoot ?? process.cwd());
16
+ const result = command.action === "install"
17
+ ? await installAgentRules({ homeDir: command.homeDir, hosts, scopePath })
18
+ : command.action === "uninstall"
19
+ ? await uninstallAgentRules({ homeDir: command.homeDir, hosts })
20
+ : await inspectAgentRules({ homeDir: command.homeDir, hosts, scopePath });
21
+ if (command.json) {
22
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
23
+ return 0;
24
+ }
25
+ if ("installed" in result) {
26
+ writeLine(io.stdout, result.installed
27
+ ? "Tower agent rules are installed."
28
+ : "Tower agent rules are not installed.");
29
+ }
30
+ else {
31
+ writeLine(io.stdout, agentRulesStatusLine(result));
32
+ }
33
+ for (const target of result.targets) {
34
+ writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
35
+ }
36
+ return 0;
37
+ }
38
+ function agentRuleHosts(host) {
39
+ return host === "all" ? ["codex", "claude"] : [host];
40
+ }
41
+ function agentRulesStatusLine(result) {
42
+ switch (result.status) {
43
+ case "created":
44
+ return "Tower agent rules installed.";
45
+ case "updated":
46
+ return "Tower agent rules updated.";
47
+ case "unchanged":
48
+ return "Tower agent rules already current.";
49
+ case "missing":
50
+ return "Tower agent rules were not installed.";
51
+ }
52
+ }
53
+ export function agentRuleHostLabel(host) {
54
+ return host === "claude" ? "CLAUDE.md" : "AGENTS.md";
55
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * What one sync run REPORTS: the per-session attribution rows posted to the
3
+ * door, and the funnel counts the CLI prints.
4
+ *
5
+ * Split out of commands/session-sync.ts (BLI-3572) because these are pure
6
+ * projections — attribution results plus upload outcomes in, rows and counts
7
+ * out, no I/O and no ordering — while the engine next door is nothing but I/O
8
+ * and ordering. Behavior-preserving move: no logic changed, and every name is
9
+ * still re-exported from `./session-sync.js` for callers that had it there.
10
+ */
11
+ import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
12
+ import { isLiveRawEvidenceSyncAttribution, isRawEvidenceUploadableAttributionState, } from "../raw-evidence-attribution-policy.js";
13
+ /** How many Codex sessions the scan could not read at all. */
14
+ export function codexAttributionReadFailureCount(scan) {
15
+ return scan.directory_read_failed_count + scan.stat_failed_count;
16
+ }
17
+ /** How many Claude sessions or sidecars the scan could not read at all. */
18
+ export function claudeAttributionReadFailureCount(scan) {
19
+ return (scan.project_dir_read_failed_count +
20
+ scan.session_stat_failed_count +
21
+ scan.sidecar_dir_read_failed_count +
22
+ scan.sidecar_stat_failed_count);
23
+ }
24
+ /**
25
+ * How good each attribution state is, for picking one row per session when the
26
+ * same session was attributed more than once. Re-exported under the collector's
27
+ * own name so callers do not have to reach into telemetry-core for it.
28
+ */
29
+ export const ATTRIBUTION_STATE_RANK = CODEX_SESSION_ATTRIBUTION_STATE_RANK;
30
+ function normalizeCodexResult(result) {
31
+ return {
32
+ source: "codex",
33
+ session_id: result.codex_session_id,
34
+ state: result.state,
35
+ reason: result.reason,
36
+ signals: result.signals,
37
+ attribution_score: result.attribution_score,
38
+ path_score: result.path_score,
39
+ content_hash_sha256: result.content_hash_sha256,
40
+ byte_size: result.byte_size,
41
+ session_file_mtime: result.session_file_mtime,
42
+ session_file_mtime_ms: result.session_file_mtime_ms,
43
+ worktree: result.worktree,
44
+ cwd_basename: result.cwd_basename,
45
+ cwd_hash: result.cwd_hash,
46
+ };
47
+ }
48
+ function normalizeClaudeResult(result) {
49
+ return {
50
+ source: "claude_code",
51
+ session_id: result.claude_session_id,
52
+ state: result.state,
53
+ reason: result.reason,
54
+ signals: result.signals,
55
+ attribution_score: result.attribution_score,
56
+ path_score: result.path_score,
57
+ content_hash_sha256: result.content_hash_sha256,
58
+ byte_size: result.byte_size,
59
+ session_file_mtime: result.session_file_mtime,
60
+ session_file_mtime_ms: result.session_file_mtime_ms,
61
+ worktree: result.worktree,
62
+ cwd_basename: result.cwd_basename,
63
+ cwd_hash: result.cwd_hash,
64
+ };
65
+ }
66
+ /**
67
+ * Dedupe every source's attributed results down to the single best result per
68
+ * `(source, session_id)`. Best means highest attribution-state rank, ties
69
+ * broken by the newer file mtime. Codex and Claude sessions that happen to
70
+ * share an id are never collapsed into each other because the key carries the
71
+ * source.
72
+ */
73
+ function bestAttributedSessionsByKey(codexResults, claudeResults) {
74
+ const normalized = [
75
+ ...codexResults.map(normalizeCodexResult),
76
+ ...claudeResults.map(normalizeClaudeResult),
77
+ ];
78
+ const bestByKey = new Map();
79
+ for (const result of normalized) {
80
+ const key = `${result.source}:${result.session_id}`;
81
+ const existing = bestByKey.get(key);
82
+ if (!existing ||
83
+ (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
84
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
85
+ ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
86
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
87
+ result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
88
+ bestByKey.set(key, result);
89
+ }
90
+ }
91
+ return bestByKey;
92
+ }
93
+ /**
94
+ * Which sessions this sync actually uploaded, and why the rest did not — read
95
+ * ONLY from main-file outcomes (kind `codex_jsonl` / `claude_jsonl`); a
96
+ * sidecar making it must never mark a session uploaded when the main did not
97
+ * (D3).
98
+ */
99
+ function sessionUploadOutcomesByKey(outcomes) {
100
+ const uploadByKey = new Map();
101
+ const noUploadReasonByKey = new Map();
102
+ let anySyncIncomplete = false;
103
+ for (const outcome of outcomes) {
104
+ if (outcome.sync.status !== "uploaded") {
105
+ anySyncIncomplete = true;
106
+ continue;
107
+ }
108
+ for (const upload of outcome.sync.raw_evidence_outcomes) {
109
+ if (!upload.codex_session_id)
110
+ continue;
111
+ const source = upload.kind === "claude_jsonl"
112
+ ? "claude_code"
113
+ : upload.kind === "codex_jsonl"
114
+ ? "codex"
115
+ : null;
116
+ if (!source)
117
+ continue; // sidecars and other kinds do not set session state
118
+ const key = `${source}:${upload.codex_session_id}`;
119
+ if (!upload.raw_evidence_pointer_id) {
120
+ // An outcome with no pointer is a failure that named itself. Keep the
121
+ // reason even though there is nothing to point at.
122
+ if (upload.reason)
123
+ noUploadReasonByKey.set(key, upload.reason);
124
+ continue;
125
+ }
126
+ uploadByKey.set(key, upload);
127
+ }
128
+ }
129
+ return { uploadByKey, noUploadReasonByKey, anySyncIncomplete };
130
+ }
131
+ /** Turn one deduped, best-ranked session result into its report row. */
132
+ function sessionReportEntry(result, context) {
133
+ const key = `${result.source}:${result.session_id}`;
134
+ const upload = context.uploadByKey.get(key);
135
+ // A previously-durable Claude session with no fresh main upload this sync
136
+ // (damped / spooled / budget-deferred) reports reused_existing + its prior
137
+ // pointer rather than not_uploaded, so the store row never flips.
138
+ const priorDurablePointer = result.source === "claude_code"
139
+ ? (context.claudePriorDurablePointers.get(result.session_id) ?? null)
140
+ : null;
141
+ return {
142
+ codex_session_id: result.session_id,
143
+ source: result.source,
144
+ observed_at: context.now.toISOString(),
145
+ attribution_state: result.state,
146
+ attribution_reason: result.reason,
147
+ attribution_score: result.attribution_score,
148
+ path_score: result.path_score,
149
+ signals: result.signals,
150
+ ...(result.content_hash_sha256
151
+ ? { session_file_hash_sha256: result.content_hash_sha256 }
152
+ : {}),
153
+ session_file_byte_size: result.byte_size,
154
+ session_file_mtime: result.session_file_mtime,
155
+ ...(result.worktree
156
+ ? {
157
+ repo_fingerprint: result.worktree.repo_fingerprint,
158
+ worktree_fingerprint: result.worktree.worktree_fingerprint,
159
+ repo_label: result.worktree.repo_label,
160
+ branch: result.worktree.branch,
161
+ }
162
+ : {}),
163
+ ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
164
+ ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
165
+ ...(upload
166
+ ? {
167
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
168
+ upload_state: upload.upload_state,
169
+ // A failed upload names itself; a successful one has nothing to
170
+ // explain.
171
+ ...(upload.upload_state === "upload_failed"
172
+ ? {
173
+ upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
174
+ }
175
+ : {}),
176
+ }
177
+ : priorDurablePointer
178
+ ? {
179
+ raw_evidence_pointer_id: priorDurablePointer,
180
+ upload_state: "reused_existing",
181
+ }
182
+ : isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
183
+ ? {
184
+ upload_state: "not_uploaded",
185
+ // BLI-2107: `not_uploaded` used to be the branch of last
186
+ // resort, recording that nothing happened and never why. It
187
+ // now always carries a cause, even when the cause is that we
188
+ // have none — a session labelled NO_UPLOAD_ATTEMPT_RECORDED
189
+ // is a path that still needs instrumenting, and saying so is
190
+ // the point.
191
+ upload_reason: context.noUploadReasonByKey.get(key) ??
192
+ (context.anySyncIncomplete
193
+ ? "sync_incomplete_this_pass"
194
+ : NO_UPLOAD_ATTEMPT_RECORDED),
195
+ }
196
+ : {
197
+ // BLI-3272: the upload policy refused this session's
198
+ // attribution state. That refusal used to spread `{}` here, so
199
+ // the row landed with upload_state NULL and upload_reason NULL
200
+ // — a withhold that named nothing, on 1,256 production
201
+ // sessions. It is a decision like any other and it says so.
202
+ // Any reason the pipeline did record still wins: it is the more
203
+ // specific answer.
204
+ upload_state: "not_uploaded",
205
+ upload_reason: context.noUploadReasonByKey.get(key) ??
206
+ notUploadableAttributionStateReason(result.reason),
207
+ }),
208
+ };
209
+ }
210
+ /**
211
+ * Generalizes the per-session report across sources. Dedupe is per
212
+ * `(source, session_id)` so a Codex session and a Claude session that happen to
213
+ * share an id are never collapsed. Upload state maps ONLY from the main-file
214
+ * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
215
+ * session's upload state (D3). Damped Claude sessions report `reused_existing`
216
+ * carrying their prior durable pointer.
217
+ *
218
+ * The pass, named: pick the best-ranked result per session
219
+ * (`bestAttributedSessionsByKey`), read back what this sync actually uploaded
220
+ * (`sessionUploadOutcomesByKey`), then render one report row per session
221
+ * (`sessionReportEntry`).
222
+ */
223
+ export function buildAgentSessionReport(options) {
224
+ const bestByKey = bestAttributedSessionsByKey(options.codexResults, options.claudeResults);
225
+ const uploadOutcomes = sessionUploadOutcomesByKey(options.outcomes);
226
+ return [...bestByKey.values()].map((result) => sessionReportEntry(result, {
227
+ ...uploadOutcomes,
228
+ claudePriorDurablePointers: options.claudePriorDurablePointers,
229
+ now: options.now,
230
+ }));
231
+ }
232
+ /**
233
+ * The funnel one sync run prints: per-source scan counts, sidecar and
234
+ * damping counts, and the two budget totals.
235
+ */
236
+ export function buildAgentSessionSummary(options) {
237
+ const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
238
+ const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason));
239
+ const sidecarsCollected = attributedClaude.reduce((total, result) => total +
240
+ result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
241
+ const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
242
+ result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
243
+ const codex = {
244
+ scanned: options.codexAttribution.scanned_file_count,
245
+ attributed: options.codexAttribution.counts.attributed,
246
+ attributed_fallback: options.codexAttribution.counts.attributed_fallback,
247
+ ambiguous: options.codexAttribution.counts.ambiguous,
248
+ unattributed: options.codexAttribution.counts.unattributed,
249
+ skipped: options.codexAttribution.counts.skipped,
250
+ stale: options.codexStaleCount,
251
+ read_failures: codexAttributionReadFailureCount(options.codexAttribution),
252
+ };
253
+ const claude = {
254
+ scanned: options.claudeAttribution.scanned_session_count,
255
+ attributed: options.claudeAttribution.counts.attributed,
256
+ attributed_fallback: options.claudeAttribution.counts.attributed_fallback,
257
+ ambiguous: options.claudeAttribution.counts.ambiguous,
258
+ unattributed: options.claudeAttribution.counts.unattributed,
259
+ skipped: options.claudeAttribution.counts.skipped,
260
+ stale: options.claudeStaleCount,
261
+ read_failures: claudeAttributionReadFailureCount(options.claudeAttribution),
262
+ sidecars_collected: sidecarsCollected,
263
+ sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
264
+ upload.upload_state === "reused_existing").length,
265
+ sidecars_skipped: sidecarsSkipped,
266
+ sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
267
+ sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
268
+ mains_oversized: options.claudeAttribution.counts.mains_oversized,
269
+ oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
270
+ project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
271
+ sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
272
+ growth_damped: options.growthDamped,
273
+ first_run_backfill: options.firstRunBackfill,
274
+ };
275
+ return {
276
+ scanned: codex.scanned,
277
+ attributed: codex.attributed,
278
+ attributed_fallback: codex.attributed_fallback,
279
+ ambiguous: codex.ambiguous,
280
+ unattributed: codex.unattributed,
281
+ skipped: codex.skipped,
282
+ stale: codex.stale,
283
+ report_posted: options.report.posted,
284
+ report_reason: options.report.reason,
285
+ codex,
286
+ claude,
287
+ files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
288
+ files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
289
+ };
290
+ }
@@ -0,0 +1,131 @@
1
+ import { bufferedWritable, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
2
+ import { normalizeUrl } from "./local-args.js";
3
+ import { runSync } from "./sync.js";
4
+ import { describeError, isMissingFileFailure } from "../health-detail.js";
5
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
6
+ export async function runAnalyze(command, io) {
7
+ const syncStdout = [];
8
+ const syncStderr = [];
9
+ const syncIo = {
10
+ ...io,
11
+ stdout: bufferedWritable(syncStdout),
12
+ stderr: bufferedWritable(syncStderr),
13
+ };
14
+ const syncCommand = {
15
+ ...command,
16
+ kind: "sync",
17
+ json: true,
18
+ };
19
+ const syncExitCode = await runSync(syncCommand, syncIo);
20
+ const syncOutput = parseCapturedJson(syncStdout);
21
+ if (syncExitCode !== 0 || !isUploadedSyncResult(syncOutput)) {
22
+ if (command.json) {
23
+ writeLine(io.stdout, JSON.stringify({
24
+ status: syncExitCode === 0 ? "sync_not_completed" : "sync_failed",
25
+ sync: syncOutput,
26
+ error: syncStderr.join("").trim()
27
+ || (syncExitCode === 0
28
+ ? "Tower sync did not upload fresh evidence."
29
+ : "Tower sync failed."),
30
+ }, null, 2));
31
+ }
32
+ else {
33
+ replayCaptured(io.stderr, syncStderr);
34
+ writeLine(io.stderr, syncExitCode === 0
35
+ ? "Analysis was not queued because a fresh upload did not complete."
36
+ : "Analysis was not queued because the latest upload failed.");
37
+ }
38
+ return syncExitCode === 0 ? 1 : syncExitCode;
39
+ }
40
+ const paths = getCollectorRuntimePaths(command.homeDir);
41
+ const session = await readLocalCollectorSessionFile(paths).catch((error) => {
42
+ // "Not signed in" is the right sentence for an absent session file and the
43
+ // wrong one for a corrupt one, which `cockpit login` will not repair
44
+ // (BLI-3238).
45
+ if (!isMissingFileFailure(error)) {
46
+ console.error("[cockpit-analyze] session file present but unreadable, reporting as not signed in", JSON.stringify({
47
+ reason: "session_file_unusable",
48
+ ...describeError(error),
49
+ }));
50
+ }
51
+ throw new Error("Tower is not signed in. Run `cockpit onboard` or `cockpit login` first.");
52
+ });
53
+ const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
54
+ const response = await io.fetch(`${dashboardUrl}/api/ambient/analyze`, {
55
+ method: "POST",
56
+ headers: {
57
+ authorization: `Bearer ${session.device_token}`,
58
+ "content-type": "application/json",
59
+ },
60
+ body: "{}",
61
+ });
62
+ const body = await readAnalyzeApiResponse(response);
63
+ if (!response.ok) {
64
+ const message = typeof body.message === "string"
65
+ ? body.message
66
+ : `Analysis request failed with HTTP ${response.status}.`;
67
+ if (command.json) {
68
+ writeLine(io.stdout, JSON.stringify({
69
+ status: "analyze_failed",
70
+ sync: syncOutput,
71
+ http_status: response.status,
72
+ code: typeof body.code === "string" ? body.code : null,
73
+ message,
74
+ retry_after_seconds: typeof body.retry_after_seconds === "number"
75
+ ? body.retry_after_seconds
76
+ : null,
77
+ }, null, 2));
78
+ }
79
+ else {
80
+ replayCaptured(io.stderr, syncStderr);
81
+ writeLine(io.stderr, `Analysis was not queued: ${message}`);
82
+ }
83
+ return 1;
84
+ }
85
+ const jobId = typeof body.job?.id === "string" ? body.job.id : null;
86
+ const jobStatus = typeof body.job?.status === "string" ? body.job.status : "pending";
87
+ if (command.json) {
88
+ writeLine(io.stdout, JSON.stringify({
89
+ status: "queued",
90
+ sync: syncOutput,
91
+ job: body.job ?? null,
92
+ dashboard_url: `${dashboardUrl}/my-work`,
93
+ }, null, 2));
94
+ return 0;
95
+ }
96
+ replayCaptured(io.stderr, syncStderr);
97
+ writeLine(io.stdout, "Tower uploaded your latest work.");
98
+ writeLine(io.stdout, "Tower analysis queued.");
99
+ if (jobId)
100
+ writeLine(io.stdout, `Job: ${jobId}`);
101
+ writeLine(io.stdout, `Status: ${jobStatus === "pending" ? "queued" : jobStatus}`);
102
+ writeLine(io.stdout, `Results: ${dashboardUrl}/my-work`);
103
+ return 0;
104
+ }
105
+ async function readAnalyzeApiResponse(response) {
106
+ const text = await response.text();
107
+ if (!text)
108
+ return {};
109
+ try {
110
+ const value = JSON.parse(text);
111
+ return value && typeof value === "object" ? value : {};
112
+ }
113
+ catch {
114
+ // `{}` erases whatever the analyze endpoint said, including its error, and
115
+ // the operator is left with a job id of `undefined` and no reason. The
116
+ // body is never logged; its shape is what identifies a proxy reply.
117
+ console.error("[cockpit-analyze] reply was not JSON", JSON.stringify({
118
+ reason: "response_body_not_json",
119
+ http_status: response.status,
120
+ byte_size: text.length,
121
+ content_type: response.headers.get("content-type") ?? "none",
122
+ }));
123
+ return {};
124
+ }
125
+ }
126
+ function isUploadedSyncResult(value) {
127
+ return Boolean(value
128
+ && typeof value === "object"
129
+ && !Array.isArray(value)
130
+ && value["status"] === "uploaded");
131
+ }
@@ -0,0 +1,105 @@
1
+ import { defaultExec, writeLine } from "./cli-io.js";
2
+ import { runAutostartHealDetached } from "./autostart-heal.js";
3
+ import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
4
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
5
+ export async function runAutostart(command, io) {
6
+ if (command.action === "heal-detached") {
7
+ // BLI-3553: the internal one-shot the scheduled macOS tick spawns. It has
8
+ // its own receipt and its own exit code; nothing else in this function
9
+ // applies to it.
10
+ return runAutostartHealDetached({
11
+ homeDir: command.homeDir,
12
+ dashboardUrl: command.dashboardUrl,
13
+ parentPid: command.parentPid ?? null,
14
+ json: command.json,
15
+ }, io);
16
+ }
17
+ const exec = io.exec ?? defaultExec();
18
+ const repoRoots = command.action === "install" || command.action === "status"
19
+ ? await resolveAutostartRoots(command.homeDir, command.repoRoot)
20
+ : [];
21
+ const result = command.action === "install"
22
+ ? await installAutostartAgent({
23
+ homeDir: command.homeDir,
24
+ repoRoot: repoRoots[0],
25
+ repoRoots,
26
+ dashboardUrl: command.dashboardUrl,
27
+ intervalSeconds: command.intervalSeconds,
28
+ exec,
29
+ })
30
+ : command.action === "uninstall"
31
+ ? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
32
+ : await autostartStatus({
33
+ homeDir: command.homeDir,
34
+ repoRoot: repoRoots[0],
35
+ repoRoots,
36
+ dashboardUrl: command.dashboardUrl,
37
+ intervalSeconds: command.intervalSeconds,
38
+ exec,
39
+ });
40
+ if (command.json) {
41
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
42
+ return autostartExitCode(command.action, result);
43
+ }
44
+ writeAutostartResult(io, result);
45
+ return autostartExitCode(command.action, result);
46
+ }
47
+ function autostartExitCode(action, result) {
48
+ if (result.status === "unsupported")
49
+ return 1;
50
+ if (action === "status")
51
+ return result.status === "loaded" ? 0 : 1;
52
+ if (action === "install" && result.loaded === false)
53
+ return 1;
54
+ if (action === "uninstall" && result.status === "not_loaded")
55
+ return 1;
56
+ return 0;
57
+ }
58
+ export async function resolveAutostartRoots(homeDir, explicitRoot) {
59
+ if (explicitRoot)
60
+ return [explicitRoot];
61
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
62
+ return config?.default_repo_paths ?? [];
63
+ }
64
+ function writeAutostartResult(io, result) {
65
+ switch (result.status) {
66
+ case "installed":
67
+ writeLine(io.stdout, result.loaded
68
+ ? "Tower autostart installed and loaded."
69
+ : "Tower autostart installed (the operating-system scheduler reported a problem).");
70
+ writeLine(io.stdout, `Label: ${result.label}`);
71
+ writeLine(io.stdout, autostartLocationLine(result));
72
+ writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
73
+ writeLine(io.stdout, `Repo: ${result.work_dir}`);
74
+ writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
75
+ if (!result.loaded && result.message)
76
+ writeLine(io.stderr, result.message);
77
+ return;
78
+ case "uninstalled":
79
+ writeLine(io.stdout, "Tower autostart removed.");
80
+ writeLine(io.stdout, autostartLocationLine(result));
81
+ return;
82
+ case "absent":
83
+ writeLine(io.stdout, "Tower autostart is not installed.");
84
+ writeLine(io.stdout, autostartLocationLine(result));
85
+ return;
86
+ case "loaded":
87
+ writeLine(io.stdout, "Tower autostart is installed and loaded.");
88
+ writeLine(io.stdout, autostartLocationLine(result));
89
+ return;
90
+ case "not_loaded":
91
+ writeLine(io.stdout, "Tower autostart exists but is disabled or not loaded; run `cockpit autostart install` to repair it.");
92
+ writeLine(io.stdout, autostartLocationLine(result));
93
+ if (result.message)
94
+ writeLine(io.stderr, result.message);
95
+ return;
96
+ case "unsupported":
97
+ writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
98
+ return;
99
+ }
100
+ }
101
+ export function autostartLocationLine(result) {
102
+ return result.task_name
103
+ ? `Task: ${result.task_name}`
104
+ : `Plist: ${result.plist_path}`;
105
+ }