@bli-cockpit/cli 0.2.28 → 0.2.29
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/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-completeness.js +226 -0
- package/dist/adapters/raw-evidence-git-diff.js +90 -0
- package/dist/adapters/raw-evidence-keys.js +92 -0
- package/dist/adapters/raw-evidence-manifest.js +132 -0
- package/dist/adapters/raw-evidence-pack-store.js +136 -0
- package/dist/adapters/raw-evidence-sanitize.js +190 -0
- package/dist/adapters/raw-evidence.js +656 -1257
- package/dist/commands/backfill.js +7 -0
- package/dist/commands/cli-io.js +92 -0
- package/dist/commands/collection-report.js +135 -0
- package/dist/commands/collection-roots.js +153 -0
- package/dist/commands/install-receipts.js +193 -0
- package/dist/commands/install-update.js +305 -0
- package/dist/commands/local-auth.js +268 -0
- package/dist/commands/local-discovery.js +100 -0
- package/dist/commands/local-help.js +281 -0
- package/dist/commands/local.js +170 -1860
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sessions.js +162 -0
- package/dist/commands/status.js +191 -0
- package/dist/evidence-upload-client.js +43 -2
- package/dist/raw-evidence-staging.js +15 -2
- package/dist/upload-agent-artifacts.js +153 -0
- package/dist/upload-envelope.js +407 -0
- package/dist/upload-evidence-delivery.js +505 -0
- package/dist/upload-http.js +46 -0
- package/dist/upload-session-reports.js +404 -0
- package/dist/upload.js +132 -1264
- package/package.json +2 -2
package/dist/commands/local.js
CHANGED
|
@@ -1,52 +1,58 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The `cockpit` subcommands and the router that picks one.
|
|
3
|
+
*
|
|
4
|
+
* Read `runLocalCockpitCli` below as the table of contents: one case per
|
|
5
|
+
* command, each delegating to a `run<Command>` function. The commands that
|
|
6
|
+
* grew their own supporting cast now live beside this file and are re-exported
|
|
7
|
+
* here so `./local.js` stays the single published entry point (BLI-3104):
|
|
8
|
+
*
|
|
9
|
+
* local-help.ts what --help prints, and the recognised command names
|
|
10
|
+
* cli-io.ts stdout/stderr/stdin plumbing and the production CliIo
|
|
11
|
+
* install-receipts.ts the named step results this machine reports back
|
|
12
|
+
* local-auth.ts which email, the OTP exchange, pairing with fallback
|
|
13
|
+
* collection-roots.ts which folders may be collected, saved and read back
|
|
14
|
+
* local-discovery.ts finding git worktrees inside those roots
|
|
15
|
+
* collection-report.ts how one collection run reads back to a person
|
|
16
|
+
* install-update.ts install, update, self-update, release
|
|
17
|
+
* status.ts `cockpit status`
|
|
18
|
+
* sessions.ts `cockpit sessions`
|
|
19
|
+
*
|
|
20
|
+
* What stays here is orchestration: onboard, login/logout/start, the sync tick,
|
|
21
|
+
* analyze, serve, autostart and agent-rules.
|
|
22
|
+
*/
|
|
4
23
|
import path from "node:path";
|
|
24
|
+
import { bufferedWritable, defaultExec, defaultIo, errorMessage, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
|
|
25
|
+
import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
|
|
26
|
+
import { addInstallEvent, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
27
|
+
import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessToken, requestPairingAccessTokenDetailed, resolveInteractiveLoginEmail, resolveOnboardEmail, } from "./local-auth.js";
|
|
28
|
+
import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
|
|
29
|
+
import { discoverCommandWorktrees, rememberDiscoveryLimits } from "./local-discovery.js";
|
|
30
|
+
import { attributedSyncRunStatus, cursorStatusLine, displayTicketId, rawEvidenceSyncLine, shortSha, worktreeSyncRow, writeAgentSessionSummary, } from "./collection-report.js";
|
|
31
|
+
import { runInstall, runRelease, runSelfUpdate, runUpdate, SelfUpdateError, } from "./install-update.js";
|
|
32
|
+
import { runStatus } from "./status.js";
|
|
33
|
+
import { runSessions } from "./sessions.js";
|
|
5
34
|
import { createCollectorServer } from "../server.js";
|
|
6
35
|
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
7
36
|
import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
|
|
8
37
|
import { runDoctor } from "./doctor.js";
|
|
9
38
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
10
|
-
import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
|
|
11
39
|
import { parseLocalArgs, normalizeUrl } from "./local-args.js";
|
|
12
40
|
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
13
|
-
import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus,
|
|
14
|
-
import { redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
15
|
-
import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
16
|
-
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
17
|
-
import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
41
|
+
import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus, logoutLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, startLocalWorkContext, } from "../local-state.js";
|
|
18
42
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
22
|
-
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
43
|
+
import { runAttributedWorktreeSync, } from "./session-sync.js";
|
|
44
|
+
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, } from "../onboarding-roots.js";
|
|
23
45
|
import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
|
|
24
46
|
import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
|
|
25
47
|
import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
|
|
26
|
-
import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
|
|
27
|
-
import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
|
|
28
48
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"login",
|
|
37
|
-
"pair",
|
|
38
|
-
"logout",
|
|
39
|
-
"start",
|
|
40
|
-
"sync",
|
|
41
|
-
"analyze",
|
|
42
|
-
"backfill",
|
|
43
|
-
"status",
|
|
44
|
-
"sessions",
|
|
45
|
-
"serve",
|
|
46
|
-
"autostart",
|
|
47
|
-
"agent-rules",
|
|
48
|
-
"release",
|
|
49
|
-
]);
|
|
49
|
+
// `./local.js` is the published entry point for this command surface: the
|
|
50
|
+
// public CLI's generated root, commands/root.ts, doctor.ts and the test suite
|
|
51
|
+
// all import from here. Splitting the file must not move a name off it.
|
|
52
|
+
export { rootCommandNames, localCommandHelp } from "./local-help.js";
|
|
53
|
+
export { classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, SYNC_ERROR_DETAIL_MAX_CHARS, } from "./install-receipts.js";
|
|
54
|
+
export { assertCollectionRootPersisted } from "./collection-roots.js";
|
|
55
|
+
export { runSelfUpdate, SelfUpdateError, } from "./install-update.js";
|
|
50
56
|
export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
51
57
|
if (isLocalHelpRequest(argv)) {
|
|
52
58
|
writeLine(io.stdout, localCommandHelp(argv[0]));
|
|
@@ -112,553 +118,6 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
112
118
|
return 1;
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
|
-
export function localCommandHelp(command) {
|
|
116
|
-
if (command)
|
|
117
|
-
return localSubcommandHelp(command);
|
|
118
|
-
return [
|
|
119
|
-
" cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
120
|
-
" cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
|
|
121
|
-
" cockpit upgrade [same flags as update]",
|
|
122
|
-
" cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
|
|
123
|
-
" cockpit fix [same flags as do-everything]",
|
|
124
|
-
" cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
|
|
125
|
-
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
126
|
-
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
127
|
-
" cockpit logout",
|
|
128
|
-
" cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
129
|
-
" cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
130
|
-
" cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
131
|
-
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
132
|
-
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
133
|
-
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
134
|
-
" cockpit serve [--port <port>] [--workspace <path>]",
|
|
135
|
-
" cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
136
|
-
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
137
|
-
" cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
138
|
-
"",
|
|
139
|
-
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
|
140
|
-
].join("\n");
|
|
141
|
-
}
|
|
142
|
-
function localSubcommandHelp(command) {
|
|
143
|
-
const helpByCommand = new Map([
|
|
144
|
-
[
|
|
145
|
-
"onboard",
|
|
146
|
-
[
|
|
147
|
-
"Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
148
|
-
"",
|
|
149
|
-
"Installs, pairs, starts work context(s), syncs once, completes all-history",
|
|
150
|
-
"Codex and Claude backfill for the saved roots, and then prints readiness proof.",
|
|
151
|
-
"If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
|
|
152
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
153
|
-
`Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
|
|
154
|
-
"Pass --dashboard-url only for staging/custom dashboards or to force a different pairing.",
|
|
155
|
-
"Run with no flags in a terminal and it prompts for the dashboard email and OTP code; pass --email to skip the email prompt. Use --no-auth to force the manual approval fallback.",
|
|
156
|
-
"Interactive runs also offer to add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md after readiness proof.",
|
|
157
|
-
],
|
|
158
|
-
],
|
|
159
|
-
[
|
|
160
|
-
"install",
|
|
161
|
-
[
|
|
162
|
-
"Usage: cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
|
|
163
|
-
"",
|
|
164
|
-
"Writes local collector config. Pair with `cockpit login`, then run `cockpit start` when work begins.",
|
|
165
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
166
|
-
"Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
|
|
167
|
-
],
|
|
168
|
-
],
|
|
169
|
-
[
|
|
170
|
-
"update",
|
|
171
|
-
[
|
|
172
|
-
"Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
|
|
173
|
-
"",
|
|
174
|
-
"Updates the global public CLI from npm, then reruns `cockpit onboard`",
|
|
175
|
-
"with the same setup flags so pairing, saved roots, all-history backfill,",
|
|
176
|
-
"agent rules, autostart, and the initial sync are refreshed in one command.",
|
|
177
|
-
"`cockpit upgrade` is an alias.",
|
|
178
|
-
],
|
|
179
|
-
],
|
|
180
|
-
[
|
|
181
|
-
"upgrade",
|
|
182
|
-
[
|
|
183
|
-
"Usage: cockpit upgrade [same flags as cockpit update]",
|
|
184
|
-
"",
|
|
185
|
-
"Alias for `cockpit update`.",
|
|
186
|
-
],
|
|
187
|
-
],
|
|
188
|
-
[
|
|
189
|
-
"do-everything",
|
|
190
|
-
[
|
|
191
|
-
"Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
|
|
192
|
-
"",
|
|
193
|
-
"Converges a blank, existing, or reused intern machine: latest CLI, interactive auth and collection-root recovery when needed, saved roots, autostart, all-history backfill, raw-evidence GC, and sync freshness.",
|
|
194
|
-
"`cockpit fix` is an alias.",
|
|
195
|
-
"Maintainer canary: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
|
|
196
|
-
"--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
|
|
197
|
-
],
|
|
198
|
-
],
|
|
199
|
-
[
|
|
200
|
-
"fix",
|
|
201
|
-
[
|
|
202
|
-
"Usage: cockpit fix [same flags as cockpit do-everything]",
|
|
203
|
-
"",
|
|
204
|
-
"Alias for `cockpit do-everything`.",
|
|
205
|
-
],
|
|
206
|
-
],
|
|
207
|
-
[
|
|
208
|
-
"login",
|
|
209
|
-
[
|
|
210
|
-
"Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
211
|
-
"",
|
|
212
|
-
"Signs in with an email OTP when interactive, starts dashboard device pairing, and stores the approved local session.",
|
|
213
|
-
"Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
|
|
214
|
-
],
|
|
215
|
-
],
|
|
216
|
-
[
|
|
217
|
-
"pair",
|
|
218
|
-
[
|
|
219
|
-
"Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
220
|
-
"",
|
|
221
|
-
"Alias for `cockpit login`.",
|
|
222
|
-
],
|
|
223
|
-
],
|
|
224
|
-
["logout", ["Usage: cockpit logout [--json]", "", "Removes the local device session."]],
|
|
225
|
-
[
|
|
226
|
-
"start",
|
|
227
|
-
[
|
|
228
|
-
"Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
|
|
229
|
-
"",
|
|
230
|
-
"Starts local ambient capture. Parent folders start each child git worktree.",
|
|
231
|
-
"Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
|
|
232
|
-
"Use --clear-ticket to intentionally return the context to general ambient capture.",
|
|
233
|
-
"Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
|
|
234
|
-
"Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
|
|
235
|
-
"Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
|
|
236
|
-
"`--repo <path>` remains supported as a backward-compatible alias; use --workspace in agent guidance.",
|
|
237
|
-
],
|
|
238
|
-
],
|
|
239
|
-
[
|
|
240
|
-
"sync",
|
|
241
|
-
[
|
|
242
|
-
"Usage: cockpit sync [--workspace <path>] [--dashboard-url <url>] [--json]",
|
|
243
|
-
"",
|
|
244
|
-
"Uploads latest local ambient envelope(s), or spools safe retries if blocked.",
|
|
245
|
-
"Omit --dashboard-url for normal production sync; pass it only for staging/custom dashboards or forced re-pairing.",
|
|
246
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
247
|
-
"Parent folders sync each child git worktree; Codex AND Claude Code JSONL",
|
|
248
|
-
"transcripts (and Claude subagent sidecars) are attributed to repos",
|
|
249
|
-
"deterministically and ambiguous transcripts are retained as unattributed",
|
|
250
|
-
"instead of being duplicated across repos. Use `cockpit sessions` to see why",
|
|
251
|
-
"a session is or is not collected.",
|
|
252
|
-
"Newly discovered repos get a general ambient work context automatically.",
|
|
253
|
-
"Discovery scans 3 folder levels and up to 50 repos by default; tune with",
|
|
254
|
-
"--max-depth and --max-repos.",
|
|
255
|
-
"Also self-updates the CLI from npm latest once per day, strictly after",
|
|
256
|
-
"collection finishes; set COCKPIT_DISABLE_AUTO_UPDATE=1 to freeze the",
|
|
257
|
-
"installed version during incident triage.",
|
|
258
|
-
],
|
|
259
|
-
],
|
|
260
|
-
[
|
|
261
|
-
"analyze",
|
|
262
|
-
[
|
|
263
|
-
"Usage: cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--json]",
|
|
264
|
-
"",
|
|
265
|
-
"Uploads the latest local ambient evidence, then queues one analysis job.",
|
|
266
|
-
"The command returns after the batch is queued; view status and results in My Work.",
|
|
267
|
-
"Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
|
|
268
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
269
|
-
],
|
|
270
|
-
],
|
|
271
|
-
[
|
|
272
|
-
"backfill",
|
|
273
|
-
[
|
|
274
|
-
"Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
275
|
-
"",
|
|
276
|
-
"Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
|
|
277
|
-
"Omit --source to scan both sources; there is no --source all literal.",
|
|
278
|
-
"The non-all window is capped at the collector session paired_at timestamp.",
|
|
279
|
-
"--all requires a dry-run review and TTY confirmation; pass --yes for headless agent runs.",
|
|
280
|
-
"Repo discovery scans 3 folder levels and up to 50 repos by default.",
|
|
281
|
-
"Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
|
|
282
|
-
"--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
|
|
283
|
-
],
|
|
284
|
-
],
|
|
285
|
-
[
|
|
286
|
-
"status",
|
|
287
|
-
[
|
|
288
|
-
"Usage: cockpit status [--workspace <path>] [--json]",
|
|
289
|
-
"",
|
|
290
|
-
"Prints install, pairing, active work, upload, and retry state.",
|
|
291
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
292
|
-
],
|
|
293
|
-
],
|
|
294
|
-
[
|
|
295
|
-
"sessions",
|
|
296
|
-
[
|
|
297
|
-
"Usage: cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--json]",
|
|
298
|
-
"",
|
|
299
|
-
"Read-only: re-runs Codex + Claude session attribution and prints each",
|
|
300
|
-
"session's id, source, state, reason, scores, signals, and per-sidecar",
|
|
301
|
-
"skip reasons. No upload, no cursor writes. Answers \"why is session X",
|
|
302
|
-
"missing?\" locally — counts and labels only, never paths or content.",
|
|
303
|
-
"--since-days uses the same paired_at cap as `cockpit backfill`; --all scans the full local history.",
|
|
304
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
305
|
-
],
|
|
306
|
-
],
|
|
307
|
-
[
|
|
308
|
-
"serve",
|
|
309
|
-
[
|
|
310
|
-
"Usage: cockpit serve [--port <port>] [--workspace <path>]",
|
|
311
|
-
"",
|
|
312
|
-
"Starts the local collector HTTP status server.",
|
|
313
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
314
|
-
],
|
|
315
|
-
],
|
|
316
|
-
[
|
|
317
|
-
"autostart",
|
|
318
|
-
[
|
|
319
|
-
"Usage: cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
320
|
-
"",
|
|
321
|
-
"Installs background sync through launchd on Apple Silicon macOS or a",
|
|
322
|
-
"per-user Task Scheduler task on native Windows.",
|
|
323
|
-
"Runs `cockpit sync` every 15 min by default and survives reboots;",
|
|
324
|
-
"the Windows task runs while the user is signed in.",
|
|
325
|
-
"Action defaults to `install`. `--workspace` is the parent work folder to sync.",
|
|
326
|
-
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
327
|
-
"Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
|
|
328
|
-
"See docs/runbooks/cockpit-launchd-sync.md and",
|
|
329
|
-
"docs/runbooks/cockpit-windows-task-scheduler-sync.md.",
|
|
330
|
-
],
|
|
331
|
-
],
|
|
332
|
-
[
|
|
333
|
-
"agent-rules",
|
|
334
|
-
[
|
|
335
|
-
"Usage: cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
336
|
-
"",
|
|
337
|
-
"Installs a managed Cockpit Ticket Binding block into ~/.codex/AGENTS.md",
|
|
338
|
-
"and ~/.claude/CLAUDE.md by default. Pass --host to manage only one.",
|
|
339
|
-
"The block is scoped to --workspace, or the current directory when omitted,",
|
|
340
|
-
"so Codex and Claude only run Cockpit ticket binding inside that onboarded folder.",
|
|
341
|
-
"Action defaults to `install`.",
|
|
342
|
-
],
|
|
343
|
-
],
|
|
344
|
-
[
|
|
345
|
-
"release",
|
|
346
|
-
[
|
|
347
|
-
"Usage: cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
348
|
-
"",
|
|
349
|
-
"Maintainer-only helper. Run from inside the bli-cockpit repo checkout.",
|
|
350
|
-
"Requires a clean `main` branch and runs `git pull --ff-only` before publishing.",
|
|
351
|
-
"Delegates to `npm run publish:public -- ...` so public packages are",
|
|
352
|
-
"built, checked, and published in the safe telemetry-core then CLI order.",
|
|
353
|
-
],
|
|
354
|
-
],
|
|
355
|
-
]);
|
|
356
|
-
return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
|
|
357
|
-
}
|
|
358
|
-
function isLocalHelpRequest(argv) {
|
|
359
|
-
const command = argv[0];
|
|
360
|
-
if (!command || !rootCommandNames.has(command))
|
|
361
|
-
return false;
|
|
362
|
-
return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
|
|
363
|
-
}
|
|
364
|
-
async function runInstall(command, io) {
|
|
365
|
-
const installEvents = [];
|
|
366
|
-
const finish = async (code) => {
|
|
367
|
-
await reportInstallEventsBestEffort({
|
|
368
|
-
homeDir: command.homeDir,
|
|
369
|
-
dashboardUrl: command.dashboardUrl,
|
|
370
|
-
command: "install",
|
|
371
|
-
events: installEvents,
|
|
372
|
-
json: command.json,
|
|
373
|
-
io,
|
|
374
|
-
});
|
|
375
|
-
return code;
|
|
376
|
-
};
|
|
377
|
-
const resolved = resolveInstallCommandRoots(command);
|
|
378
|
-
if (resolved.homeRootOptIn) {
|
|
379
|
-
addInstallEvent(installEvents, "home_root_optin", "ok");
|
|
380
|
-
}
|
|
381
|
-
const result = await installLocalCollector(resolved.command);
|
|
382
|
-
// Same invariant as the onboarding path: never report a successful install
|
|
383
|
-
// over a config that saved no usable collection root.
|
|
384
|
-
await assertCollectionRootPersisted(resolved.command.homeDir);
|
|
385
|
-
addInstallEvent(installEvents, "install", "ok");
|
|
386
|
-
if (command.json) {
|
|
387
|
-
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
388
|
-
return finish(0);
|
|
389
|
-
}
|
|
390
|
-
writeLine(io.stdout, "Cockpit local collector installed.");
|
|
391
|
-
writeLine(io.stdout, `Config: ${result.paths.config_file}`);
|
|
392
|
-
writeLine(io.stdout, `Session: ${result.paths.session_file}`);
|
|
393
|
-
writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
|
|
394
|
-
writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
|
|
395
|
-
return finish(0);
|
|
396
|
-
}
|
|
397
|
-
function resolveInstallCommandRoots(command) {
|
|
398
|
-
const detailed = normalizeRootsDetailed([command.repoRoot ?? process.cwd()], {
|
|
399
|
-
homeDir: command.homeDir,
|
|
400
|
-
allowHomeRoot: command.allowHomeRoot,
|
|
401
|
-
});
|
|
402
|
-
if (detailed.roots.length > 0) {
|
|
403
|
-
const root = detailed.roots[0];
|
|
404
|
-
return {
|
|
405
|
-
command: {
|
|
406
|
-
...command,
|
|
407
|
-
repoRoot: root,
|
|
408
|
-
},
|
|
409
|
-
homeRootOptIn: Boolean(command.allowHomeRoot) &&
|
|
410
|
-
path.resolve(root) === path.resolve(command.homeDir ?? os.homedir()),
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
if (detailed.rejected.length > 0) {
|
|
414
|
-
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(detailed.rejected[0], command)}`);
|
|
415
|
-
}
|
|
416
|
-
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(command)}`);
|
|
417
|
-
}
|
|
418
|
-
async function runUpdate(command, io) {
|
|
419
|
-
const installEvents = [];
|
|
420
|
-
const finish = async (code) => {
|
|
421
|
-
await reportInstallEventsBestEffort({
|
|
422
|
-
homeDir: command.homeDir,
|
|
423
|
-
dashboardUrl: command.dashboardUrl,
|
|
424
|
-
command: "update",
|
|
425
|
-
events: installEvents,
|
|
426
|
-
json: command.json,
|
|
427
|
-
io,
|
|
428
|
-
});
|
|
429
|
-
return code;
|
|
430
|
-
};
|
|
431
|
-
const exec = io.exec ?? defaultExec();
|
|
432
|
-
try {
|
|
433
|
-
await runSelfUpdate(io, { json: command.json });
|
|
434
|
-
}
|
|
435
|
-
catch (error) {
|
|
436
|
-
if (!(error instanceof SelfUpdateError))
|
|
437
|
-
throw error;
|
|
438
|
-
addInstallEvent(installEvents, "npm_install", "fail", error.eacces
|
|
439
|
-
? "npm_install_eacces"
|
|
440
|
-
: "npm_install_failed");
|
|
441
|
-
if (command.json) {
|
|
442
|
-
writeLine(io.stdout, JSON.stringify({
|
|
443
|
-
status: "blocked",
|
|
444
|
-
step: "npm_install",
|
|
445
|
-
command: `npm ${selfUpdateInstallArgs().join(" ")}`,
|
|
446
|
-
exit_code: error.result.code,
|
|
447
|
-
}, null, 2));
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
|
|
451
|
-
if (error.eacces) {
|
|
452
|
-
writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
|
|
453
|
-
writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return finish(error.result.code || 1);
|
|
457
|
-
}
|
|
458
|
-
addInstallEvent(installEvents, "npm_install", "ok");
|
|
459
|
-
if (!command.json) {
|
|
460
|
-
writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
|
|
461
|
-
}
|
|
462
|
-
const onboard = await exec("cockpit", [
|
|
463
|
-
"onboard",
|
|
464
|
-
...updateOnboardArgs(command),
|
|
465
|
-
]);
|
|
466
|
-
writeExecOutput(io, onboard, { stdout: true, stderr: true });
|
|
467
|
-
addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
|
|
468
|
-
return finish(onboard.code);
|
|
469
|
-
}
|
|
470
|
-
export class SelfUpdateError extends Error {
|
|
471
|
-
result;
|
|
472
|
-
eacces;
|
|
473
|
-
constructor(result) {
|
|
474
|
-
super("npm install failed; Cockpit CLI was not refreshed.");
|
|
475
|
-
this.name = "SelfUpdateError";
|
|
476
|
-
this.result = result;
|
|
477
|
-
this.eacces = isNpmEaccesFailure(result.stderr);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
export async function runSelfUpdate(io, options = {}) {
|
|
481
|
-
const exec = io.exec ?? defaultExec();
|
|
482
|
-
const tag = options.tag ?? "latest";
|
|
483
|
-
if (!options.json) {
|
|
484
|
-
writeLine(io.stdout, `Updating Cockpit CLI from npm (${tag})...`);
|
|
485
|
-
}
|
|
486
|
-
const install = await exec("npm", selfUpdateInstallArgs(tag));
|
|
487
|
-
writeExecOutput(io, install, { stdout: !options.json, stderr: true });
|
|
488
|
-
if (install.code !== 0) {
|
|
489
|
-
throw new SelfUpdateError(install);
|
|
490
|
-
}
|
|
491
|
-
return { updated: true, version: LOCAL_COLLECTOR_VERSION };
|
|
492
|
-
}
|
|
493
|
-
function selfUpdateInstallArgs(tag = "latest") {
|
|
494
|
-
return [
|
|
495
|
-
"install",
|
|
496
|
-
"-g",
|
|
497
|
-
`@bli-cockpit/cli@${tag}`,
|
|
498
|
-
"--prefer-online",
|
|
499
|
-
];
|
|
500
|
-
}
|
|
501
|
-
async function runRelease(command, io) {
|
|
502
|
-
const releaseRoot = await findPublicReleaseRoot(process.cwd());
|
|
503
|
-
if (!releaseRoot) {
|
|
504
|
-
writeLine(io.stderr, "cockpit release must be run inside the bli-cockpit repo checkout (missing publish:public script).");
|
|
505
|
-
return 1;
|
|
506
|
-
}
|
|
507
|
-
const exec = io.exec ?? defaultExec();
|
|
508
|
-
const gitReady = await prepareReleaseMainBranch(releaseRoot, exec, io);
|
|
509
|
-
if (!gitReady)
|
|
510
|
-
return 1;
|
|
511
|
-
writeLine(io.stdout, "Running Cockpit public package release...");
|
|
512
|
-
const npmArgs = ["--prefix", releaseRoot, "run", "publish:public"];
|
|
513
|
-
if (command.args.length > 0)
|
|
514
|
-
npmArgs.push("--", ...command.args);
|
|
515
|
-
const releaseExec = io.interactiveExec ?? defaultInteractiveExec();
|
|
516
|
-
const result = await releaseExec("npm", npmArgs);
|
|
517
|
-
writeExecOutput(io, result, { stdout: true, stderr: true });
|
|
518
|
-
return result.code;
|
|
519
|
-
}
|
|
520
|
-
async function prepareReleaseMainBranch(releaseRoot, exec, io) {
|
|
521
|
-
const branch = await exec("git", [
|
|
522
|
-
"-C",
|
|
523
|
-
releaseRoot,
|
|
524
|
-
"rev-parse",
|
|
525
|
-
"--abbrev-ref",
|
|
526
|
-
"HEAD",
|
|
527
|
-
]);
|
|
528
|
-
writeExecOutput(io, branch, { stdout: false, stderr: true });
|
|
529
|
-
if (branch.code !== 0) {
|
|
530
|
-
writeLine(io.stderr, "BLOCKED: cockpit release could not read the current git branch.");
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
const currentBranch = branch.stdout.trim();
|
|
534
|
-
if (currentBranch !== "main") {
|
|
535
|
-
writeLine(io.stderr, `BLOCKED: cockpit release only publishes from main. Current branch is ${currentBranch || "unknown"}.`);
|
|
536
|
-
writeLine(io.stderr, "Merge the release changes, switch to main, then rerun `cockpit release`.");
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
const status = await exec("git", [
|
|
540
|
-
"-C",
|
|
541
|
-
releaseRoot,
|
|
542
|
-
"status",
|
|
543
|
-
"--porcelain",
|
|
544
|
-
]);
|
|
545
|
-
writeExecOutput(io, status, { stdout: false, stderr: true });
|
|
546
|
-
if (status.code !== 0) {
|
|
547
|
-
writeLine(io.stderr, "BLOCKED: cockpit release could not inspect git status.");
|
|
548
|
-
return false;
|
|
549
|
-
}
|
|
550
|
-
if (status.stdout.trim()) {
|
|
551
|
-
writeLine(io.stderr, "BLOCKED: cockpit release requires a clean main checkout.");
|
|
552
|
-
writeLine(io.stderr, "Commit or discard local changes, then rerun `cockpit release`.");
|
|
553
|
-
return false;
|
|
554
|
-
}
|
|
555
|
-
writeLine(io.stdout, "Syncing main with git pull --ff-only...");
|
|
556
|
-
const pull = await exec("git", ["-C", releaseRoot, "pull", "--ff-only"]);
|
|
557
|
-
writeExecOutput(io, pull, { stdout: true, stderr: true });
|
|
558
|
-
if (pull.code !== 0) {
|
|
559
|
-
writeLine(io.stderr, "BLOCKED: git pull --ff-only failed; main is not safely current.");
|
|
560
|
-
return false;
|
|
561
|
-
}
|
|
562
|
-
return true;
|
|
563
|
-
}
|
|
564
|
-
async function findPublicReleaseRoot(startDir) {
|
|
565
|
-
let current = path.resolve(startDir);
|
|
566
|
-
while (true) {
|
|
567
|
-
const packageJsonPath = path.join(current, "package.json");
|
|
568
|
-
try {
|
|
569
|
-
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
570
|
-
if (packageJson.scripts?.["publish:public"] !== undefined)
|
|
571
|
-
return current;
|
|
572
|
-
}
|
|
573
|
-
catch {
|
|
574
|
-
// Keep walking: nested packages may be missing package.json or have one
|
|
575
|
-
// without the release script.
|
|
576
|
-
}
|
|
577
|
-
const parent = path.dirname(current);
|
|
578
|
-
if (parent === current)
|
|
579
|
-
return null;
|
|
580
|
-
current = parent;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
function updateOnboardArgs(command) {
|
|
584
|
-
const args = [];
|
|
585
|
-
if (command.homeDir)
|
|
586
|
-
args.push("--home", command.homeDir);
|
|
587
|
-
for (const root of updateCollectionRoots(command)) {
|
|
588
|
-
args.push("--workspace", root);
|
|
589
|
-
}
|
|
590
|
-
if (command.dashboardUrlExplicit) {
|
|
591
|
-
args.push("--dashboard-url", command.dashboardUrl);
|
|
592
|
-
}
|
|
593
|
-
if (command.claimedOwnerEmail)
|
|
594
|
-
args.push("--email", command.claimedOwnerEmail);
|
|
595
|
-
if (command.noAuth)
|
|
596
|
-
args.push("--no-auth");
|
|
597
|
-
if (command.deviceName)
|
|
598
|
-
args.push("--device-name", command.deviceName);
|
|
599
|
-
if (command.activeTicketId)
|
|
600
|
-
args.push("--ticket", command.activeTicketId);
|
|
601
|
-
if (command.branch)
|
|
602
|
-
args.push("--branch", command.branch);
|
|
603
|
-
if (command.pollIntervalMs !== undefined) {
|
|
604
|
-
args.push("--poll-interval-ms", String(command.pollIntervalMs));
|
|
605
|
-
}
|
|
606
|
-
if (command.timeoutMs !== undefined) {
|
|
607
|
-
args.push("--timeout-ms", String(command.timeoutMs));
|
|
608
|
-
}
|
|
609
|
-
if (command.maxDepth !== undefined)
|
|
610
|
-
args.push("--max-depth", String(command.maxDepth));
|
|
611
|
-
if (command.maxRepos !== undefined)
|
|
612
|
-
args.push("--max-repos", String(command.maxRepos));
|
|
613
|
-
if (command.allowHomeRoot)
|
|
614
|
-
args.push("--allow-home-root");
|
|
615
|
-
if (command.json)
|
|
616
|
-
args.push("--json");
|
|
617
|
-
return args;
|
|
618
|
-
}
|
|
619
|
-
function isNpmEaccesFailure(stderr) {
|
|
620
|
-
return /EACCES|permission denied/i.test(stderr);
|
|
621
|
-
}
|
|
622
|
-
function updateOnboardFailureCode(result) {
|
|
623
|
-
const output = `${result.stdout}\n${result.stderr}`;
|
|
624
|
-
if (output.includes(COLLECTION_ROOT_REQUIRED))
|
|
625
|
-
return COLLECTION_ROOT_REQUIRED;
|
|
626
|
-
if (/pairing|approval|device_pairing/i.test(output))
|
|
627
|
-
return "pairing_timeout";
|
|
628
|
-
if (/sync_blocked|spooled|upload failed|network_or_ingest/i.test(output)) {
|
|
629
|
-
return "sync_blocked";
|
|
630
|
-
}
|
|
631
|
-
return "onboard_rerun_failed";
|
|
632
|
-
}
|
|
633
|
-
function updateCollectionRoots(command) {
|
|
634
|
-
const roots = command.collectionRoots?.length
|
|
635
|
-
? command.collectionRoots
|
|
636
|
-
: command.repoRoot
|
|
637
|
-
? [command.repoRoot]
|
|
638
|
-
: [];
|
|
639
|
-
const seen = new Set();
|
|
640
|
-
const deduped = [];
|
|
641
|
-
for (const root of roots) {
|
|
642
|
-
if (seen.has(root))
|
|
643
|
-
continue;
|
|
644
|
-
seen.add(root);
|
|
645
|
-
deduped.push(root);
|
|
646
|
-
}
|
|
647
|
-
return deduped;
|
|
648
|
-
}
|
|
649
|
-
function addInstallEvent(events, step, status, errorCode,
|
|
650
|
-
// BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
|
|
651
|
-
// pass it; it is redacted at this boundary, not at the call site.
|
|
652
|
-
errorMessage) {
|
|
653
|
-
const detail = errorMessage ? redactedHealthDetail(errorMessage) : "";
|
|
654
|
-
const code = errorCode ? sanitizeInstallErrorCode(errorCode) : undefined;
|
|
655
|
-
events.push({
|
|
656
|
-
step,
|
|
657
|
-
status,
|
|
658
|
-
...(code ? { error_code: code } : {}),
|
|
659
|
-
...(detail && detail !== code ? { error_detail: detail } : {}),
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
121
|
function addOnboardFailureEvent(events, blocker) {
|
|
663
122
|
const step = onboardFailureStep(blocker, events);
|
|
664
123
|
const existing = events.find((event) => event.step === step && event.status === "fail");
|
|
@@ -703,395 +162,6 @@ function onboardFailureStep(blocker, events) {
|
|
|
703
162
|
const lastIncomplete = ["install", "auth", "pair", "work_context", "sync"].find((step) => !events.some((event) => event.step === step));
|
|
704
163
|
return lastIncomplete ?? "sync";
|
|
705
164
|
}
|
|
706
|
-
function sanitizeInstallErrorCode(value) {
|
|
707
|
-
const normalized = value
|
|
708
|
-
.trim()
|
|
709
|
-
.toLowerCase()
|
|
710
|
-
.replace(/[^a-z0-9_]+/gu, "_")
|
|
711
|
-
.replace(/^_+|_+$/gu, "")
|
|
712
|
-
.slice(0, 120);
|
|
713
|
-
return normalized || "unknown";
|
|
714
|
-
}
|
|
715
|
-
/**
|
|
716
|
-
* Posts pending install events. Also the collector's only per-tick listening
|
|
717
|
-
* post: the response carries the server-published `min_cli_version` floor
|
|
718
|
-
* (BLI-2678), so the last one observed is returned for the scheduled
|
|
719
|
-
* self-update step to act on. Every early-out returns null — no receipt, no
|
|
720
|
-
* floor.
|
|
721
|
-
*/
|
|
722
|
-
export async function reportInstallEventsBestEffort(options) {
|
|
723
|
-
if (options.events.length === 0)
|
|
724
|
-
return null;
|
|
725
|
-
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
726
|
-
try {
|
|
727
|
-
await enqueueInstallEventEntry(paths, {
|
|
728
|
-
dashboardUrl: options.dashboardUrl,
|
|
729
|
-
cliVersion: LOCAL_COLLECTOR_VERSION,
|
|
730
|
-
command: options.command,
|
|
731
|
-
osPlatform: os.platform(),
|
|
732
|
-
events: options.events.map((event) => ({
|
|
733
|
-
step: event.step.trim().slice(0, 120),
|
|
734
|
-
status: event.status,
|
|
735
|
-
...(event.error_code
|
|
736
|
-
? { error_code: sanitizeInstallErrorCode(event.error_code) }
|
|
737
|
-
: {}),
|
|
738
|
-
// Already redacted and capped at the point it was produced; bounded
|
|
739
|
-
// again here because this mapping is what the server contract sees.
|
|
740
|
-
...(event.error_detail
|
|
741
|
-
? {
|
|
742
|
-
error_detail: event.error_detail
|
|
743
|
-
.trim()
|
|
744
|
-
.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS),
|
|
745
|
-
}
|
|
746
|
-
: {}),
|
|
747
|
-
...(event.at ? { at: event.at } : {}),
|
|
748
|
-
})),
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
catch {
|
|
752
|
-
if (options.json) {
|
|
753
|
-
writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
|
|
754
|
-
}
|
|
755
|
-
return null;
|
|
756
|
-
}
|
|
757
|
-
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
758
|
-
if (!session ||
|
|
759
|
-
session.session_state !== "valid" ||
|
|
760
|
-
typeof session.device_token !== "string" ||
|
|
761
|
-
!session.device_token) {
|
|
762
|
-
return null;
|
|
763
|
-
}
|
|
764
|
-
const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
|
|
765
|
-
const failures = [];
|
|
766
|
-
let observedMinCliVersion = null;
|
|
767
|
-
for (let offset = 0; offset < pending.length; offset += 5) {
|
|
768
|
-
await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
|
|
769
|
-
const controller = new AbortController();
|
|
770
|
-
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
771
|
-
try {
|
|
772
|
-
const response = await options.io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
|
|
773
|
-
method: "POST",
|
|
774
|
-
headers: {
|
|
775
|
-
"Content-Type": "application/json",
|
|
776
|
-
Authorization: `Bearer ${session.device_token}`,
|
|
777
|
-
},
|
|
778
|
-
body: JSON.stringify({
|
|
779
|
-
cli_version: entry.cli_version,
|
|
780
|
-
command: entry.command,
|
|
781
|
-
os_platform: entry.os_platform,
|
|
782
|
-
events: entry.events,
|
|
783
|
-
}),
|
|
784
|
-
signal: controller.signal,
|
|
785
|
-
});
|
|
786
|
-
if (!response.ok) {
|
|
787
|
-
throw new Error(`http_${response.status}`);
|
|
788
|
-
}
|
|
789
|
-
const receipt = (await response
|
|
790
|
-
.json()
|
|
791
|
-
.catch(() => null));
|
|
792
|
-
if (typeof receipt?.min_cli_version === "string" &&
|
|
793
|
-
receipt.min_cli_version.trim()) {
|
|
794
|
-
observedMinCliVersion = receipt.min_cli_version.trim();
|
|
795
|
-
}
|
|
796
|
-
await removeInstallEventEntry(paths, entry.outbox_id);
|
|
797
|
-
}
|
|
798
|
-
catch (error) {
|
|
799
|
-
const failureReason = classifyInstallTelemetryError(error);
|
|
800
|
-
failures.push(failureReason);
|
|
801
|
-
await recordInstallEventAttemptFailure(paths, entry, {
|
|
802
|
-
attemptedAt: new Date().toISOString(),
|
|
803
|
-
failureReason,
|
|
804
|
-
}).catch(() => undefined);
|
|
805
|
-
}
|
|
806
|
-
finally {
|
|
807
|
-
clearTimeout(timeout);
|
|
808
|
-
}
|
|
809
|
-
}));
|
|
810
|
-
}
|
|
811
|
-
if (options.json && failures.length > 0) {
|
|
812
|
-
writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
|
|
813
|
-
}
|
|
814
|
-
return observedMinCliVersion;
|
|
815
|
-
}
|
|
816
|
-
function classifyInstallTelemetryError(error) {
|
|
817
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
818
|
-
return "timeout";
|
|
819
|
-
}
|
|
820
|
-
const message = errorMessage(error);
|
|
821
|
-
const status = message.match(/http_(\d{3})/i)?.[1];
|
|
822
|
-
if (status)
|
|
823
|
-
return `http_${status}`;
|
|
824
|
-
if (/fetch|network|ENOTFOUND|ECONNREFUSED/i.test(message))
|
|
825
|
-
return "network";
|
|
826
|
-
return "failed";
|
|
827
|
-
}
|
|
828
|
-
function writeExecOutput(io, result, options) {
|
|
829
|
-
if (options.stdout)
|
|
830
|
-
writeRaw(io.stdout, result.stdout);
|
|
831
|
-
if (options.stderr)
|
|
832
|
-
writeRaw(io.stderr, result.stderr);
|
|
833
|
-
}
|
|
834
|
-
function writeRaw(stream, text) {
|
|
835
|
-
if (!text)
|
|
836
|
-
return;
|
|
837
|
-
stream.write(text);
|
|
838
|
-
if (!text.endsWith("\n"))
|
|
839
|
-
stream.write("\n");
|
|
840
|
-
}
|
|
841
|
-
function isInteractiveStdin(io) {
|
|
842
|
-
return Boolean(io.stdin.isTTY);
|
|
843
|
-
}
|
|
844
|
-
/**
|
|
845
|
-
* Reads one line from stdin after writing a prompt. Shared by the onboard email
|
|
846
|
-
* prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
|
|
847
|
-
* headless / piped / spawned runs never block on input.
|
|
848
|
-
*/
|
|
849
|
-
async function readLine(io, prompt) {
|
|
850
|
-
io.stdout.write(prompt);
|
|
851
|
-
io.stdin.setEncoding("utf8");
|
|
852
|
-
return new Promise((resolve) => {
|
|
853
|
-
const onData = (chunk) => {
|
|
854
|
-
io.stdin.removeListener("data", onData);
|
|
855
|
-
io.stdin.pause();
|
|
856
|
-
resolve(chunk);
|
|
857
|
-
};
|
|
858
|
-
io.stdin.resume();
|
|
859
|
-
io.stdin.on("data", onData);
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
/**
|
|
863
|
-
* Asks for the dashboard email so `cockpit onboard` (no flags) need not force a
|
|
864
|
-
* `--email`. Only called when stdin is a TTY and not in --json mode. An empty
|
|
865
|
-
* answer or a non-email skips rather than failing, matching `optionalEmail`'s
|
|
866
|
-
* leniency (the approving admin's account then owns the device).
|
|
867
|
-
*/
|
|
868
|
-
async function promptOnboardEmail(io) {
|
|
869
|
-
const raw = await readLine(io, "What's your @buildlaunchiterate.ca email? (press enter to skip): ");
|
|
870
|
-
const answer = raw.trim().toLowerCase();
|
|
871
|
-
if (!answer)
|
|
872
|
-
return undefined;
|
|
873
|
-
if (!answer.includes("@")) {
|
|
874
|
-
writeLine(io.stderr, `"${answer}" is not an email; continuing without one — the approving admin's account will own this device.`);
|
|
875
|
-
return undefined;
|
|
876
|
-
}
|
|
877
|
-
return answer;
|
|
878
|
-
}
|
|
879
|
-
function onboardingRootPrompt(io) {
|
|
880
|
-
return {
|
|
881
|
-
confirm: async (message) => yesByDefault(await readLine(io, message)),
|
|
882
|
-
input: (message) => readLine(io, message),
|
|
883
|
-
message: (message) => writeLine(io.stdout, message),
|
|
884
|
-
};
|
|
885
|
-
}
|
|
886
|
-
function yesByDefault(raw) {
|
|
887
|
-
const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
888
|
-
return answer !== "n" && answer !== "no";
|
|
889
|
-
}
|
|
890
|
-
const OTP_CODE_PROMPT = [
|
|
891
|
-
"Email code needed:",
|
|
892
|
-
" Check your latest Cockpit email for a 6- to 10-digit code.",
|
|
893
|
-
"What you can do:",
|
|
894
|
-
" 1) Paste the code here.",
|
|
895
|
-
" 2) No code yet: wait for the resend window, then rerun this command.",
|
|
896
|
-
" 3) Can't use email: rerun with --no-auth for manual approval.",
|
|
897
|
-
"Code: ",
|
|
898
|
-
].join("\n");
|
|
899
|
-
const OTP_INVALID_MESSAGE = [
|
|
900
|
-
"Email code was not accepted:",
|
|
901
|
-
" Cockpit expects digits only, length 6 to 10.",
|
|
902
|
-
"What you can do:",
|
|
903
|
-
" 1) Rerun and paste the latest email code.",
|
|
904
|
-
" 2) Use manual approval: rerun this command with --no-auth --email <you@buildlaunchiterate.ca>",
|
|
905
|
-
].join("\n");
|
|
906
|
-
async function resolveOnboardEmail(command, roots, config, io) {
|
|
907
|
-
if (command.claimedOwnerEmail)
|
|
908
|
-
return command.claimedOwnerEmail;
|
|
909
|
-
const inferred = await inferOnboardEmail(command.homeDir, roots, config);
|
|
910
|
-
const interactive = !command.json && isInteractiveStdin(io);
|
|
911
|
-
if (!inferred) {
|
|
912
|
-
return interactive ? promptOnboardEmail(io) : undefined;
|
|
913
|
-
}
|
|
914
|
-
if (inferred.source === "session") {
|
|
915
|
-
if (!command.json)
|
|
916
|
-
writeLine(io.stdout, `Dashboard email: ${inferred.email}`);
|
|
917
|
-
return inferred.email;
|
|
918
|
-
}
|
|
919
|
-
if (!interactive)
|
|
920
|
-
return inferred.email;
|
|
921
|
-
const answer = (await readLine(io, `Use ${inferred.email} for Cockpit pairing? [Y/n] `)).trim().toLowerCase();
|
|
922
|
-
const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
|
|
923
|
-
if (typedEmail)
|
|
924
|
-
return typedEmail;
|
|
925
|
-
if (yesByDefault(answer)) {
|
|
926
|
-
return inferred.email;
|
|
927
|
-
}
|
|
928
|
-
return promptOnboardEmail(io);
|
|
929
|
-
}
|
|
930
|
-
async function resolveInteractiveLoginEmail(command, io) {
|
|
931
|
-
if (command.claimedOwnerEmail)
|
|
932
|
-
return command.claimedOwnerEmail;
|
|
933
|
-
if (command.noAuth || command.json || !isInteractiveStdin(io)) {
|
|
934
|
-
return undefined;
|
|
935
|
-
}
|
|
936
|
-
return promptOnboardEmail(io);
|
|
937
|
-
}
|
|
938
|
-
async function requestPairingAccessToken(input, io) {
|
|
939
|
-
return (await requestPairingAccessTokenDetailed(input, io)).accessToken;
|
|
940
|
-
}
|
|
941
|
-
async function requestPairingAccessTokenDetailed(input, io) {
|
|
942
|
-
if (!input.email ||
|
|
943
|
-
input.noAuth ||
|
|
944
|
-
input.json ||
|
|
945
|
-
!isInteractiveStdin(io)) {
|
|
946
|
-
return {
|
|
947
|
-
status: "skipped",
|
|
948
|
-
errorCode: authSkippedReason(input, io),
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
try {
|
|
952
|
-
const fetchImpl = io.fetch;
|
|
953
|
-
writeLine(io.stdout, `Signing in as ${input.email}.`);
|
|
954
|
-
const start = await postOtpStart(fetchImpl, input.dashboardUrl, input.email);
|
|
955
|
-
const resendAfter = typeof start.resend_after_seconds === "number"
|
|
956
|
-
? start.resend_after_seconds
|
|
957
|
-
: 60;
|
|
958
|
-
writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
|
|
959
|
-
const code = (await readLine(io, OTP_CODE_PROMPT)).trim();
|
|
960
|
-
if (!/^\d{6,10}$/.test(code)) {
|
|
961
|
-
throw new Error(OTP_INVALID_MESSAGE);
|
|
962
|
-
}
|
|
963
|
-
const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
|
|
964
|
-
if (typeof verified.access_token !== "string" || !verified.access_token) {
|
|
965
|
-
throw new Error("OTP verified but dashboard returned no access token.");
|
|
966
|
-
}
|
|
967
|
-
writeLine(io.stdout, `Signed in as ${input.email}.`);
|
|
968
|
-
return { status: "ok", accessToken: verified.access_token };
|
|
969
|
-
}
|
|
970
|
-
catch (error) {
|
|
971
|
-
writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
|
|
972
|
-
writeLine(io.stderr, "Continuing with manual dashboard approval.");
|
|
973
|
-
return { status: "fail", errorCode: classifyAuthError(error) };
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
function authSkippedReason(input, io) {
|
|
977
|
-
if (!input.email)
|
|
978
|
-
return "email_missing";
|
|
979
|
-
if (input.noAuth)
|
|
980
|
-
return "no_auth";
|
|
981
|
-
if (input.json)
|
|
982
|
-
return "json_mode";
|
|
983
|
-
if (!isInteractiveStdin(io))
|
|
984
|
-
return "non_interactive";
|
|
985
|
-
return "auth_skipped";
|
|
986
|
-
}
|
|
987
|
-
function classifyAuthError(error) {
|
|
988
|
-
const message = errorMessage(error);
|
|
989
|
-
if (/\b(otp|code|digit|invalid)\b/i.test(message))
|
|
990
|
-
return "otp_invalid";
|
|
991
|
-
if (/fetch|network|ENOTFOUND|ECONNREFUSED|HTTP/i.test(message)) {
|
|
992
|
-
return "network_or_auth";
|
|
993
|
-
}
|
|
994
|
-
return "auth_failed";
|
|
995
|
-
}
|
|
996
|
-
async function pairLocalCollectorWithAuthFallback(options, io) {
|
|
997
|
-
try {
|
|
998
|
-
return await pairLocalCollector(options);
|
|
999
|
-
}
|
|
1000
|
-
catch (error) {
|
|
1001
|
-
if (!options.pairingAccessToken || !isPairingAuthFailure(error)) {
|
|
1002
|
-
throw error;
|
|
1003
|
-
}
|
|
1004
|
-
writeLine(io.stderr, `Authenticated pairing failed: ${errorMessage(error)}`);
|
|
1005
|
-
writeLine(io.stderr, "Continuing with manual dashboard approval.");
|
|
1006
|
-
return pairLocalCollector({
|
|
1007
|
-
...options,
|
|
1008
|
-
pairingAccessToken: undefined,
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
function isPairingAuthFailure(error) {
|
|
1013
|
-
return /\b(auth|authorization|bearer|token|jwt|otp)\b/i.test(errorMessage(error));
|
|
1014
|
-
}
|
|
1015
|
-
async function postOtpStart(fetchImpl, dashboardUrl, email) {
|
|
1016
|
-
const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/start`, {
|
|
1017
|
-
method: "POST",
|
|
1018
|
-
headers: { "Content-Type": "application/json" },
|
|
1019
|
-
body: JSON.stringify({ email }),
|
|
1020
|
-
});
|
|
1021
|
-
const parsed = await readJsonResponse(response);
|
|
1022
|
-
if (!response.ok) {
|
|
1023
|
-
throw new Error(responseErrorMessage(parsed, "OTP start failed"));
|
|
1024
|
-
}
|
|
1025
|
-
return parsed;
|
|
1026
|
-
}
|
|
1027
|
-
async function postOtpVerify(fetchImpl, dashboardUrl, email, code) {
|
|
1028
|
-
const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/verify`, {
|
|
1029
|
-
method: "POST",
|
|
1030
|
-
headers: { "Content-Type": "application/json" },
|
|
1031
|
-
body: JSON.stringify({ email, code }),
|
|
1032
|
-
});
|
|
1033
|
-
const parsed = await readJsonResponse(response);
|
|
1034
|
-
if (!response.ok) {
|
|
1035
|
-
throw new Error(responseErrorMessage(parsed, "OTP verify failed"));
|
|
1036
|
-
}
|
|
1037
|
-
return parsed;
|
|
1038
|
-
}
|
|
1039
|
-
async function readJsonResponse(response) {
|
|
1040
|
-
const text = await response.text();
|
|
1041
|
-
if (!text)
|
|
1042
|
-
return null;
|
|
1043
|
-
try {
|
|
1044
|
-
return JSON.parse(text);
|
|
1045
|
-
}
|
|
1046
|
-
catch {
|
|
1047
|
-
return text;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
function responseErrorMessage(parsed, fallback) {
|
|
1051
|
-
if (parsed && typeof parsed === "object") {
|
|
1052
|
-
const record = parsed;
|
|
1053
|
-
if (typeof record["message"] === "string" && record["message"].trim()) {
|
|
1054
|
-
return record["message"];
|
|
1055
|
-
}
|
|
1056
|
-
if (typeof record["error"] === "string" && record["error"].trim()) {
|
|
1057
|
-
return record["error"];
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
return fallback;
|
|
1061
|
-
}
|
|
1062
|
-
async function inferOnboardEmail(homeDir, roots, config) {
|
|
1063
|
-
const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
|
|
1064
|
-
const sessionEmail = normalizeEmailForComparison(session?.email);
|
|
1065
|
-
if (session?.session_state === "valid" && sessionEmail) {
|
|
1066
|
-
return { email: sessionEmail, source: "session" };
|
|
1067
|
-
}
|
|
1068
|
-
const configEmail = normalizeEmailForComparison(config?.claimed_owner_email);
|
|
1069
|
-
if (configEmail)
|
|
1070
|
-
return { email: configEmail, source: "config" };
|
|
1071
|
-
const gitEmail = await inferUniqueGitEmail(roots);
|
|
1072
|
-
return gitEmail ? { email: gitEmail, source: "git" } : null;
|
|
1073
|
-
}
|
|
1074
|
-
async function inferUniqueGitEmail(roots) {
|
|
1075
|
-
const emails = new Set();
|
|
1076
|
-
for (const root of roots) {
|
|
1077
|
-
const email = await readGitConfigEmail(root);
|
|
1078
|
-
if (email)
|
|
1079
|
-
emails.add(email);
|
|
1080
|
-
}
|
|
1081
|
-
return emails.size === 1 ? [...emails][0] ?? null : null;
|
|
1082
|
-
}
|
|
1083
|
-
async function readGitConfigEmail(root) {
|
|
1084
|
-
return new Promise((resolve) => {
|
|
1085
|
-
execFile("git", ["config", "user.email"], { cwd: root, encoding: "utf8" }, (error, stdout) => {
|
|
1086
|
-
if (error) {
|
|
1087
|
-
resolve(null);
|
|
1088
|
-
return;
|
|
1089
|
-
}
|
|
1090
|
-
const email = normalizeEmailForComparison(stdout);
|
|
1091
|
-
resolve(email?.includes("@") ? email : null);
|
|
1092
|
-
});
|
|
1093
|
-
});
|
|
1094
|
-
}
|
|
1095
165
|
/** Refreshes the host autostart backend after successful onboarding. */
|
|
1096
166
|
async function refreshOnboardAutostart(command, roots, io) {
|
|
1097
167
|
if (!io.exec)
|
|
@@ -1199,6 +269,25 @@ function onboardBackfillPayload(outcome) {
|
|
|
1199
269
|
retry_command: outcome.retryCommand,
|
|
1200
270
|
});
|
|
1201
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* The blocked-backfill result, identical in both onboarding arms. `extra`
|
|
274
|
+
* carries the arm-specific keys and is spread where those keys appeared before,
|
|
275
|
+
* so the `--json` key order is unchanged.
|
|
276
|
+
*/
|
|
277
|
+
function writeOnboardBackfillBlockedResult(io, options) {
|
|
278
|
+
if (!options.json) {
|
|
279
|
+
writeOnboardBackfillBlocker(io, options.backfill);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
writeLine(io.stdout, JSON.stringify({
|
|
283
|
+
...options.base,
|
|
284
|
+
...options.extra,
|
|
285
|
+
backfill: onboardBackfillPayload(options.backfill),
|
|
286
|
+
blocker: "backfill_incomplete",
|
|
287
|
+
backfill_failure_reason: options.backfill.failureReason,
|
|
288
|
+
next_step: options.backfill.retryCommand,
|
|
289
|
+
}, null, 2));
|
|
290
|
+
}
|
|
1202
291
|
function writeOnboardBackfillBlocker(io, outcome) {
|
|
1203
292
|
writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
|
|
1204
293
|
writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
|
|
@@ -1216,125 +305,80 @@ function backgroundSyncLine(result) {
|
|
|
1216
305
|
}
|
|
1217
306
|
return result.status;
|
|
1218
307
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
308
|
+
function writeOnboardBanner(command, io) {
|
|
309
|
+
writeLine(io.stdout, "Cockpit harvest onboarding");
|
|
310
|
+
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
|
|
311
|
+
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Step 2 of onboarding: reuse the device session when it already belongs to
|
|
315
|
+
* this owner on this dashboard, otherwise sign in and pair — logging the old
|
|
316
|
+
* session out first when it belongs to someone else.
|
|
317
|
+
*
|
|
318
|
+
* Returns null when an existing session was reused, which is why the caller's
|
|
319
|
+
* `pair` stays null and the readiness proof falls back to the status file.
|
|
320
|
+
*/
|
|
321
|
+
async function pairForOnboarding(command, input, installEvents, io) {
|
|
322
|
+
const installedStatus = await inspectLocalCollectorStatus({
|
|
1224
323
|
homeDir: command.homeDir,
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
interactive,
|
|
1228
|
-
allowHomeRoot: command.allowHomeRoot,
|
|
1229
|
-
prompt: interactive ? onboardingRootPrompt(io) : undefined,
|
|
324
|
+
repoRoot: input.primaryRoot,
|
|
325
|
+
branch: command.branch,
|
|
1230
326
|
});
|
|
1231
|
-
const
|
|
1232
|
-
const
|
|
1233
|
-
if (
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
|
|
327
|
+
const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
|
|
328
|
+
const canReuseInstalledSession = canReuseOnboardSession(installedSession, input.claimedOwnerEmail, command.dashboardUrl);
|
|
329
|
+
if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
|
|
330
|
+
addInstallEvent(installEvents, "auth", "skipped", "existing_session");
|
|
331
|
+
addInstallEvent(installEvents, "pair", "skipped", "existing_session");
|
|
332
|
+
if (!command.json) {
|
|
333
|
+
writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
if (installedStatus.session_state === "valid") {
|
|
338
|
+
await logoutLocalCollector({ homeDir: command.homeDir });
|
|
339
|
+
if (!command.json) {
|
|
340
|
+
writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const authResult = await requestPairingAccessTokenDetailed({
|
|
344
|
+
dashboardUrl: command.dashboardUrl,
|
|
345
|
+
email: input.claimedOwnerEmail,
|
|
346
|
+
noAuth: command.noAuth,
|
|
347
|
+
json: command.json,
|
|
348
|
+
}, io);
|
|
349
|
+
addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
|
|
350
|
+
const pair = await pairLocalCollectorWithAuthFallback({
|
|
1249
351
|
homeDir: command.homeDir,
|
|
1250
|
-
repoRoot: resolution.primaryRoot,
|
|
1251
|
-
repoRoots: resolution.collectionRoots,
|
|
1252
|
-
replaceRepoRoots: resolution.replaceRepoRoots,
|
|
1253
352
|
dashboardUrl: command.dashboardUrl,
|
|
353
|
+
claimedOwnerEmail: input.claimedOwnerEmail,
|
|
1254
354
|
deviceName: command.deviceName,
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
355
|
+
pollIntervalMs: command.pollIntervalMs,
|
|
356
|
+
timeoutMs: command.timeoutMs,
|
|
357
|
+
pairingAccessToken: authResult.accessToken,
|
|
358
|
+
fetch: io.fetch,
|
|
359
|
+
onPairStarted: command.json
|
|
360
|
+
? undefined
|
|
361
|
+
: (request) => writePairingInstructions(io, request),
|
|
362
|
+
}, io);
|
|
363
|
+
addInstallEvent(installEvents, "pair", "ok");
|
|
364
|
+
if (!command.json) {
|
|
365
|
+
writeLine(io.stdout, "2/5 Device paired.");
|
|
366
|
+
writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
|
|
367
|
+
writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
|
|
368
|
+
}
|
|
369
|
+
return pair;
|
|
1258
370
|
}
|
|
1259
371
|
/**
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
* it back. Savina's onboard did exactly that, persisted nothing, and every
|
|
1264
|
-
* scheduled sync afterwards threw `collection_root_required` into a log nobody
|
|
1265
|
-
* reads — twelve consecutive failures, six days at 3 uploaded of 195, found
|
|
1266
|
-
* only by hand-querying the database. BLI-1986 fixed one path into that state;
|
|
1267
|
-
* this closes the state itself.
|
|
1268
|
-
*
|
|
1269
|
-
* So we read the config back through the SAME resolution the scheduled sync
|
|
1270
|
-
* will use, and fail here — in front of a human who can still fix it — rather
|
|
1271
|
-
* than silently handing back a machine that will never collect.
|
|
372
|
+
* The last two setup steps of a successful onboard, in the order both arms run
|
|
373
|
+
* them. `ok` is false only when a supported scheduler was asked to load
|
|
374
|
+
* recurring collection and refused — an unsupported host is not a failure.
|
|
1272
375
|
*/
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
}
|
|
1279
|
-
// Present in the file is not the same as usable. A root that no longer
|
|
1280
|
-
// exists on disk resolves to nothing at sync time, which is the same silent
|
|
1281
|
-
// dead end arriving one step later.
|
|
1282
|
-
const usable = [];
|
|
1283
|
-
for (const root of saved) {
|
|
1284
|
-
if (await directoryExists(root))
|
|
1285
|
-
usable.push(root);
|
|
1286
|
-
}
|
|
1287
|
-
if (usable.length === 0) {
|
|
1288
|
-
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
|
|
1289
|
-
}
|
|
1290
|
-
return usable;
|
|
1291
|
-
}
|
|
1292
|
-
// Placeholders like <path-to-your-work-folder> make a person stop and think.
|
|
1293
|
-
// These print real, paste-able commands with this machine's actual paths in
|
|
1294
|
-
// them, so the fix is a copy away rather than a puzzle.
|
|
1295
|
-
function collectionRootNotPersistedMessage(homeDir) {
|
|
1296
|
-
const home = path.resolve(homeDir ?? os.homedir());
|
|
1297
|
-
return [
|
|
1298
|
-
"Setup finished without saving a collection root, so this machine would never collect anything.",
|
|
1299
|
-
"Nothing was saved, so nothing is broken — setup just did not finish.",
|
|
1300
|
-
"",
|
|
1301
|
-
"Fix it by running ONE of these:",
|
|
1302
|
-
"",
|
|
1303
|
-
" # Sync everything on this machine (what most people want on a work laptop)",
|
|
1304
|
-
" cockpit do-everything --allow-home-root",
|
|
1305
|
-
"",
|
|
1306
|
-
" # Or sync one folder — replace the path with where your projects live",
|
|
1307
|
-
` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
|
|
1308
|
-
"",
|
|
1309
|
-
" # Or answer the folder question interactively",
|
|
1310
|
-
" cockpit do-everything",
|
|
1311
|
-
"",
|
|
1312
|
-
"Then check it worked:",
|
|
1313
|
-
" cockpit status",
|
|
1314
|
-
].join("\n");
|
|
1315
|
-
}
|
|
1316
|
-
function collectionRootMissingOnDiskMessage(saved, homeDir) {
|
|
1317
|
-
const home = path.resolve(homeDir ?? os.homedir());
|
|
1318
|
-
return [
|
|
1319
|
-
"Cockpit is set up to collect from a folder that is not on this machine:",
|
|
1320
|
-
...saved.map((root) => ` ${root}`),
|
|
1321
|
-
"",
|
|
1322
|
-
"That usually means the folder was renamed, moved, or deleted since setup.",
|
|
1323
|
-
"",
|
|
1324
|
-
"Fix it by running ONE of these:",
|
|
1325
|
-
"",
|
|
1326
|
-
" # Point Cockpit at where your projects actually live now",
|
|
1327
|
-
` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
|
|
1328
|
-
"",
|
|
1329
|
-
" # Or sync everything on this machine and stop worrying about the path",
|
|
1330
|
-
" cockpit do-everything --allow-home-root",
|
|
1331
|
-
"",
|
|
1332
|
-
"Not sure where your projects are? This lists the folders Cockpit can see:",
|
|
1333
|
-
" cockpit status",
|
|
1334
|
-
].join("\n");
|
|
1335
|
-
}
|
|
1336
|
-
async function directoryExists(dir) {
|
|
1337
|
-
return stat(dir).then((stats) => stats.isDirectory(), () => false);
|
|
376
|
+
async function completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io) {
|
|
377
|
+
const agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
|
|
378
|
+
addInstallEvent(installEvents, "agent_rules", "ok");
|
|
379
|
+
const autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
|
|
380
|
+
addAutostartInstallEvent(installEvents, autostart);
|
|
381
|
+
return { agentRules, autostart, ok: !onboardAutostartFailed(autostart) };
|
|
1338
382
|
}
|
|
1339
383
|
async function runDoctorLogin(command, io) {
|
|
1340
384
|
return runLogin({
|
|
@@ -1383,11 +427,8 @@ async function runOnboard(command, io) {
|
|
|
1383
427
|
let agentRules = null;
|
|
1384
428
|
let autostart = null;
|
|
1385
429
|
try {
|
|
1386
|
-
if (!command.json)
|
|
1387
|
-
|
|
1388
|
-
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
|
|
1389
|
-
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
1390
|
-
}
|
|
430
|
+
if (!command.json)
|
|
431
|
+
writeOnboardBanner(command, io);
|
|
1391
432
|
const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
|
|
1392
433
|
const existingConfig = resolvedRoots.existingConfig;
|
|
1393
434
|
rootsResult = resolvedRoots.rootsResult;
|
|
@@ -1412,54 +453,7 @@ async function runOnboard(command, io) {
|
|
|
1412
453
|
writeLine(io.stdout, "1/5 Installed local collector.");
|
|
1413
454
|
writeLine(io.stdout, `Config: ${install.paths.config_file}`);
|
|
1414
455
|
}
|
|
1415
|
-
|
|
1416
|
-
homeDir: command.homeDir,
|
|
1417
|
-
repoRoot: primaryRoot,
|
|
1418
|
-
branch: command.branch,
|
|
1419
|
-
});
|
|
1420
|
-
const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
|
|
1421
|
-
const canReuseInstalledSession = canReuseOnboardSession(installedSession, claimedOwnerEmail, command.dashboardUrl);
|
|
1422
|
-
if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
|
|
1423
|
-
addInstallEvent(installEvents, "auth", "skipped", "existing_session");
|
|
1424
|
-
addInstallEvent(installEvents, "pair", "skipped", "existing_session");
|
|
1425
|
-
if (!command.json) {
|
|
1426
|
-
writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
else {
|
|
1430
|
-
if (installedStatus.session_state === "valid" && !canReuseInstalledSession) {
|
|
1431
|
-
await logoutLocalCollector({ homeDir: command.homeDir });
|
|
1432
|
-
if (!command.json) {
|
|
1433
|
-
writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
const authResult = await requestPairingAccessTokenDetailed({
|
|
1437
|
-
dashboardUrl: command.dashboardUrl,
|
|
1438
|
-
email: claimedOwnerEmail,
|
|
1439
|
-
noAuth: command.noAuth,
|
|
1440
|
-
json: command.json,
|
|
1441
|
-
}, io);
|
|
1442
|
-
addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
|
|
1443
|
-
pair = await pairLocalCollectorWithAuthFallback({
|
|
1444
|
-
homeDir: command.homeDir,
|
|
1445
|
-
dashboardUrl: command.dashboardUrl,
|
|
1446
|
-
claimedOwnerEmail,
|
|
1447
|
-
deviceName: command.deviceName,
|
|
1448
|
-
pollIntervalMs: command.pollIntervalMs,
|
|
1449
|
-
timeoutMs: command.timeoutMs,
|
|
1450
|
-
pairingAccessToken: authResult.accessToken,
|
|
1451
|
-
fetch: io.fetch,
|
|
1452
|
-
onPairStarted: command.json
|
|
1453
|
-
? undefined
|
|
1454
|
-
: (request) => writePairingInstructions(io, request),
|
|
1455
|
-
}, io);
|
|
1456
|
-
addInstallEvent(installEvents, "pair", "ok");
|
|
1457
|
-
if (!command.json) {
|
|
1458
|
-
writeLine(io.stdout, "2/5 Device paired.");
|
|
1459
|
-
writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
|
|
1460
|
-
writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
456
|
+
pair = await pairForOnboarding(command, { primaryRoot, claimedOwnerEmail }, installEvents, io);
|
|
1463
457
|
const worktrees = await discoverCommandWorktrees(collectionRoots, {
|
|
1464
458
|
maxDepth: command.maxDepth,
|
|
1465
459
|
maxRepos: command.maxRepos,
|
|
@@ -1491,30 +485,26 @@ async function runOnboard(command, io) {
|
|
|
1491
485
|
? undefined
|
|
1492
486
|
: backfill.failureReason ?? "backfill_incomplete");
|
|
1493
487
|
if (!completedBackfill) {
|
|
1494
|
-
|
|
1495
|
-
|
|
488
|
+
writeOnboardBackfillBlockedResult(io, {
|
|
489
|
+
json: command.json,
|
|
490
|
+
base: {
|
|
1496
491
|
...onboardResult("blocked", resolvedCommand, install, pair, null, null),
|
|
1497
492
|
collection_roots: collectionRoots,
|
|
1498
493
|
root_resolution: rootsResult,
|
|
494
|
+
},
|
|
495
|
+
extra: {
|
|
1499
496
|
mode: "multi_repo",
|
|
1500
497
|
repos: multi.results,
|
|
1501
498
|
codex_sessions: multi.codex_sessions,
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
next_step: backfill.retryCommand,
|
|
1506
|
-
}, null, 2));
|
|
1507
|
-
}
|
|
1508
|
-
else {
|
|
1509
|
-
writeOnboardBackfillBlocker(io, backfill);
|
|
1510
|
-
}
|
|
499
|
+
},
|
|
500
|
+
backfill,
|
|
501
|
+
});
|
|
1511
502
|
return finish(1);
|
|
1512
503
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
autostart =
|
|
1516
|
-
|
|
1517
|
-
const onboardOk = !onboardAutostartFailed(autostart);
|
|
504
|
+
const multiSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
|
|
505
|
+
agentRules = multiSetup.agentRules;
|
|
506
|
+
autostart = multiSetup.autostart;
|
|
507
|
+
const onboardOk = multiSetup.ok;
|
|
1518
508
|
if (command.json) {
|
|
1519
509
|
writeLine(io.stdout, JSON.stringify({
|
|
1520
510
|
...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
|
|
@@ -1585,14 +575,14 @@ async function runOnboard(command, io) {
|
|
|
1585
575
|
});
|
|
1586
576
|
if (!run.ok || sync.status !== "uploaded") {
|
|
1587
577
|
addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
|
|
1588
|
-
const
|
|
578
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
1589
579
|
if (command.json) {
|
|
1590
580
|
writeLine(io.stdout, JSON.stringify({
|
|
1591
581
|
...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
|
|
1592
582
|
collection_roots: collectionRoots,
|
|
1593
583
|
root_resolution: rootsResult,
|
|
1594
584
|
codex_sessions: run.summary,
|
|
1595
|
-
collection_status:
|
|
585
|
+
collection_status: collectionRunStatus,
|
|
1596
586
|
}, null, 2));
|
|
1597
587
|
}
|
|
1598
588
|
else {
|
|
@@ -1601,7 +591,7 @@ async function runOnboard(command, io) {
|
|
|
1601
591
|
? sync.retry_command
|
|
1602
592
|
: `cockpit sync --workspace ${JSON.stringify(worktreeRoot)}`;
|
|
1603
593
|
writeLine(io.stderr, "BLOCKED: initial collection is incomplete; retry until every eligible session has a durable receipt or explicit terminal reason.");
|
|
1604
|
-
writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ?
|
|
594
|
+
writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ? collectionRunStatus : run.summary.report_reason)}`);
|
|
1605
595
|
writeAgentSessionSummary(io, run.summary);
|
|
1606
596
|
writeLine(io.stderr, `Retry: ${retryCommand}`);
|
|
1607
597
|
}
|
|
@@ -1614,29 +604,23 @@ async function runOnboard(command, io) {
|
|
|
1614
604
|
? undefined
|
|
1615
605
|
: backfill.failureReason ?? "backfill_incomplete");
|
|
1616
606
|
if (!completedBackfill) {
|
|
1617
|
-
|
|
1618
|
-
|
|
607
|
+
writeOnboardBackfillBlockedResult(io, {
|
|
608
|
+
json: command.json,
|
|
609
|
+
base: {
|
|
1619
610
|
...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
|
|
1620
611
|
collection_roots: collectionRoots,
|
|
1621
612
|
root_resolution: rootsResult,
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
next_step: backfill.retryCommand,
|
|
1627
|
-
}, null, 2));
|
|
1628
|
-
}
|
|
1629
|
-
else {
|
|
1630
|
-
writeOnboardBackfillBlocker(io, backfill);
|
|
1631
|
-
}
|
|
613
|
+
},
|
|
614
|
+
extra: { codex_sessions: run.summary },
|
|
615
|
+
backfill,
|
|
616
|
+
});
|
|
1632
617
|
return finish(1);
|
|
1633
618
|
}
|
|
1634
619
|
if (command.json) {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
autostart =
|
|
1638
|
-
|
|
1639
|
-
const onboardOk = !onboardAutostartFailed(autostart);
|
|
620
|
+
const jsonSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
|
|
621
|
+
agentRules = jsonSetup.agentRules;
|
|
622
|
+
autostart = jsonSetup.autostart;
|
|
623
|
+
const onboardOk = jsonSetup.ok;
|
|
1640
624
|
writeLine(io.stdout, JSON.stringify({
|
|
1641
625
|
...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, sync, status),
|
|
1642
626
|
collection_roots: collectionRoots,
|
|
@@ -1665,11 +649,10 @@ async function runOnboard(command, io) {
|
|
|
1665
649
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
1666
650
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
1667
651
|
writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
autostart =
|
|
1671
|
-
|
|
1672
|
-
if (onboardAutostartFailed(autostart)) {
|
|
652
|
+
const setup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
|
|
653
|
+
agentRules = setup.agentRules;
|
|
654
|
+
autostart = setup.autostart;
|
|
655
|
+
if (!setup.ok) {
|
|
1673
656
|
writeOnboardAutostartBlocker(io, autostart);
|
|
1674
657
|
return finish(1);
|
|
1675
658
|
}
|
|
@@ -1715,212 +698,6 @@ async function runOnboard(command, io) {
|
|
|
1715
698
|
return finish(1);
|
|
1716
699
|
}
|
|
1717
700
|
}
|
|
1718
|
-
function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
|
|
1719
|
-
if (session.session_state !== "valid")
|
|
1720
|
-
return false;
|
|
1721
|
-
const expectedEmail = normalizeEmailForComparison(claimedOwnerEmail);
|
|
1722
|
-
if (expectedEmail && normalizeEmailForComparison(session.email) !== expectedEmail) {
|
|
1723
|
-
return false;
|
|
1724
|
-
}
|
|
1725
|
-
const expectedDashboardUrl = normalizeUrlForComparison(dashboardUrl);
|
|
1726
|
-
if (expectedDashboardUrl &&
|
|
1727
|
-
normalizeUrlForComparison(session.dashboard_url) !== expectedDashboardUrl) {
|
|
1728
|
-
return false;
|
|
1729
|
-
}
|
|
1730
|
-
return true;
|
|
1731
|
-
}
|
|
1732
|
-
function normalizeEmailForComparison(value) {
|
|
1733
|
-
const normalized = value?.trim().toLowerCase();
|
|
1734
|
-
return normalized ? normalized : null;
|
|
1735
|
-
}
|
|
1736
|
-
async function readOnboardSessionReuseCandidate(homeDir) {
|
|
1737
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
1738
|
-
try {
|
|
1739
|
-
return await readLocalCollectorSessionFile(paths);
|
|
1740
|
-
}
|
|
1741
|
-
catch {
|
|
1742
|
-
return readLocalSessionReference(paths);
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
function normalizeUrlForComparison(value) {
|
|
1746
|
-
return value ? normalizeUrl(value) : null;
|
|
1747
|
-
}
|
|
1748
|
-
function shortSha(value) {
|
|
1749
|
-
return value ? value.slice(0, 12) : "unknown";
|
|
1750
|
-
}
|
|
1751
|
-
function sourceFunnelLine(label, counts) {
|
|
1752
|
-
const readFailures = counts.read_failures > 0 ? `, read_failures ${counts.read_failures}` : "";
|
|
1753
|
-
return `${label} sessions: attributed ${counts.attributed}, fallback ${counts.attributed_fallback}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}${readFailures}`;
|
|
1754
|
-
}
|
|
1755
|
-
function attributionReportLine(summary) {
|
|
1756
|
-
return summary.report_posted
|
|
1757
|
-
? "Attribution report: recorded"
|
|
1758
|
-
: `Attribution report: skipped (${summary.report_reason})`;
|
|
1759
|
-
}
|
|
1760
|
-
/**
|
|
1761
|
-
* One funnel line per source, an anomaly diagnostics line only when something
|
|
1762
|
-
* is nonzero (clean syncs stay one line per source), and the report line.
|
|
1763
|
-
* Counts only — project-dir slugs encode full local paths and never print
|
|
1764
|
-
* (B.4 §5).
|
|
1765
|
-
*/
|
|
1766
|
-
function writeAgentSessionSummary(io, summary) {
|
|
1767
|
-
writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
|
|
1768
|
-
writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
|
|
1769
|
-
const claudeDiagnostics = claudeDiagnosticsLine(summary);
|
|
1770
|
-
if (claudeDiagnostics)
|
|
1771
|
-
writeLine(io.stdout, claudeDiagnostics);
|
|
1772
|
-
writeLine(io.stdout, attributionReportLine(summary));
|
|
1773
|
-
}
|
|
1774
|
-
function claudeDiagnosticsLine(summary) {
|
|
1775
|
-
// Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
|
|
1776
|
-
// counters and must NOT trigger this line, or a healthy orchestrated sync
|
|
1777
|
-
// prints it 48×/day in launchd logs. Clean syncs stay one line per source.
|
|
1778
|
-
const claude = summary.claude;
|
|
1779
|
-
const parts = [];
|
|
1780
|
-
if (claude.sidecars_skipped)
|
|
1781
|
-
parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
|
|
1782
|
-
if (claude.sidecars_capped)
|
|
1783
|
-
parts.push(`sidecars_capped ${claude.sidecars_capped}`);
|
|
1784
|
-
if (claude.sidecars_failed)
|
|
1785
|
-
parts.push(`sidecars_failed ${claude.sidecars_failed}`);
|
|
1786
|
-
if (claude.mains_oversized)
|
|
1787
|
-
parts.push(`mains_oversized ${claude.mains_oversized}`);
|
|
1788
|
-
if (claude.oversized_lines_skipped)
|
|
1789
|
-
parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
|
|
1790
|
-
if (claude.project_dirs_skipped)
|
|
1791
|
-
parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
|
|
1792
|
-
if (claude.sessions_schema_drift)
|
|
1793
|
-
parts.push(`schema_drift ${claude.sessions_schema_drift}`);
|
|
1794
|
-
if (claude.growth_damped)
|
|
1795
|
-
parts.push(`growth_damped ${claude.growth_damped}`);
|
|
1796
|
-
if (claude.first_run_backfill)
|
|
1797
|
-
parts.push("first_run_backfill");
|
|
1798
|
-
if (summary.files_deferred_byte_budget)
|
|
1799
|
-
parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
|
|
1800
|
-
if (summary.files_deferred_object_budget)
|
|
1801
|
-
parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
|
|
1802
|
-
return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
|
|
1803
|
-
}
|
|
1804
|
-
function rawEvidenceSyncLine(sync) {
|
|
1805
|
-
const failures = sync.raw_evidence_failure_reasons.length > 0
|
|
1806
|
-
? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
|
|
1807
|
-
: "";
|
|
1808
|
-
const retries = sync.raw_evidence_retry_reasons.length > 0
|
|
1809
|
-
? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
|
|
1810
|
-
: "";
|
|
1811
|
-
// A held object and a nine-day-old first failure both belong on this line.
|
|
1812
|
-
// Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
|
|
1813
|
-
// invisible (BLI-3066).
|
|
1814
|
-
const held = sync.raw_evidence_delivery_held_count > 0
|
|
1815
|
-
? ` held: ${sync.raw_evidence_delivery_held_count}`
|
|
1816
|
-
: "";
|
|
1817
|
-
const stuck = sync.raw_evidence_stuck_object_count > 0
|
|
1818
|
-
? ` stuck: ${sync.raw_evidence_stuck_object_count} (worst ${sync.raw_evidence_max_delivery_attempts} attempt(s) since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
|
|
1819
|
-
: "";
|
|
1820
|
-
return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${held}${stuck}${failures}${retries}`;
|
|
1821
|
-
}
|
|
1822
|
-
function cursorStatusLine(sync) {
|
|
1823
|
-
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
1824
|
-
}
|
|
1825
|
-
const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
|
|
1826
|
-
const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
|
|
1827
|
-
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
1828
|
-
// What the operator typed this run, else what they typed some previous run,
|
|
1829
|
-
// else the built-in defaults (BLI-2362).
|
|
1830
|
-
const limits = await resolveDiscoveryLimits(discovery, discovery.homeDir);
|
|
1831
|
-
const maxWorktrees = limits.maxRepos;
|
|
1832
|
-
const roots = Array.isArray(repoRoot)
|
|
1833
|
-
? repoRoot
|
|
1834
|
-
: [repoRoot ?? process.cwd()];
|
|
1835
|
-
const result = await discoverGitWorktreesInRootsWithStatus(roots, {
|
|
1836
|
-
maxDepth: limits.maxDepth,
|
|
1837
|
-
maxWorktrees,
|
|
1838
|
-
});
|
|
1839
|
-
if (io && result.unreadable_dirs.length > 0) {
|
|
1840
|
-
// Never silently dropped: anything under these folders is missing from the
|
|
1841
|
-
// scan, so say so even when the run otherwise succeeds.
|
|
1842
|
-
writeLine(io.stderr, unreadableDirectoriesMessage(result.unreadable_dirs));
|
|
1843
|
-
}
|
|
1844
|
-
const worktrees = result.worktrees;
|
|
1845
|
-
if (!result.complete) {
|
|
1846
|
-
// Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
|
|
1847
|
-
// cursor after a partial scan would mark the run as covering repos it
|
|
1848
|
-
// never saw, permanently skipping their sessions — backfill can tolerate
|
|
1849
|
-
// partial only because it keeps per-scope completion markers, and sync
|
|
1850
|
-
// does not. The bug (BLI-2362) was that the refusal named no roots and
|
|
1851
|
-
// gave no runnable command, so a big workspace just stayed red forever.
|
|
1852
|
-
const message = incompleteDiscoveryMessage({
|
|
1853
|
-
result,
|
|
1854
|
-
roots,
|
|
1855
|
-
maxDepth: limits.maxDepth,
|
|
1856
|
-
maxRepos: maxWorktrees,
|
|
1857
|
-
found: worktrees.length,
|
|
1858
|
-
});
|
|
1859
|
-
if (io)
|
|
1860
|
-
writeLine(io.stderr, message);
|
|
1861
|
-
throw new Error(message);
|
|
1862
|
-
}
|
|
1863
|
-
if (worktrees.length === 0 && !discovery.allowEmpty) {
|
|
1864
|
-
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
1865
|
-
}
|
|
1866
|
-
return worktrees;
|
|
1867
|
-
}
|
|
1868
|
-
/**
|
|
1869
|
-
* Persists `--max-depth` / `--max-repos` when a command carried them, so the
|
|
1870
|
-
* number survives into the background sync and the doctor's own sync — neither
|
|
1871
|
-
* of which has anywhere to type one (BLI-2362). Best-effort: failing to record
|
|
1872
|
-
* a preference must never fail the command the operator actually asked for.
|
|
1873
|
-
*/
|
|
1874
|
-
async function rememberDiscoveryLimits(command) {
|
|
1875
|
-
const limits = command;
|
|
1876
|
-
if (limits.maxDepth === undefined && limits.maxRepos === undefined)
|
|
1877
|
-
return;
|
|
1878
|
-
await saveDiscoveryLimits({ maxDepth: limits.maxDepth, maxRepos: limits.maxRepos }, limits.homeDir).catch(() => undefined);
|
|
1879
|
-
}
|
|
1880
|
-
/**
|
|
1881
|
-
* Says which folders could not be opened, and therefore what the scan could not
|
|
1882
|
-
* see. Reported without failing the run — an unreadable folder cannot be fixed
|
|
1883
|
-
* by retrying, so blocking on one would strand the machine (BLI-2362).
|
|
1884
|
-
*/
|
|
1885
|
-
function unreadableDirectoriesMessage(unreadable) {
|
|
1886
|
-
return [
|
|
1887
|
-
`WARNING: ${unreadable.length} folder(s) could not be opened, so anything inside them was not scanned:`,
|
|
1888
|
-
...unreadable.map((dir) => ` ${dir.path} (${dir.code})`),
|
|
1889
|
-
"Collection continued for everything else. If a repo is missing from Cockpit,",
|
|
1890
|
-
"check the permissions on the folders above.",
|
|
1891
|
-
].join("\n");
|
|
1892
|
-
}
|
|
1893
|
-
/**
|
|
1894
|
-
* Names the roots that could not be covered and hands back a command that
|
|
1895
|
-
* actually fixes it, with this machine's numbers already filled in.
|
|
1896
|
-
*/
|
|
1897
|
-
function incompleteDiscoveryMessage(input) {
|
|
1898
|
-
const { result, roots, maxDepth, maxRepos, found } = input;
|
|
1899
|
-
const blocked = result.incomplete_roots.length > 0 ? result.incomplete_roots : roots;
|
|
1900
|
-
const hitRepoCap = result.incomplete_reasons.includes("max_worktrees_reached");
|
|
1901
|
-
const nextDepth = maxDepth + 3;
|
|
1902
|
-
const nextRepos = Math.max(maxRepos * 2, found + 50);
|
|
1903
|
-
const retry = [
|
|
1904
|
-
"cockpit do-everything",
|
|
1905
|
-
...roots.map((root) => `--workspace ${root}`),
|
|
1906
|
-
`--max-depth ${hitRepoCap ? maxDepth : nextDepth}`,
|
|
1907
|
-
`--max-repos ${nextRepos}`,
|
|
1908
|
-
].join(" ");
|
|
1909
|
-
return [
|
|
1910
|
-
`Cockpit could not finish scanning for repos, so it stopped instead of collecting a partial picture (${result.incomplete_reasons.join(", ")}).`,
|
|
1911
|
-
"It stops rather than continuing because a partial scan would mark these repos as already checked and skip them from now on.",
|
|
1912
|
-
"",
|
|
1913
|
-
"Could not fully scan:",
|
|
1914
|
-
...blocked.map((root) => ` ${root}`),
|
|
1915
|
-
"",
|
|
1916
|
-
`Found ${found} repo(s) before stopping, with --max-depth ${maxDepth} and --max-repos ${maxRepos}.`,
|
|
1917
|
-
"",
|
|
1918
|
-
"Run this to raise the limits and try again:",
|
|
1919
|
-
` ${retry}`,
|
|
1920
|
-
"",
|
|
1921
|
-
"If that still stops, the folder is deeper or larger than expected — raise the numbers again, or point --workspace at the specific project folders instead of a parent.",
|
|
1922
|
-
].join("\n");
|
|
1923
|
-
}
|
|
1924
701
|
async function runMultiRepoOnboard(command, io, worktrees) {
|
|
1925
702
|
if (!command.json) {
|
|
1926
703
|
writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
|
|
@@ -1938,7 +715,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
1938
715
|
fetchImpl: io.fetch,
|
|
1939
716
|
});
|
|
1940
717
|
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
1941
|
-
const
|
|
718
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
1942
719
|
if (!command.json) {
|
|
1943
720
|
for (const [index, outcome] of run.outcomes.entries()) {
|
|
1944
721
|
const row = results[index];
|
|
@@ -1954,42 +731,12 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
1954
731
|
writeLine(io.stdout, "Live sync receipts complete; verifying all-history Codex and Claude backfill.");
|
|
1955
732
|
}
|
|
1956
733
|
else {
|
|
1957
|
-
writeLine(io.stderr, `BLOCKED: Cockpit collection is ${
|
|
734
|
+
writeLine(io.stderr, `BLOCKED: Cockpit collection is ${collectionRunStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
|
|
1958
735
|
}
|
|
1959
736
|
writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
|
|
1960
737
|
}
|
|
1961
738
|
return { ok: run.ok, results, codex_sessions: run.summary };
|
|
1962
739
|
}
|
|
1963
|
-
function worktreeSyncRow(outcome, run) {
|
|
1964
|
-
const { worktree, context, sync } = outcome;
|
|
1965
|
-
const matchesWorktree = (result) => matchesLiveSyncWorktree(result, worktree);
|
|
1966
|
-
const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
|
|
1967
|
-
const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
|
|
1968
|
-
const attributedSessionCount = codexSessionCount + claudeSessionCount;
|
|
1969
|
-
return {
|
|
1970
|
-
repo_label: context?.repo_label ?? worktree.repo_label,
|
|
1971
|
-
repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
|
|
1972
|
-
worktree_label: context?.worktree_label ?? worktree.worktree_label,
|
|
1973
|
-
worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
|
|
1974
|
-
branch: context?.branch ?? worktree.branch,
|
|
1975
|
-
head_sha: sync.head_sha ?? worktree.head_sha,
|
|
1976
|
-
work_context_id: context?.work_context_id ?? sync.work_context_id,
|
|
1977
|
-
upload_status: sync.status,
|
|
1978
|
-
raw_evidence_file_count: sync.raw_evidence_file_count,
|
|
1979
|
-
raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
|
|
1980
|
-
raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
|
|
1981
|
-
raw_evidence_reused_count: sync.raw_evidence_reused_count,
|
|
1982
|
-
raw_evidence_failed_count: sync.raw_evidence_failed_count,
|
|
1983
|
-
raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
|
|
1984
|
-
raw_evidence_retry_required: sync.raw_evidence_retry_required,
|
|
1985
|
-
raw_evidence_retry_reasons: sync.raw_evidence_retry_reasons,
|
|
1986
|
-
attributed_session_count: attributedSessionCount,
|
|
1987
|
-
codex_session_count: codexSessionCount,
|
|
1988
|
-
claude_session_count: claudeSessionCount,
|
|
1989
|
-
cursor_tracked_object_count: sync.cursor_tracked_object_count,
|
|
1990
|
-
failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
|
|
1991
|
-
};
|
|
1992
|
-
}
|
|
1993
740
|
async function runLogin(command, io) {
|
|
1994
741
|
// Standalone `cockpit login` must work on a fresh machine: bootstrap a
|
|
1995
742
|
// minimal rootless config when onboard/install has not run yet, instead of
|
|
@@ -2442,46 +1189,6 @@ async function runSyncWithHealthReceipt(command, io) {
|
|
|
2442
1189
|
await lock.handle.release();
|
|
2443
1190
|
}
|
|
2444
1191
|
}
|
|
2445
|
-
export function classifySyncHealthError(error) {
|
|
2446
|
-
const message = errorMessage(error);
|
|
2447
|
-
if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
|
|
2448
|
-
return "auth_failed";
|
|
2449
|
-
}
|
|
2450
|
-
if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
|
|
2451
|
-
return "network_failed";
|
|
2452
|
-
}
|
|
2453
|
-
// Anchored on the code the collector actually throws rather than on loose
|
|
2454
|
-
// vocabulary. The old test matched /collection.root|workspace|repo|worktree/
|
|
2455
|
-
// against the message, so any failure that merely mentioned a repo was filed
|
|
2456
|
-
// as a collection-root failure and the real reason was lost (BLI-2492).
|
|
2457
|
-
if (message.includes(COLLECTION_ROOT_REQUIRED) ||
|
|
2458
|
-
/collection root/iu.test(message)) {
|
|
2459
|
-
return "collection_root_failed";
|
|
2460
|
-
}
|
|
2461
|
-
return "sync_failed";
|
|
2462
|
-
}
|
|
2463
|
-
// The bucket above is for aggregation. This is the reason — the actual message,
|
|
2464
|
-
// redacted on the machine that produced it, before it ever leaves.
|
|
2465
|
-
//
|
|
2466
|
-
// Error text can carry absolute paths and, on some auth failures, token-shaped
|
|
2467
|
-
// fragments. It goes through the same deterministic redaction the collector
|
|
2468
|
-
// already applies to evidence, and is capped so one pathological stack trace
|
|
2469
|
-
// cannot dominate a health receipt.
|
|
2470
|
-
export const SYNC_ERROR_DETAIL_MAX_CHARS = 600;
|
|
2471
|
-
export function redactedSyncErrorDetail(error) {
|
|
2472
|
-
const message = errorMessage(error).replace(/\s+/gu, " ").trim();
|
|
2473
|
-
const { text } = redactSecretLikeContent(message, {
|
|
2474
|
-
appliedBy: "local_collector",
|
|
2475
|
-
});
|
|
2476
|
-
// BLI-2542: the comment above always said this text can carry absolute paths,
|
|
2477
|
-
// and until now nothing removed them — secret redaction matches token shapes,
|
|
2478
|
-
// not filesystem paths. Same masking the doctor receipts use, so one boundary
|
|
2479
|
-
// rule covers every health receipt.
|
|
2480
|
-
const masked = maskLocalIdentifiers(text);
|
|
2481
|
-
return masked.length > SYNC_ERROR_DETAIL_MAX_CHARS
|
|
2482
|
-
? `${masked.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
|
|
2483
|
-
: masked;
|
|
2484
|
-
}
|
|
2485
1192
|
/**
|
|
2486
1193
|
* Turn a finished run into an exit code and the reasons behind it.
|
|
2487
1194
|
*
|
|
@@ -2522,12 +1229,12 @@ async function runSyncLocked(command, io) {
|
|
|
2522
1229
|
});
|
|
2523
1230
|
if (run.outcomes.length > 1) {
|
|
2524
1231
|
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
2525
|
-
const
|
|
1232
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
2526
1233
|
const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
|
|
2527
1234
|
if (command.json) {
|
|
2528
1235
|
writeLine(io.stdout, JSON.stringify({
|
|
2529
1236
|
mode: "multi_repo",
|
|
2530
|
-
status:
|
|
1237
|
+
status: collectionRunStatus,
|
|
2531
1238
|
collection_complete: run.ok,
|
|
2532
1239
|
results: run.outcomes.map((outcome) => outcome.sync),
|
|
2533
1240
|
repos: rows,
|
|
@@ -2537,7 +1244,7 @@ async function runSyncLocked(command, io) {
|
|
|
2537
1244
|
}, null, 2));
|
|
2538
1245
|
return syncResult(run);
|
|
2539
1246
|
}
|
|
2540
|
-
writeLine(run.ok ? io.stdout : io.stderr, `Cockpit parent sync ${
|
|
1247
|
+
writeLine(run.ok ? io.stdout : io.stderr, `Cockpit parent sync ${collectionRunStatus} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
|
|
2541
1248
|
for (const outcome of run.outcomes) {
|
|
2542
1249
|
const { worktree, sync } = outcome;
|
|
2543
1250
|
const uploaded = sync.status === "uploaded";
|
|
@@ -2556,12 +1263,12 @@ async function runSyncLocked(command, io) {
|
|
|
2556
1263
|
// fleet machine with a single empty root and taught people to ignore
|
|
2557
1264
|
// sync_failed (BLI-2722). A genuinely broken run still fails via run.ok.
|
|
2558
1265
|
if (run.outcomes.length === 0) {
|
|
2559
|
-
const
|
|
1266
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
2560
1267
|
const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
|
|
2561
1268
|
if (command.json) {
|
|
2562
1269
|
writeLine(io.stdout, JSON.stringify({
|
|
2563
1270
|
mode: "no_worktrees",
|
|
2564
|
-
status:
|
|
1271
|
+
status: collectionRunStatus,
|
|
2565
1272
|
collection_complete: run.ok,
|
|
2566
1273
|
codex_sessions: run.summary,
|
|
2567
1274
|
raw_evidence_gc: gc,
|
|
@@ -2569,7 +1276,7 @@ async function runSyncLocked(command, io) {
|
|
|
2569
1276
|
}, null, 2));
|
|
2570
1277
|
return syncResult(run);
|
|
2571
1278
|
}
|
|
2572
|
-
writeLine(run.ok ? io.stdout : io.stderr, `Cockpit sync ${
|
|
1279
|
+
writeLine(run.ok ? io.stdout : io.stderr, `Cockpit sync ${collectionRunStatus}: no git worktrees under this root; session scan ran.`);
|
|
2573
1280
|
writeAgentSessionSummary(io, run.summary);
|
|
2574
1281
|
if (gc && !gc.skipped)
|
|
2575
1282
|
writeLine(io.stdout, rawEvidenceGcSummary(gc));
|
|
@@ -2579,12 +1286,12 @@ async function runSyncLocked(command, io) {
|
|
|
2579
1286
|
if (!result) {
|
|
2580
1287
|
throw new Error("Sync produced no result for the repo worktree.");
|
|
2581
1288
|
}
|
|
2582
|
-
const
|
|
1289
|
+
const collectionRunStatus = attributedSyncRunStatus(run);
|
|
2583
1290
|
const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
|
|
2584
1291
|
if (command.json) {
|
|
2585
1292
|
writeLine(io.stdout, JSON.stringify({
|
|
2586
1293
|
...result,
|
|
2587
|
-
status:
|
|
1294
|
+
status: collectionRunStatus,
|
|
2588
1295
|
collection_complete: run.ok,
|
|
2589
1296
|
codex_sessions: run.summary,
|
|
2590
1297
|
raw_evidence_gc: gc,
|
|
@@ -2630,20 +1337,6 @@ async function resolveSyncCollectionRoots(command) {
|
|
|
2630
1337
|
}
|
|
2631
1338
|
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no explicit or saved collection root is available.`);
|
|
2632
1339
|
}
|
|
2633
|
-
async function collectionRootConsentAliases(roots) {
|
|
2634
|
-
// Keep both spellings when an existing root is reached through a filesystem
|
|
2635
|
-
// alias such as macOS `/var` -> `/private/var`. Deleted child paths cannot be
|
|
2636
|
-
// realpathed later, so either transcript spelling must remain inside the same
|
|
2637
|
-
// operator-approved physical root.
|
|
2638
|
-
return normalizeCollectionRoots(await collectionRootPathAliases(roots));
|
|
2639
|
-
}
|
|
2640
|
-
function attributedSyncRunStatus(run) {
|
|
2641
|
-
if (run.ok)
|
|
2642
|
-
return "uploaded";
|
|
2643
|
-
return run.outcomes.every((outcome) => outcome.sync.status === "uploaded")
|
|
2644
|
-
? "partial"
|
|
2645
|
-
: "spooled";
|
|
2646
|
-
}
|
|
2647
1340
|
async function runAnalyze(command, io) {
|
|
2648
1341
|
const syncStdout = [];
|
|
2649
1342
|
const syncStderr = [];
|
|
@@ -2746,372 +1439,15 @@ async function readAnalyzeApiResponse(response) {
|
|
|
2746
1439
|
return {};
|
|
2747
1440
|
}
|
|
2748
1441
|
}
|
|
2749
|
-
function parseCapturedJson(chunks) {
|
|
2750
|
-
const text = chunks.join("").trim();
|
|
2751
|
-
if (!text)
|
|
2752
|
-
return null;
|
|
2753
|
-
try {
|
|
2754
|
-
return JSON.parse(text);
|
|
2755
|
-
}
|
|
2756
|
-
catch {
|
|
2757
|
-
return text;
|
|
2758
|
-
}
|
|
2759
|
-
}
|
|
2760
1442
|
function isUploadedSyncResult(value) {
|
|
2761
1443
|
return Boolean(value
|
|
2762
1444
|
&& typeof value === "object"
|
|
2763
1445
|
&& !Array.isArray(value)
|
|
2764
1446
|
&& value["status"] === "uploaded");
|
|
2765
1447
|
}
|
|
2766
|
-
function bufferedWritable(chunks) {
|
|
2767
|
-
return {
|
|
2768
|
-
write(chunk) {
|
|
2769
|
-
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
2770
|
-
return true;
|
|
2771
|
-
},
|
|
2772
|
-
};
|
|
2773
|
-
}
|
|
2774
|
-
function replayCaptured(stream, chunks) {
|
|
2775
|
-
for (const chunk of chunks)
|
|
2776
|
-
stream.write(chunk);
|
|
2777
|
-
}
|
|
2778
1448
|
async function runSyncRawEvidenceGc(command, io) {
|
|
2779
1449
|
return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
|
|
2780
1450
|
}
|
|
2781
|
-
async function runStatus(command, io) {
|
|
2782
|
-
const backfillCursor = await inspectBackfillCursor(command.homeDir);
|
|
2783
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
|
|
2784
|
-
if (worktrees.length > 1) {
|
|
2785
|
-
const statuses = await Promise.all(worktrees.map(async (worktree) => ({
|
|
2786
|
-
...(await inspectLocalCollectorStatus({
|
|
2787
|
-
homeDir: command.homeDir,
|
|
2788
|
-
repoRoot: worktree.repo_root,
|
|
2789
|
-
})),
|
|
2790
|
-
head_sha: worktree.head_sha,
|
|
2791
|
-
})));
|
|
2792
|
-
if (command.json) {
|
|
2793
|
-
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
|
|
2794
|
-
return 0;
|
|
2795
|
-
}
|
|
2796
|
-
writeLine(io.stdout, "Cockpit parent status");
|
|
2797
|
-
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
2798
|
-
for (const status of statuses) {
|
|
2799
|
-
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
|
|
2800
|
-
}
|
|
2801
|
-
return 0;
|
|
2802
|
-
}
|
|
2803
|
-
const status = await inspectLocalCollectorStatus(command);
|
|
2804
|
-
if (command.json) {
|
|
2805
|
-
writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
|
|
2806
|
-
return 0;
|
|
2807
|
-
}
|
|
2808
|
-
writeLine(io.stdout, "Cockpit local status");
|
|
2809
|
-
writeLine(io.stdout, `installed: ${status.installed}`);
|
|
2810
|
-
writeLine(io.stdout, `session_state: ${status.session_state}`);
|
|
2811
|
-
writeLine(io.stdout, `repo: ${status.repo}`);
|
|
2812
|
-
writeLine(io.stdout, `branch: ${status.branch}`);
|
|
2813
|
-
writeLine(io.stdout, `ticket: ${displayTicketId(status.active_ticket_id)}`);
|
|
2814
|
-
writeLine(io.stdout, `work: ${displayWorkLabel(status)}`);
|
|
2815
|
-
writeLine(io.stdout, `collector_freshness: ${status.collector_freshness}`);
|
|
2816
|
-
writeLine(io.stdout, `collector_version: ${status.collector_version}`);
|
|
2817
|
-
writeLine(io.stdout, `upload_state: ${status.upload_state}`);
|
|
2818
|
-
writeLine(io.stdout, `last_upload_attempt: ${status.last_upload_attempt_at ?? "never"}`);
|
|
2819
|
-
writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
|
|
2820
|
-
writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
|
|
2821
|
-
writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
|
|
2822
|
-
writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
|
|
2823
|
-
writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
|
|
2824
|
-
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
2825
|
-
writeLine(io.stdout, `stuck_evidence: ${stuckEvidenceLine(status)}`);
|
|
2826
|
-
for (const detail of status.details)
|
|
2827
|
-
writeLine(io.stdout, `- ${detail}`);
|
|
2828
|
-
return 0;
|
|
2829
|
-
}
|
|
2830
|
-
/**
|
|
2831
|
-
* One line that cannot say "fine" while an object has never been accepted.
|
|
2832
|
-
* Named reasons, worst attempt count, and the date it started.
|
|
2833
|
-
*/
|
|
2834
|
-
function stuckEvidenceLine(status) {
|
|
2835
|
-
if (status.stuck_evidence_object_count === 0)
|
|
2836
|
-
return "none";
|
|
2837
|
-
return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
|
|
2838
|
-
}
|
|
2839
|
-
function displayTicketId(ticketId) {
|
|
2840
|
-
return ticketId ?? "general ambient";
|
|
2841
|
-
}
|
|
2842
|
-
function displayWorkLabel(status) {
|
|
2843
|
-
if (status.work_label && status.work_id)
|
|
2844
|
-
return `${status.work_label} (${status.work_id})`;
|
|
2845
|
-
return status.work_label ?? status.work_id ?? "no active work context";
|
|
2846
|
-
}
|
|
2847
|
-
async function inspectBackfillCursor(homeDir) {
|
|
2848
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
2849
|
-
const roots = await currentBackfillRoots(homeDir);
|
|
2850
|
-
const marker = await readBackfillCompletionMarker(paths);
|
|
2851
|
-
if (backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
|
|
2852
|
-
return {
|
|
2853
|
-
state: "done",
|
|
2854
|
-
remaining_count: 0,
|
|
2855
|
-
updated_at: marker?.cursor.updated_at ?? null,
|
|
2856
|
-
completed_at: marker?.completed_at ?? null,
|
|
2857
|
-
sources: summarizeBackfillCursorSources(marker?.cursor ?? emptyBackfillCursorState()),
|
|
2858
|
-
};
|
|
2859
|
-
}
|
|
2860
|
-
const cursor = (prepareBackfillCursorForScope(await readBackfillCursor(paths), roots, ["codex", "claude_code"])).cursor;
|
|
2861
|
-
if (!cursor.updated_at) {
|
|
2862
|
-
return {
|
|
2863
|
-
state: "never_run",
|
|
2864
|
-
remaining_count: 0,
|
|
2865
|
-
updated_at: null,
|
|
2866
|
-
completed_at: null,
|
|
2867
|
-
sources: summarizeBackfillCursorSources(cursor),
|
|
2868
|
-
};
|
|
2869
|
-
}
|
|
2870
|
-
return {
|
|
2871
|
-
state: "remaining",
|
|
2872
|
-
remaining_count: await countRemainingBackfillSessionFiles(homeDir ?? os.homedir(), cursor),
|
|
2873
|
-
updated_at: cursor.updated_at,
|
|
2874
|
-
completed_at: null,
|
|
2875
|
-
sources: summarizeBackfillCursorSources(cursor),
|
|
2876
|
-
};
|
|
2877
|
-
}
|
|
2878
|
-
async function currentBackfillRoots(homeDir) {
|
|
2879
|
-
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
2880
|
-
return normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
2881
|
-
}
|
|
2882
|
-
function summarizeBackfillCursorSources(cursor) {
|
|
2883
|
-
return {
|
|
2884
|
-
codex: summarizeBackfillSource(cursor.sources.codex),
|
|
2885
|
-
claude_code: summarizeBackfillSource(cursor.sources.claude_code),
|
|
2886
|
-
};
|
|
2887
|
-
}
|
|
2888
|
-
function summarizeBackfillSource(source) {
|
|
2889
|
-
return {
|
|
2890
|
-
oldest_mtime_processed: source.oldest_mtime_processed,
|
|
2891
|
-
observed_count: Object.values(source.state_counts).reduce((total, count) => total + count, 0),
|
|
2892
|
-
state_counts: source.state_counts,
|
|
2893
|
-
reason_counts: source.reason_counts,
|
|
2894
|
-
};
|
|
2895
|
-
}
|
|
2896
|
-
function backfillCursorLine(status) {
|
|
2897
|
-
switch (status.state) {
|
|
2898
|
-
case "done":
|
|
2899
|
-
return `done (${status.completed_at ?? "completion marker present"})`;
|
|
2900
|
-
case "never_run":
|
|
2901
|
-
return "never run";
|
|
2902
|
-
case "remaining":
|
|
2903
|
-
return `${status.remaining_count} remaining`;
|
|
2904
|
-
}
|
|
2905
|
-
}
|
|
2906
|
-
async function countRemainingBackfillSessionFiles(homeDir, cursor) {
|
|
2907
|
-
const codex = await countJsonlFilesBeforeCursor(defaultCodexSessionDirs(homeDir), cursor.sources.codex.oldest_mtime_ms_processed);
|
|
2908
|
-
const claude = await countClaudeMainFilesBeforeCursor(path.join(homeDir, ".claude", "projects"), cursor.sources.claude_code.oldest_mtime_ms_processed);
|
|
2909
|
-
return codex + claude;
|
|
2910
|
-
}
|
|
2911
|
-
async function countJsonlFilesBeforeCursor(roots, oldestProcessedMs) {
|
|
2912
|
-
let count = 0;
|
|
2913
|
-
await walkFiles(roots, async (filePath, entryName) => {
|
|
2914
|
-
if (!entryName.endsWith(".jsonl"))
|
|
2915
|
-
return;
|
|
2916
|
-
const info = await stat(filePath).catch(() => null);
|
|
2917
|
-
if (!info?.isFile())
|
|
2918
|
-
return;
|
|
2919
|
-
if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
|
|
2920
|
-
count += 1;
|
|
2921
|
-
});
|
|
2922
|
-
return count;
|
|
2923
|
-
}
|
|
2924
|
-
async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs) {
|
|
2925
|
-
let count = 0;
|
|
2926
|
-
await walkFiles([projectsDir], async (filePath, entryName) => {
|
|
2927
|
-
if (!entryName.endsWith(".jsonl"))
|
|
2928
|
-
return;
|
|
2929
|
-
if (filePath.includes(`${path.sep}subagents${path.sep}`))
|
|
2930
|
-
return;
|
|
2931
|
-
const info = await stat(filePath).catch(() => null);
|
|
2932
|
-
if (!info?.isFile())
|
|
2933
|
-
return;
|
|
2934
|
-
if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
|
|
2935
|
-
count += 1;
|
|
2936
|
-
});
|
|
2937
|
-
return count;
|
|
2938
|
-
}
|
|
2939
|
-
async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
2940
|
-
const stack = [...roots];
|
|
2941
|
-
while (stack.length > 0 && !shouldStop()) {
|
|
2942
|
-
const current = stack.pop();
|
|
2943
|
-
if (!current)
|
|
2944
|
-
continue;
|
|
2945
|
-
let entries;
|
|
2946
|
-
try {
|
|
2947
|
-
entries = await readdir(current, { withFileTypes: true });
|
|
2948
|
-
}
|
|
2949
|
-
catch {
|
|
2950
|
-
continue;
|
|
2951
|
-
}
|
|
2952
|
-
for (const entry of entries) {
|
|
2953
|
-
if (shouldStop())
|
|
2954
|
-
return;
|
|
2955
|
-
const full = path.join(current, entry.name);
|
|
2956
|
-
if (entry.isDirectory()) {
|
|
2957
|
-
stack.push(full);
|
|
2958
|
-
}
|
|
2959
|
-
else if (entry.isFile()) {
|
|
2960
|
-
await onFile(full, entry.name);
|
|
2961
|
-
}
|
|
2962
|
-
}
|
|
2963
|
-
}
|
|
2964
|
-
}
|
|
2965
|
-
async function sessionsScanWindow(command, now) {
|
|
2966
|
-
if (command.all) {
|
|
2967
|
-
return {
|
|
2968
|
-
mode: "all",
|
|
2969
|
-
since_days: null,
|
|
2970
|
-
started_at: new Date(now.getTime() - ALL_SESSION_SCAN_WINDOW_MINUTES * 60_000)
|
|
2971
|
-
.toISOString(),
|
|
2972
|
-
paired_at: await readPairedAt(command.homeDir),
|
|
2973
|
-
since_minutes: ALL_SESSION_SCAN_WINDOW_MINUTES,
|
|
2974
|
-
limit: SESSION_SCAN_OVERRIDE_LIMIT,
|
|
2975
|
-
};
|
|
2976
|
-
}
|
|
2977
|
-
if (command.sinceDays !== undefined) {
|
|
2978
|
-
const requestedMs = now.getTime() - command.sinceDays * 24 * 60 * 60_000;
|
|
2979
|
-
const pairedAt = await readPairedAt(command.homeDir);
|
|
2980
|
-
const pairedMs = pairedAt ? Date.parse(pairedAt) : Number.NaN;
|
|
2981
|
-
const startedAtMs = Number.isFinite(pairedMs)
|
|
2982
|
-
? Math.max(requestedMs, pairedMs)
|
|
2983
|
-
: requestedMs;
|
|
2984
|
-
return {
|
|
2985
|
-
mode: "since_days",
|
|
2986
|
-
since_days: command.sinceDays,
|
|
2987
|
-
started_at: new Date(startedAtMs).toISOString(),
|
|
2988
|
-
paired_at: pairedAt,
|
|
2989
|
-
since_minutes: Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000)),
|
|
2990
|
-
limit: SESSION_SCAN_OVERRIDE_LIMIT,
|
|
2991
|
-
};
|
|
2992
|
-
}
|
|
2993
|
-
return {
|
|
2994
|
-
mode: "default",
|
|
2995
|
-
since_days: null,
|
|
2996
|
-
started_at: null,
|
|
2997
|
-
paired_at: await readPairedAt(command.homeDir),
|
|
2998
|
-
since_minutes: null,
|
|
2999
|
-
limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
|
|
3000
|
-
};
|
|
3001
|
-
}
|
|
3002
|
-
async function readPairedAt(homeDir) {
|
|
3003
|
-
const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
3004
|
-
return typeof session?.paired_at === "string" ? session.paired_at : null;
|
|
3005
|
-
}
|
|
3006
|
-
function sessionsWindowLine(window) {
|
|
3007
|
-
if (window.mode === "all")
|
|
3008
|
-
return "all local history";
|
|
3009
|
-
if (window.mode === "since_days") {
|
|
3010
|
-
return `since ${window.started_at} (${window.since_days} day request, paired_at cap ${window.paired_at ?? "unavailable"})`;
|
|
3011
|
-
}
|
|
3012
|
-
return "default scan window";
|
|
3013
|
-
}
|
|
3014
|
-
/**
|
|
3015
|
-
* Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
|
|
3016
|
-
* prints why each session is or is not collected. Per-session reasons otherwise
|
|
3017
|
-
* live only in a service-role table with no UI, so this is the operator's local
|
|
3018
|
-
* answer to "why is session X missing?" (B.4 §6). Counts and labels only — the
|
|
3019
|
-
* project-dir slug encodes a local path and is never printed.
|
|
3020
|
-
*/
|
|
3021
|
-
async function runSessions(command, io) {
|
|
3022
|
-
const now = new Date();
|
|
3023
|
-
const homeDir = command.homeDir ?? os.homedir();
|
|
3024
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
|
|
3025
|
-
const window = await sessionsScanWindow(command, now);
|
|
3026
|
-
const wantCodex = command.source !== "claude";
|
|
3027
|
-
const wantClaude = command.source !== "codex";
|
|
3028
|
-
const codex = wantCodex
|
|
3029
|
-
? await scanAndAttributeCodexSessions({
|
|
3030
|
-
sessionsDirs: defaultCodexSessionDirs(homeDir),
|
|
3031
|
-
worktrees,
|
|
3032
|
-
now,
|
|
3033
|
-
sinceMinutes: window.since_minutes ?? CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
|
|
3034
|
-
limit: window.limit,
|
|
3035
|
-
})
|
|
3036
|
-
: null;
|
|
3037
|
-
const claude = wantClaude
|
|
3038
|
-
? await scanAndAttributeClaudeSessions({
|
|
3039
|
-
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
3040
|
-
worktrees,
|
|
3041
|
-
now,
|
|
3042
|
-
sinceMinutes: window.since_minutes ?? undefined,
|
|
3043
|
-
limit: window.mode === "default" ? undefined : window.limit,
|
|
3044
|
-
})
|
|
3045
|
-
: null;
|
|
3046
|
-
// Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
|
|
3047
|
-
// skip reasons, plus the repo_label basename. Branch is intentionally omitted
|
|
3048
|
-
// — branch names can carry operator-authored task/customer text.
|
|
3049
|
-
const codexRows = (codex?.results ?? []).map((result) => ({
|
|
3050
|
-
source: "codex",
|
|
3051
|
-
session_id: result.codex_session_id,
|
|
3052
|
-
state: result.state,
|
|
3053
|
-
reason: result.reason,
|
|
3054
|
-
attribution_score: result.attribution_score,
|
|
3055
|
-
path_score: result.path_score,
|
|
3056
|
-
signals: result.signals,
|
|
3057
|
-
repo_label: result.worktree?.repo_label ?? null,
|
|
3058
|
-
}));
|
|
3059
|
-
const claudeRows = (claude?.results ?? []).map((result) => ({
|
|
3060
|
-
source: "claude_code",
|
|
3061
|
-
session_id: result.claude_session_id,
|
|
3062
|
-
state: result.state,
|
|
3063
|
-
reason: result.reason,
|
|
3064
|
-
attribution_score: result.attribution_score,
|
|
3065
|
-
path_score: result.path_score,
|
|
3066
|
-
signals: result.signals,
|
|
3067
|
-
repo_label: result.worktree?.repo_label ?? null,
|
|
3068
|
-
main_file_oversized: result.main_file_oversized,
|
|
3069
|
-
sidecar_skips: result.sidecar_files
|
|
3070
|
-
.filter((sidecar) => sidecar.skipped_reason)
|
|
3071
|
-
.map((sidecar) => ({
|
|
3072
|
-
file_name: sidecar.file_name,
|
|
3073
|
-
reason: sidecar.skipped_reason,
|
|
3074
|
-
})),
|
|
3075
|
-
}));
|
|
3076
|
-
if (command.json) {
|
|
3077
|
-
writeLine(io.stdout, JSON.stringify({
|
|
3078
|
-
window,
|
|
3079
|
-
...(codex
|
|
3080
|
-
? { codex: { counts: codex.counts, sessions: codexRows } }
|
|
3081
|
-
: {}),
|
|
3082
|
-
...(claude
|
|
3083
|
-
? {
|
|
3084
|
-
claude: {
|
|
3085
|
-
counts: claude.counts,
|
|
3086
|
-
project_dirs_skipped: claude.project_dirs_skipped,
|
|
3087
|
-
sessions: claudeRows,
|
|
3088
|
-
},
|
|
3089
|
-
}
|
|
3090
|
-
: {}),
|
|
3091
|
-
}, null, 2));
|
|
3092
|
-
return 0;
|
|
3093
|
-
}
|
|
3094
|
-
writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
|
|
3095
|
-
writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
|
|
3096
|
-
for (const row of codexRows) {
|
|
3097
|
-
writeLine(io.stdout, sessionRowLine(row));
|
|
3098
|
-
}
|
|
3099
|
-
for (const row of claudeRows) {
|
|
3100
|
-
writeLine(io.stdout, sessionRowLine(row));
|
|
3101
|
-
for (const sidecar of row.sidecar_skips) {
|
|
3102
|
-
writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
|
|
3103
|
-
}
|
|
3104
|
-
}
|
|
3105
|
-
if (codexRows.length === 0 && claudeRows.length === 0) {
|
|
3106
|
-
writeLine(io.stdout, "No sessions observed in the scan window.");
|
|
3107
|
-
}
|
|
3108
|
-
return 0;
|
|
3109
|
-
}
|
|
3110
|
-
function sessionRowLine(row) {
|
|
3111
|
-
const repo = row.repo_label ? ` repo:${row.repo_label}` : "";
|
|
3112
|
-
const signals = row.signals.length > 0 ? ` signals:${row.signals.join("|")}` : "";
|
|
3113
|
-
return `- [${row.source}] ${row.session_id} ${row.state} (${row.reason}) score:${row.attribution_score} path:${row.path_score}${repo}${signals}`;
|
|
3114
|
-
}
|
|
3115
1451
|
async function runServe(command, io) {
|
|
3116
1452
|
const server = createCollectorServer(command);
|
|
3117
1453
|
await new Promise((resolve) => {
|
|
@@ -3255,30 +1591,4 @@ function autostartLocationLine(result) {
|
|
|
3255
1591
|
return result.task_name
|
|
3256
1592
|
? `Task: ${result.task_name}`
|
|
3257
1593
|
: `Plist: ${result.plist_path}`;
|
|
3258
|
-
}
|
|
3259
|
-
function defaultExec() {
|
|
3260
|
-
return createCapturedExecRunner();
|
|
3261
|
-
}
|
|
3262
|
-
function defaultInteractiveExec() {
|
|
3263
|
-
return createInteractiveExecRunner();
|
|
3264
|
-
}
|
|
3265
|
-
function defaultIo() {
|
|
3266
|
-
if (!globalThis.fetch) {
|
|
3267
|
-
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
3268
|
-
}
|
|
3269
|
-
return {
|
|
3270
|
-
stdin: process.stdin,
|
|
3271
|
-
stdout: process.stdout,
|
|
3272
|
-
stderr: process.stderr,
|
|
3273
|
-
env: process.env,
|
|
3274
|
-
fetch: globalThis.fetch.bind(globalThis),
|
|
3275
|
-
exec: defaultExec(),
|
|
3276
|
-
interactiveExec: defaultInteractiveExec(),
|
|
3277
|
-
};
|
|
3278
|
-
}
|
|
3279
|
-
function writeLine(stream, text) {
|
|
3280
|
-
stream.write(`${text}\n`);
|
|
3281
|
-
}
|
|
3282
|
-
function errorMessage(error) {
|
|
3283
|
-
return error instanceof Error ? error.message : String(error);
|
|
3284
1594
|
}
|