@bli-cockpit/cli 0.2.237 → 0.2.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-overlay.js +73 -30
  29. package/dist/commands/cal.js +36 -23
  30. package/dist/commands/clean.js +4 -1
  31. package/dist/commands/docs-read.js +5 -2
  32. package/dist/commands/doctor-backfill.js +88 -71
  33. package/dist/commands/doctor-pipeline.js +59 -46
  34. package/dist/commands/heartbeat-post.js +116 -0
  35. package/dist/commands/heartbeat-token-lock.js +46 -33
  36. package/dist/commands/heartbeat.js +43 -125
  37. package/dist/commands/install-update.js +17 -15
  38. package/dist/commands/issue-show.js +27 -28
  39. package/dist/commands/issue-write.js +70 -23
  40. package/dist/commands/issue.js +68 -37
  41. package/dist/commands/jarvis-live-answer.js +99 -0
  42. package/dist/commands/jarvis-render.js +1 -94
  43. package/dist/commands/jarvis-trace.js +30 -22
  44. package/dist/commands/jarvis-turn-report.js +124 -0
  45. package/dist/commands/jarvis-turn.js +108 -182
  46. package/dist/commands/jarvis.js +26 -17
  47. package/dist/commands/local-args-collector-setup.js +82 -84
  48. package/dist/commands/local-args-collector-work.js +37 -38
  49. package/dist/commands/local-args-tower-admin.js +155 -142
  50. package/dist/commands/local-args-tower-cal.js +52 -50
  51. package/dist/commands/local-args-tower-careers.js +73 -54
  52. package/dist/commands/local-args-tower-docs-msg.js +55 -45
  53. package/dist/commands/local-args-tower-github.js +26 -20
  54. package/dist/commands/local-args-tower-hits.js +32 -20
  55. package/dist/commands/local-args-tower-manager.js +85 -4
  56. package/dist/commands/local-args-tower-memory.js +139 -117
  57. package/dist/commands/local-args-tower-notes.js +346 -0
  58. package/dist/commands/local-args-tower-pages.js +100 -378
  59. package/dist/commands/local-args-tower-settings.js +28 -29
  60. package/dist/commands/local-args-tower-work.js +52 -33
  61. package/dist/commands/local-args.js +80 -129
  62. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  63. package/dist/commands/local-help-commands-tower.js +6 -2
  64. package/dist/commands/local-help-commands.js +342 -341
  65. package/dist/commands/local-help.js +87 -83
  66. package/dist/commands/local-run-guards.js +100 -0
  67. package/dist/commands/local.js +137 -206
  68. package/dist/commands/login.js +13 -9
  69. package/dist/commands/logs.js +55 -38
  70. package/dist/commands/mail-attachments.js +44 -26
  71. package/dist/commands/manager-render-home.js +208 -0
  72. package/dist/commands/manager-render.js +7 -3
  73. package/dist/commands/manager-requests.js +33 -0
  74. package/dist/commands/manager.js +39 -5
  75. package/dist/commands/memory-consolidation.js +18 -9
  76. package/dist/commands/memory-daemon-probe.js +37 -28
  77. package/dist/commands/memory-install-config.js +64 -60
  78. package/dist/commands/memory-install.js +21 -41
  79. package/dist/commands/memory-requeue.js +26 -19
  80. package/dist/commands/memory-rules.js +26 -18
  81. package/dist/commands/memory-search-print.js +26 -0
  82. package/dist/commands/memory-store.js +14 -30
  83. package/dist/commands/memory-verb-list.js +49 -48
  84. package/dist/commands/notes-accounts.js +49 -32
  85. package/dist/commands/notes-sync-loop.js +115 -89
  86. package/dist/commands/notes-wisprflow-login.js +44 -23
  87. package/dist/commands/notes-writes.js +115 -100
  88. package/dist/commands/notes.js +33 -55
  89. package/dist/commands/onboard-flows.js +69 -51
  90. package/dist/commands/onboard.js +11 -9
  91. package/dist/commands/ops-render-memory.js +10 -7
  92. package/dist/commands/ops-render.js +33 -30
  93. package/dist/commands/ops.js +3 -0
  94. package/dist/commands/public-root.js +1 -1
  95. package/dist/commands/session-sync-outside-roots.js +40 -36
  96. package/dist/commands/session-sync-plan.js +14 -10
  97. package/dist/commands/session-sync-upload.js +24 -22
  98. package/dist/commands/session-sync.js +19 -8
  99. package/dist/commands/slack.js +25 -16
  100. package/dist/commands/sources-check.js +13 -7
  101. package/dist/commands/sources-discovery.js +25 -17
  102. package/dist/commands/sources-verdict.js +19 -18
  103. package/dist/commands/speed.js +23 -19
  104. package/dist/commands/start.js +20 -16
  105. package/dist/commands/sync-doctor.js +52 -48
  106. package/dist/commands/sync.js +22 -16
  107. package/dist/commands/usage-telemetry-toml.js +71 -45
  108. package/dist/commands/usage.js +91 -67
  109. package/dist/commands/workbook-render.js +10 -7
  110. package/dist/evidence-reconcile-client.js +75 -64
  111. package/dist/evidence-redelivery-plan.js +37 -34
  112. package/dist/evidence-redelivery.js +32 -29
  113. package/dist/evidence-transcripts-client.js +20 -20
  114. package/dist/evidence-upload-object.js +86 -89
  115. package/dist/evidence-upload-plan.js +94 -83
  116. package/dist/evidence-upload-terminal.js +45 -50
  117. package/dist/image-description-session.js +156 -159
  118. package/dist/local-state-pairing-code.js +39 -31
  119. package/dist/local-state-status.js +9 -2
  120. package/dist/onboarding-roots.js +5 -8
  121. package/dist/process-runner.js +8 -8
  122. package/dist/raw-evidence-gc.js +33 -38
  123. package/dist/raw-evidence-stuck-summary.js +7 -14
  124. package/dist/repo-identity.js +26 -23
  125. package/dist/scheduled-self-update.js +11 -15
  126. package/dist/second-install.js +3 -0
  127. package/dist/slim/codex-rules.js +23 -36
  128. package/dist/sync-lock.js +5 -15
  129. package/dist/tower-client.js +36 -53
  130. package/dist/tower-stream.js +7 -24
  131. package/dist/upload-agent-artifacts.js +58 -64
  132. package/dist/upload-envelope-build.js +60 -59
  133. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  134. package/dist/upload-evidence-delivery-summary.js +32 -34
  135. package/dist/upload-ingest-receipt.js +20 -23
  136. package/dist/upload-session-reports-queue.js +4 -1
  137. package/dist/upload-session-reports-wire.js +64 -68
  138. package/dist/upload-sync.js +171 -157
  139. package/package.json +2 -2
@@ -1,10 +1,10 @@
1
1
  import { runOpsCommand } from "./local-run-ops.js";
2
2
  import { runGlossary } from "./glossary.js";
3
3
  import { runSpend } from "./spend.js";
4
- import { defaultIo, errorMessage, writeLine } from "./cli-io.js";
5
- import { isLocalHelpRequest, localCommandHelp, rootCommandNames } from "./local-help.js";
4
+ import { defaultIo, writeLine } from "./cli-io.js";
5
+ import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
6
6
  import { beginStage, endStage } from "../crash-guard.js";
7
- import { describeError } from "../health-detail.js";
7
+ import { reportCommandFailure, refuseArguments, stripGlobalToggles, watchWrites } from "./local-run-guards.js";
8
8
  import { reportInstallEventsBestEffort } from "./install-receipts.js";
9
9
  import { persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
10
10
  import { rememberDiscoveryLimits } from "./local-discovery.js";
@@ -70,29 +70,36 @@ export { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorD
70
70
  export { assertCollectionRootPersisted } from "./collection-roots.js";
71
71
  export { runSelfUpdate, SelfUpdateError, } from "./install-update.js";
72
72
  import { runRoots } from "./roots.js";
73
+ /** Commands that act on THIS machine; the rest talk to Tower. */
74
+ const MACHINE_COMMAND_KINDS = [
75
+ "roots",
76
+ "install",
77
+ "onboard",
78
+ "update",
79
+ "doctor",
80
+ "login",
81
+ "logout",
82
+ "start",
83
+ "sync",
84
+ "analyze",
85
+ "backfill",
86
+ "status",
87
+ "sessions",
88
+ "serve",
89
+ "autostart",
90
+ "agent-rules",
91
+ "memory-log",
92
+ "memory",
93
+ "memory-store",
94
+ "clean",
95
+ "sources",
96
+ "release",
97
+ ];
98
+ function isMachineCommand(command) {
99
+ return MACHINE_COMMAND_KINDS.includes(command.kind);
100
+ }
73
101
  export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorAfterSync }) {
74
- // BLI-10450: `--quiet` is a global toggle, stripped here so no per-command
75
- // parser's `allowedFlags` needs to learn it, and mirrored onto
76
- // `COCKPIT_QUIET` so `tower-client.ts` can react without every call site
77
- // threading a flag through. `--json` already implies it (tower-client.ts).
78
- if (argv.includes("--quiet")) {
79
- argv = argv.filter((token) => token !== "--quiet");
80
- process.env.COCKPIT_QUIET = "1";
81
- }
82
- // BLI-10746: `--fresh`/`--allow-stale` are global toggles for the
83
- // stale-while-revalidate read cache, same reason and same shape as
84
- // `--quiet` above — stripped here so none of the seven cached read
85
- // commands' own `allowedFlags` needs to learn them, and mirrored onto
86
- // env vars `read-cache.ts` reads. One `cockpit` process makes at most one
87
- // cacheable request, so a process-wide toggle is exactly a per-request one.
88
- if (argv.includes("--fresh")) {
89
- argv = argv.filter((token) => token !== "--fresh");
90
- process.env.COCKPIT_READ_FRESH = "1";
91
- }
92
- if (argv.includes("--allow-stale")) {
93
- argv = argv.filter((token) => token !== "--allow-stale");
94
- process.env.COCKPIT_READ_ALLOW_STALE = "1";
95
- }
102
+ argv = stripGlobalToggles(argv);
96
103
  if (isLocalHelpRequest(argv)) {
97
104
  writeLine(io.stdout, localCommandHelp(argv[0]));
98
105
  return 0;
@@ -102,29 +109,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
102
109
  command = parseLocalArgs(argv);
103
110
  }
104
111
  catch (error) {
105
- // BLI-10431: under --json a refused parse still owes stdout one object.
106
- // It used to write nothing there, so `cockpit usage sessions --json`
107
- // without a filter read to an agent as an empty answer, not a refusal.
108
- if (argv.includes("--json")) {
109
- writeLine(io.stdout, JSON.stringify({
110
- ok: false,
111
- error: "invalid_arguments",
112
- command: argv[0] ?? "",
113
- detail: errorMessage(error),
114
- }));
115
- }
116
- console.error("[cockpit-cli] arguments refused", JSON.stringify({ reason: "invalid_arguments", command: argv[0] ?? "", json: argv.includes("--json") }));
117
- writeLine(io.stderr, errorMessage(error));
118
- writeLine(io.stderr, "");
119
- // A refused `cockpit cal create --meet` used to print its one correct
120
- // reason line and then the WHOLE collector wall — onboard, update, forty
121
- // more — so the reason drowned and Edward read it as "it just shows usage"
122
- // (2026-09-06). A parse error on a KNOWN command gets that command's own
123
- // manual, which names the flags the reason line just asked for; only an
124
- // unknown command earns the wall.
125
- const command = argv[0];
126
- writeLine(io.stderr, command && rootCommandNames.has(command) ? localCommandHelp(command) : localCommandHelp());
127
- return 1;
112
+ return refuseArguments(argv, error, io);
128
113
  }
129
114
  // A limit typed on the command line is remembered for every later run,
130
115
  // including the scheduled one nobody types into (BLI-2362). Done here so it
@@ -141,150 +126,117 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
141
126
  if (stdoutWatch)
142
127
  io = { ...io, stdout: stdoutWatch.stream };
143
128
  try {
144
- switch (command.kind) {
145
- case "roots": return await runRoots(command, io);
146
- case "install":
147
- return await runInstall(command, io);
148
- case "onboard":
149
- return await runOnboard(command, io);
150
- case "update":
151
- return await runUpdate(command, io);
152
- case "doctor":
153
- return await runDoctor(command, io, {
154
- reportInstallEvents: reportInstallEventsBestEffort,
155
- selfUpdate: runSelfUpdate,
156
- runLogin: runDoctorLogin,
157
- resolveAndSaveRoots: resolveAndSaveDoctorRoots,
158
- });
159
- case "login":
160
- return await runLogin(command, io);
161
- case "logout":
162
- return await runLogout(command, io);
163
- case "start":
164
- return await runStart(command, io);
165
- case "sync":
166
- return await runSync(command, io);
167
- case "analyze":
168
- return await runAnalyze(command, io);
169
- case "jarvis":
170
- return await runJarvis(command, io);
171
- case "model":
172
- return await runModel(command, io);
173
- case "models":
174
- return await runModels(command, io);
175
- case "scout":
176
- case "ci":
177
- case "ops":
178
- return await runOpsCommand(command, io);
179
- case "slack":
180
- return await runSlack(command, io);
181
- case "settings":
182
- return await runSettings(command, io);
183
- case "team":
184
- return await runTeam(command, io);
185
- case "workbook":
186
- return await runWorkbook(command, io);
187
- case "brief":
188
- // BLI-3458 S2.7/S2.8: `edit` and `rewrite` are the two PANEL actions
189
- // reaching the terminal. Dispatched here rather than inside `brief.ts`
190
- // so each one stays its own module — `brief.ts` owns reading and
191
- // nothing else.
192
- if (command.action === "edit")
193
- return await runBriefEdit(command, io);
194
- if (command.action === "rewrite")
195
- return await runBriefRewrite(command, io);
196
- // BLI-3605: the board's done checkbox, at a terminal.
197
- if (command.action === "done" || command.action === "undone") {
198
- return await runBriefDone(command, io);
199
- }
200
- return await runBrief(command, io);
201
- case "correct":
202
- return await runCorrect(command, io);
203
- case "notes":
204
- return await runNotes(command, io);
205
- case "backfill":
206
- return await runBackfillCommand(command, io);
207
- case "status":
208
- return await runStatus(command, io);
209
- case "sessions":
210
- return await runSessions(command, io);
211
- case "serve":
212
- return await runServe(command, io);
213
- case "autostart":
214
- return await runAutostart(command, io);
215
- case "agent-rules":
216
- return await runAgentRules(command, io);
217
- case "memory-log":
218
- return await runMemoryLog(command, io);
219
- case "memory":
220
- // BLI-4047: a bare `cockpit memory` says what the noun does. It used
221
- // to install; `install` is still one word away and every unattended
222
- // caller asks for it by name in code.
223
- if (command.action === "help")
224
- return runMemoryVerbList(io, command.json);
225
- return await runMemoryInstall(command, io);
226
- case "memory-store":
227
- return await runMemoryStore(command, io);
228
- case "clean":
229
- return await runClean(command, io);
230
- case "sources":
231
- return await runSourcesCheck(command, io);
232
- case "glossary":
233
- return await runGlossary(command, io);
234
- case "spend": return await runSpend(command, io);
235
- case "docs": return await runDocs(command, io);
236
- case "msg": return await runMsg(command, io);
237
- case "issue": return await runIssue(command, io);
238
- case "cal": return runCal(command, io);
239
- case "mail": return await runMail(command, io);
240
- case "project": return await runProject(command, io);
241
- case "search": return await runSearch(command, io);
242
- case "github": return await runGithub(command, io);
243
- case "release": return await runRelease(command, io);
244
- case "careers": return await runCareers(command, io);
245
- case "rename": return await runRename(command, io);
246
- case "setup": return await runSetup(command, io);
247
- case "usage": return await runUsage(command, io);
248
- case "speed": return await runSpeed(command, io);
249
- case "logs": return await runLogs(command, io);
250
- case "brain": return await runBrain(command, io);
251
- case "hits": return await runHits(command, io);
252
- case "intentions": return await runIntentions(command, io);
253
- case "who": return await runWho(command, io);
254
- case "people": return await runPeople(command, io);
255
- case "approvals": return await runApprovals(command, io);
256
- case "drills": return await runDrills(command, io);
257
- case "manager": return await runManager(command, io);
258
- case "image": return await runImage(command, io);
259
- }
129
+ // Not awaited, as it never was: a `cal` rejection bypasses the net below.
130
+ if (command.kind === "cal")
131
+ return runCal(command, io);
132
+ if (isMachineCommand(command))
133
+ return await runMachineCommand(command, io);
134
+ return await runTowerCommand(command, io);
260
135
  }
261
136
  catch (error) {
262
- // The outermost net under every subcommand. It printed `error.message` and
263
- // dropped the name, the code, the syscall and the cause — and never said
264
- // WHICH subcommand died, so a scheduled `cockpit sync` and a hand-typed
265
- // `cockpit doctor` failing the same way were indistinguishable in
266
- // sync.err.log (BLI-3483). The human line is unchanged; the structured one
267
- // beside it carries names and codes only, scrubbed by `describeError`.
268
- console.error("[cockpit-cli] command failed", JSON.stringify({
269
- reason: "command_failed",
270
- command: command.kind,
271
- ...describeError(error),
272
- }));
273
- if (stdoutWatch && !stdoutWatch.written()) {
274
- writeLine(io.stdout, JSON.stringify({
275
- ok: false,
276
- error: "command_failed",
277
- command: command.kind,
278
- detail: errorMessage(error),
279
- }));
280
- }
281
- writeLine(io.stderr, errorMessage(error));
282
- return 1;
137
+ return reportCommandFailure(command, error, io, stdoutWatch);
283
138
  }
284
139
  finally {
285
140
  endStage(`command:${command.kind}`);
286
141
  }
287
142
  }
143
+ async function runMachineCommand(command, io) {
144
+ switch (command.kind) {
145
+ case "roots": return await runRoots(command, io);
146
+ case "install": return await runInstall(command, io);
147
+ case "onboard": return await runOnboard(command, io);
148
+ case "update": return await runUpdate(command, io);
149
+ case "doctor":
150
+ return await runDoctor(command, io, {
151
+ reportInstallEvents: reportInstallEventsBestEffort,
152
+ selfUpdate: runSelfUpdate,
153
+ runLogin: runDoctorLogin,
154
+ resolveAndSaveRoots: resolveAndSaveDoctorRoots,
155
+ });
156
+ case "login": return await runLogin(command, io);
157
+ case "logout": return await runLogout(command, io);
158
+ case "start": return await runStart(command, io);
159
+ case "sync": return await runSync(command, io);
160
+ case "analyze": return await runAnalyze(command, io);
161
+ case "backfill": return await runBackfillCommand(command, io);
162
+ case "status": return await runStatus(command, io);
163
+ case "sessions": return await runSessions(command, io);
164
+ case "serve": return await runServe(command, io);
165
+ case "autostart": return await runAutostart(command, io);
166
+ case "agent-rules": return await runAgentRules(command, io);
167
+ case "memory-log": return await runMemoryLog(command, io);
168
+ case "memory":
169
+ // BLI-4047: a bare `cockpit memory` says what the noun does. It used
170
+ // to install; `install` is still one word away and every unattended
171
+ // caller asks for it by name in code.
172
+ if (command.action === "help")
173
+ return runMemoryVerbList(io, command.json);
174
+ return await runMemoryInstall(command, io);
175
+ case "memory-store": return await runMemoryStore(command, io);
176
+ case "clean": return await runClean(command, io);
177
+ case "sources": return await runSourcesCheck(command, io);
178
+ case "release": return await runRelease(command, io);
179
+ }
180
+ }
181
+ async function runTowerCommand(command, io) {
182
+ switch (command.kind) {
183
+ case "jarvis": return await runJarvis(command, io);
184
+ case "model": return await runModel(command, io);
185
+ case "models": return await runModels(command, io);
186
+ case "scout":
187
+ case "ci":
188
+ case "ops":
189
+ return await runOpsCommand(command, io);
190
+ case "slack": return await runSlack(command, io);
191
+ case "settings": return await runSettings(command, io);
192
+ case "team": return await runTeam(command, io);
193
+ case "workbook": return await runWorkbook(command, io);
194
+ case "brief": return await runBriefVerb(command, io);
195
+ case "correct": return await runCorrect(command, io);
196
+ case "notes": return await runNotes(command, io);
197
+ case "glossary": return await runGlossary(command, io);
198
+ case "spend": return await runSpend(command, io);
199
+ case "docs": return await runDocs(command, io);
200
+ case "msg": return await runMsg(command, io);
201
+ case "issue": return await runIssue(command, io);
202
+ case "mail": return await runMail(command, io);
203
+ case "project": return await runProject(command, io);
204
+ case "search": return await runSearch(command, io);
205
+ case "github": return await runGithub(command, io);
206
+ case "careers": return await runCareers(command, io);
207
+ case "rename": return await runRename(command, io);
208
+ case "setup": return await runSetup(command, io);
209
+ case "usage": return await runUsage(command, io);
210
+ case "speed": return await runSpeed(command, io);
211
+ case "logs": return await runLogs(command, io);
212
+ case "brain": return await runBrain(command, io);
213
+ case "hits": return await runHits(command, io);
214
+ case "intentions": return await runIntentions(command, io);
215
+ case "who": return await runWho(command, io);
216
+ case "people": return await runPeople(command, io);
217
+ case "approvals": return await runApprovals(command, io);
218
+ case "drills": return await runDrills(command, io);
219
+ case "manager": return await runManager(command, io);
220
+ case "image": return await runImage(command, io);
221
+ }
222
+ }
223
+ /**
224
+ * BLI-3458 S2.7/S2.8: `edit` and `rewrite` are the two PANEL actions
225
+ * reaching the terminal. Dispatched here rather than inside `brief.ts`
226
+ * so each one stays its own module — `brief.ts` owns reading and
227
+ * nothing else.
228
+ */
229
+ async function runBriefVerb(command, io) {
230
+ if (command.action === "edit")
231
+ return await runBriefEdit(command, io);
232
+ if (command.action === "rewrite")
233
+ return await runBriefRewrite(command, io);
234
+ // BLI-3605: the board's done checkbox, at a terminal.
235
+ if (command.action === "done" || command.action === "undone") {
236
+ return await runBriefDone(command, io);
237
+ }
238
+ return await runBrief(command, io);
239
+ }
288
240
  async function runDoctorLogin(command, io) {
289
241
  return runLogin({
290
242
  kind: "login",
@@ -311,25 +263,4 @@ async function resolveAndSaveDoctorRoots(command, io) {
311
263
  if (!command.json) {
312
264
  writeLine(io.stdout, `Saved collection roots: ${resolution.collectionRoots.join(", ")}`);
313
265
  }
314
- }
315
- /**
316
- * The same stream, with a flag that flips on the first write. A Proxy rather
317
- * than a copied object so `isTTY`, `columns` and every listener method still
318
- * reach the real stream.
319
- */
320
- function watchWrites(stream) {
321
- let wrote = false;
322
- const watched = new Proxy(stream, {
323
- get(target, property) {
324
- if (property === "write") {
325
- return (...args) => {
326
- wrote = true;
327
- return target.write.apply(target, args);
328
- };
329
- }
330
- const value = Reflect.get(target, property, target);
331
- return typeof value === "function" ? value.bind(target) : value;
332
- },
333
- });
334
- return { stream: watched, written: () => wrote };
335
266
  }
@@ -33,6 +33,18 @@ export async function runLogin(command, io) {
33
33
  if (command.pairCode)
34
34
  return 1;
35
35
  }
36
+ const result = await pairByDashboardApproval(command, dashboardUrl, io);
37
+ reportPaired(command, io, {
38
+ email: result.session.email ?? result.session.auth_subject_id,
39
+ deviceName: result.session.device_name ?? result.session.device_id ?? "unknown",
40
+ sessionFile: result.session_file,
41
+ json: result,
42
+ });
43
+ writeNextStep(command, io, config.default_repo_paths.length > 0);
44
+ return 0;
45
+ }
46
+ /** The legacy arm: sign in by email, then wait for the person to approve the device in the dashboard. */
47
+ async function pairByDashboardApproval(command, dashboardUrl, io) {
36
48
  const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
37
49
  const pairingAccessToken = await requestPairingAccessToken({
38
50
  dashboardUrl,
@@ -40,7 +52,7 @@ export async function runLogin(command, io) {
40
52
  noAuth: command.noAuth,
41
53
  json: command.json,
42
54
  }, io);
43
- const result = await pairLocalCollectorWithAuthFallback({
55
+ return await pairLocalCollectorWithAuthFallback({
44
56
  homeDir: command.homeDir,
45
57
  dashboardUrl,
46
58
  claimedOwnerEmail,
@@ -58,14 +70,6 @@ export async function runLogin(command, io) {
58
70
  writeLine(io.stdout, "Waiting for dashboard approval...");
59
71
  },
60
72
  }, io);
61
- reportPaired(command, io, {
62
- email: result.session.email ?? result.session.auth_subject_id,
63
- deviceName: result.session.device_name ?? result.session.device_id ?? "unknown",
64
- sessionFile: result.session_file,
65
- json: result,
66
- });
67
- writeNextStep(command, io, config.default_repo_paths.length > 0);
68
- return 0;
69
73
  }
70
74
  /**
71
75
  * The one-link ceremony. Returns null when it could not finish, having already
@@ -30,15 +30,8 @@ function refuse(io, json, reason, detail) {
30
30
  return 1;
31
31
  }
32
32
  export async function runLogs(command, io, deps = {}) {
33
- if (command.keyAction) {
34
- const tower = await openTower("logs keys", command, io);
35
- const result = await callTower(tower, { path: "/api/settings/log-ingest-keys", method: "POST", body: { source: command.source, action: command.keyAction }, label: "logs keys" });
36
- if (!result.ok)
37
- return writeCommandFailure(io, command.json, result);
38
- // Deliberate one-time credential output; never stderr, cache or log.
39
- writeLine(io.stdout, command.json ? JSON.stringify(result.body) : String(asRecord(result.body).key));
40
- return 0;
41
- }
33
+ if (command.keyAction)
34
+ return mintLogKey(command, io, command.keyAction);
42
35
  if (command.search)
43
36
  return runLogsSearch(command, io);
44
37
  // BLI-12855: a project name (or --stored, or --list) reads the digests Tower's cron stored, not a local log.
@@ -46,52 +39,76 @@ export async function runLogs(command, io, deps = {}) {
46
39
  if (command.list || stored)
47
40
  return runStoredLogs(command, io, { project: stored ?? "tower" });
48
41
  const paths = getCollectorRuntimePaths(command.homeDir);
49
- let table;
50
- if (command.tableFile) {
51
- try {
52
- table = ClassTable.fromJson(await readFile(command.tableFile, "utf8"));
53
- }
54
- catch (error) {
55
- return refuse(io, command.json, "table_unreadable", `--table ${command.tableFile} is not a readable class table: ${error instanceof Error ? error.message.slice(0, 160) : "unknown error"}`);
56
- }
57
- }
58
- let stdinText;
42
+ const tableRead = await readClassTable(command, io);
43
+ if ("exitCode" in tableRead)
44
+ return tableRead.exitCode;
59
45
  const stdinIsTerminal = command.source === "-" && isInteractiveStdin(io);
60
- if (command.source === "-" && !stdinIsTerminal) {
61
- try {
62
- stdinText = await readPipedText(io.stdin, { maxChars: MAX_STDIN_CHARS, overflowMessage: "logs - reads at most 64 MB of piped lines." });
63
- }
64
- catch (error) {
65
- return refuse(io, command.json, "stdin_too_large", error instanceof Error ? error.message : "stdin could not be read");
66
- }
67
- }
46
+ const stdinRead = await readPipedLogs(command, io, stdinIsTerminal);
47
+ if ("exitCode" in stdinRead)
48
+ return stdinRead.exitCode;
68
49
  const outcome = await digestSource({
69
50
  source: command.source,
70
51
  since: command.since,
71
52
  raw: command.raw,
72
53
  rawLimit: command.rawLimit,
73
- table,
54
+ table: tableRead.value,
74
55
  allLevels: command.allLevels,
75
56
  }, {
76
57
  stateDir: paths.state_dir,
77
- stdinText,
58
+ stdinText: stdinRead.value,
78
59
  stdinIsTerminal,
79
60
  run: deps.run,
80
61
  nowMs: deps.nowMs,
81
62
  log: (message, fields) => writeLine(io.stderr, `${message} ${JSON.stringify(fields)}`),
82
63
  });
83
- if (!outcome.ok && LOCAL_ACCESS_REFUSALS.has(outcome.reason) && (command.source === "dashboard" || command.source === "railway") && !command.raw) {
84
- // No local access to the platform: say so, then answer from what the cron stored (a refusal of its own if this machine is not paired).
85
- writeLine(io.stderr, `${TAG} local source unreadable, reading the stored digest ${JSON.stringify({ reason: outcome.reason })}`);
86
- return runStoredLogs(command, io, {
87
- project: "tower",
88
- fallbackReason: outcome.reason,
89
- // The person asked for a local read: if the stored digest is out of reach too, the local refusal is the answer.
90
- onNotPaired: () => refuse(io, command.json, outcome.reason, `${outcome.detail} This machine is not paired with Tower (cockpit login), so the stored digest could not be read either.`),
91
- });
64
+ if (!outcome.ok && canFallBackToStoredDigest(command, outcome)) {
65
+ return answerFromStoredDigest(command, io, outcome);
92
66
  }
93
67
  if (!outcome.ok)
94
68
  return refuse(io, command.json, outcome.reason, outcome.detail);
95
69
  writeLine(io.stdout, command.json ? JSON.stringify(outcome) : outcome.text);
96
70
  return 0;
71
+ }
72
+ async function mintLogKey(command, io, keyAction) {
73
+ const tower = await openTower("logs keys", command, io);
74
+ const result = await callTower(tower, { path: "/api/settings/log-ingest-keys", method: "POST", body: { source: command.source, action: keyAction }, label: "logs keys" });
75
+ if (!result.ok)
76
+ return writeCommandFailure(io, command.json, result);
77
+ // Deliberate one-time credential output; never stderr, cache or log.
78
+ writeLine(io.stdout, command.json ? JSON.stringify(result.body) : String(asRecord(result.body).key));
79
+ return 0;
80
+ }
81
+ async function readClassTable(command, io) {
82
+ if (!command.tableFile)
83
+ return { value: undefined };
84
+ try {
85
+ return { value: ClassTable.fromJson(await readFile(command.tableFile, "utf8")) };
86
+ }
87
+ catch (error) {
88
+ return { exitCode: refuse(io, command.json, "table_unreadable", `--table ${command.tableFile} is not a readable class table: ${error instanceof Error ? error.message.slice(0, 160) : "unknown error"}`) };
89
+ }
90
+ }
91
+ /** The piped lines for `logs -`; nothing to read when the source is not stdin or stdin is a terminal. */
92
+ async function readPipedLogs(command, io, stdinIsTerminal) {
93
+ if (command.source !== "-" || stdinIsTerminal)
94
+ return { value: undefined };
95
+ try {
96
+ return { value: await readPipedText(io.stdin, { maxChars: MAX_STDIN_CHARS, overflowMessage: "logs - reads at most 64 MB of piped lines." }) };
97
+ }
98
+ catch (error) {
99
+ return { exitCode: refuse(io, command.json, "stdin_too_large", error instanceof Error ? error.message : "stdin could not be read") };
100
+ }
101
+ }
102
+ function canFallBackToStoredDigest(command, refusal) {
103
+ return LOCAL_ACCESS_REFUSALS.has(refusal.reason) && (command.source === "dashboard" || command.source === "railway") && !command.raw;
104
+ }
105
+ function answerFromStoredDigest(command, io, refusal) {
106
+ // No local access to the platform: say so, then answer from what the cron stored (a refusal of its own if this machine is not paired).
107
+ writeLine(io.stderr, `${TAG} local source unreadable, reading the stored digest ${JSON.stringify({ reason: refusal.reason })}`);
108
+ return runStoredLogs(command, io, {
109
+ project: "tower",
110
+ fallbackReason: refusal.reason,
111
+ // The person asked for a local read: if the stored digest is out of reach too, the local refusal is the answer.
112
+ onNotPaired: () => refuse(io, command.json, refusal.reason, `${refusal.detail} This machine is not paired with Tower (cockpit login), so the stored digest could not be read either.`),
113
+ });
97
114
  }
@@ -116,14 +116,48 @@ function totalTooLarge(total) {
116
116
  export async function readMailAttachments(paths) {
117
117
  if (paths.length === 0)
118
118
  return { ok: true, attachments: [], summary: { count: 0, bytes: 0, sizes: [], content_types: [] } };
119
+ const refusedByName = refuseByCountOrName(paths);
120
+ if (refusedByName)
121
+ return refusedByName;
122
+ const statted = await statEveryAttachment(paths);
123
+ if (!statted.ok)
124
+ return statted;
125
+ const tooLarge = totalTooLarge(statted.sizes.reduce((sum, size) => sum + size, 0));
126
+ if (tooLarge)
127
+ return tooLarge;
128
+ const read = await readEveryAttachment(paths);
129
+ if (!read.ok)
130
+ return read;
131
+ // A file that grew between the stat and the read is the file as it is now.
132
+ const total = read.sizes.reduce((sum, size) => sum + size, 0);
133
+ const grew = totalTooLarge(total);
134
+ if (grew)
135
+ return grew;
136
+ if (read.sizes.includes(0)) {
137
+ return refuse("attachment_unreadable", `Attachment ${read.sizes.indexOf(0) + 1} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
138
+ }
139
+ return {
140
+ ok: true,
141
+ attachments: read.attachments,
142
+ summary: {
143
+ count: read.attachments.length,
144
+ bytes: total,
145
+ sizes: read.sizes,
146
+ content_types: read.attachments.map((attachment) => attachment.content_type),
147
+ },
148
+ };
149
+ }
150
+ function refuseByCountOrName(paths) {
119
151
  if (paths.length > MAIL_ATTACHMENT_MAX_COUNT) {
120
152
  return refuse("too_many_attachments", `${paths.length} files were named with --attach and one message carries at most ${MAIL_ATTACHMENT_MAX_COUNT}. ${NOTHING_SENT}`);
121
153
  }
122
154
  // Names first, before a single stat or read: a secret-looking file is never opened.
123
155
  const secret = paths.findIndex((filePath) => attachmentLooksSecret(path.basename(filePath)));
124
- if (secret !== -1) {
125
- return refuse("attachment_secret_like", `Attachment ${secret + 1} has a name that looks like a key or credential file, so it was not opened. ${NOTHING_SENT}`);
126
- }
156
+ if (secret === -1)
157
+ return null;
158
+ return refuse("attachment_secret_like", `Attachment ${secret + 1} has a name that looks like a key or credential file, so it was not opened. ${NOTHING_SENT}`);
159
+ }
160
+ async function statEveryAttachment(paths) {
127
161
  const sizes = [];
128
162
  for (const [index, filePath] of paths.entries()) {
129
163
  const label = `Attachment ${index + 1}`;
@@ -144,11 +178,12 @@ export async function readMailAttachments(paths) {
144
178
  return refuse("attachment_unreadable", `${label} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
145
179
  sizes.push(size);
146
180
  }
147
- const tooLarge = totalTooLarge(sizes.reduce((sum, size) => sum + size, 0));
148
- if (tooLarge)
149
- return tooLarge;
181
+ return { ok: true, sizes };
182
+ }
183
+ /** The files as they are now: their wire form, and the byte count each one really had. */
184
+ async function readEveryAttachment(paths) {
150
185
  const attachments = [];
151
- const actual = [];
186
+ const sizes = [];
152
187
  for (const [index, filePath] of paths.entries()) {
153
188
  let bytes;
154
189
  try {
@@ -158,7 +193,7 @@ export async function readMailAttachments(paths) {
158
193
  const code = error.code;
159
194
  return refuse("attachment_unreadable", `Attachment ${index + 1} could not be read (${code ?? "unknown error"}). ${NOTHING_SENT}`);
160
195
  }
161
- actual.push(bytes.length);
196
+ sizes.push(bytes.length);
162
197
  const filename = path.basename(filePath);
163
198
  attachments.push({
164
199
  filename,
@@ -166,24 +201,7 @@ export async function readMailAttachments(paths) {
166
201
  content_base64: bytes.toString("base64"),
167
202
  });
168
203
  }
169
- // A file that grew between the stat and the read is the file as it is now.
170
- const total = actual.reduce((sum, size) => sum + size, 0);
171
- const grew = totalTooLarge(total);
172
- if (grew)
173
- return grew;
174
- if (actual.includes(0)) {
175
- return refuse("attachment_unreadable", `Attachment ${actual.indexOf(0) + 1} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
176
- }
177
- return {
178
- ok: true,
179
- attachments,
180
- summary: {
181
- count: attachments.length,
182
- bytes: total,
183
- sizes: actual,
184
- content_types: attachments.map((attachment) => attachment.content_type),
185
- },
186
- };
204
+ return { ok: true, attachments, sizes };
187
205
  }
188
206
  /**
189
207
  * The second check: the whole request, not just the files. Called with the JSON