impel-cli 0.20.45 → 0.20.46-beta.2

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.
@@ -0,0 +1,289 @@
1
+ // The automatic bug-report path: a failed `impel setup` or `impel update`
2
+ // reporting itself.
3
+ //
4
+ // The manual command in `src/commands/report.js` has a person attached — they
5
+ // typed it, they are watching, and an error they can read is a useful outcome.
6
+ // This module has none of that. It runs inside a command that has already
7
+ // failed, on a machine whose owner may have walked away, and the only two
8
+ // things it owes anyone are the disclosure line and a promise never to make the
9
+ // failure worse. Everything below follows from that asymmetry:
10
+ //
11
+ // - Every gate returns silently. There is no "reporting was skipped because…"
12
+ // sentence, because nobody asked for a report and a refusal is not news.
13
+ // - The whole body is wrapped in a `try`/`catch` that swallows (KTD10). A
14
+ // throw from here would surface as a crash inside a command that had
15
+ // already decided how it wanted to fail.
16
+ // - `process.exitCode` is never written. The failing command owns its exit
17
+ // code; the report is a side effect, not an outcome.
18
+ //
19
+ // What is *sent* is identical to the manual path — same `buildEnvelope`, same
20
+ // origin check, same spool — because "the automatic sender cannot send
21
+ // something the manual sender would not" has to be a property of the code
22
+ // rather than a habit. This file only decides *whether* to send.
23
+ //
24
+ // The suppression marker lives under `CONFIG_DIR`, so `impel nuke` erases it.
25
+
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ import {
31
+ buildEnvelope,
32
+ deliverWithRetry,
33
+ MAX_DIAGNOSTIC_KEYS,
34
+ REPORT_SPOOL_DIR,
35
+ resolveReportOrigin,
36
+ sleep,
37
+ spoolReport,
38
+ writeJsonAtomically,
39
+ } from "./bugReport.js";
40
+ import { resolveDefaultAppUrl } from "./config.js";
41
+ import { fetchHttp1 } from "./http1.js";
42
+ import { sanitizeInstallFailureEnvelope } from "./installRecovery/redact.js";
43
+ import {
44
+ DO_NOT_TRACK_ENV,
45
+ firstPartyCli,
46
+ managedMarkerPresent,
47
+ reportEnvOptOut,
48
+ } from "./telemetryConsent.js";
49
+
50
+ /**
51
+ * Shorter than `impel report`'s 10 s: this timeout is spent by a command that
52
+ * has already failed and wants to exit. Three attempts at 5 s plus 500/1,500 ms
53
+ * of backoff is ~17 s worst case against a black hole (KTD5).
54
+ */
55
+ export const AUTO_REPORT_TIMEOUT_MS = 5_000;
56
+
57
+ /** R4's record of what this machine has already reported. */
58
+ export const AUTO_REPORT_MARKER_NAME = ".last-auto-report.json";
59
+
60
+ /**
61
+ * The marker sits beside the spooled reports rather than in its own directory,
62
+ * so one `impel nuke` erases both and a test that redirects `spoolDir`
63
+ * redirects the marker with it.
64
+ */
65
+ export function autoReportMarkerPath(spoolDir = REPORT_SPOOL_DIR) {
66
+ return path.join(spoolDir, AUTO_REPORT_MARKER_NAME);
67
+ }
68
+
69
+ /** R4's window. A failure that recurs the next day is worth hearing about again. */
70
+ export const AUTO_REPORT_SUPPRESSION_MS = 24 * 60 * 60 * 1_000;
71
+
72
+ /**
73
+ * A list, not a single slot (KTD4). One machine reaches more than one failure
74
+ * mode, and two alternating failures sharing a slot would evict each other's
75
+ * record so neither is ever suppressed — the exact case automatic dispatch
76
+ * makes common.
77
+ */
78
+ export const AUTO_REPORT_MARKER_ENTRIES = 32;
79
+
80
+ /**
81
+ * `buildEnvelope` adds `tenant` and `reportId` of its own, and this mapping adds
82
+ * four more, so the failure's own diagnostics are trimmed to leave room.
83
+ * Exceeding `MAX_DIAGNOSTIC_KEYS` throws, and a report lost to a crowded
84
+ * diagnostics bag is a worse outcome than a report missing its 19th diagnostic.
85
+ */
86
+ const RESERVED_DIAGNOSTIC_KEYS = 6;
87
+
88
+ /* -------------------------------------------------------------------------- */
89
+ /* Envelope */
90
+ /* -------------------------------------------------------------------------- */
91
+
92
+ /**
93
+ * Fold the sanitized install-failure envelope into the bug-report field bag.
94
+ *
95
+ * The two shapes are close enough to look interchangeable and are not.
96
+ * `bugReportEnvelopeSchema` is `.strict()`, so `scope`, `tenantId`, `step`, and
97
+ * `signal` have no top-level home and must travel inside `diagnostics`. Each
98
+ * mismatch left uncorrected is a 400, not a dropped field.
99
+ *
100
+ * The wire's own bounds are not re-applied here: `buildEnvelope` already
101
+ * truncates each field to its schema length and drops a falsy one rather than
102
+ * sending it, which is the same answer the sanitizer's `null`s want. A second
103
+ * copy of `64` in this file would be a bound that can drift from the one the
104
+ * server actually enforces.
105
+ *
106
+ * `step` doubles as `command` when the failure has none, so the server's task
107
+ * title has something to name.
108
+ */
109
+ function reportFields(sanitized) {
110
+ const own = Object.entries(sanitized.diagnostics || {})
111
+ .slice(0, MAX_DIAGNOSTIC_KEYS - RESERVED_DIAGNOSTIC_KEYS);
112
+ return {
113
+ message: sanitized.message,
114
+ command: sanitized.command || sanitized.step,
115
+ errorCode: sanitized.errorCode,
116
+ exitCode: sanitized.exitCode,
117
+ log: sanitized.stderr || sanitized.stdout || "",
118
+ diagnostics: {
119
+ ...Object.fromEntries(own),
120
+ scope: sanitized.scope,
121
+ step: sanitized.step,
122
+ ...(sanitized.tenantId ? { tenantId: sanitized.tenantId } : {}),
123
+ ...(sanitized.signal ? { signal: sanitized.signal } : {}),
124
+ },
125
+ };
126
+ }
127
+
128
+ /* -------------------------------------------------------------------------- */
129
+ /* Suppression marker */
130
+ /* -------------------------------------------------------------------------- */
131
+
132
+ /** An unreadable or malformed marker reads as absent: it must not block a report. */
133
+ function readMarker(markerPath) {
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(markerPath, "utf8"));
136
+ if (!Array.isArray(parsed?.entries)) return [];
137
+ return parsed.entries.filter((entry) => (
138
+ entry && typeof entry.reportId === "string" && typeof entry.sentAt === "string"
139
+ ));
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ function alreadyReported(entries, reportId, now) {
146
+ return entries.some((entry) => {
147
+ if (entry.reportId !== reportId) return false;
148
+ const sentAt = Date.parse(entry.sentAt);
149
+ return Number.isFinite(sentAt) && now - sentAt < AUTO_REPORT_SUPPRESSION_MS;
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Record this send.
155
+ *
156
+ * Losing the marker costs one duplicate report; refusing to report because the
157
+ * marker could not be written costs the report. So every failure here is
158
+ * swallowed by the caller.
159
+ */
160
+ function writeMarker(markerPath, entries, reportId, now) {
161
+ const kept = entries
162
+ .filter((entry) => entry.reportId !== reportId)
163
+ .filter((entry) => {
164
+ const sentAt = Date.parse(entry.sentAt);
165
+ return Number.isFinite(sentAt) && now - sentAt < AUTO_REPORT_SUPPRESSION_MS;
166
+ });
167
+ // Newest first, so the cap drops the oldest.
168
+ const next = [{ reportId, sentAt: new Date(now).toISOString() }, ...kept]
169
+ .slice(0, AUTO_REPORT_MARKER_ENTRIES);
170
+ writeJsonAtomically(markerPath, { entries: next });
171
+ }
172
+
173
+ /* -------------------------------------------------------------------------- */
174
+ /* Dispatch */
175
+ /* -------------------------------------------------------------------------- */
176
+
177
+ /**
178
+ * File a bug report for a failed install, or do nothing.
179
+ *
180
+ * `failure` is the same object the command already hands `io.recoverInstall`,
181
+ * so a call site adds no new data collection — it hands over what it had
182
+ * assembled anyway.
183
+ *
184
+ * `io` is the *calling command's* bag, which carries none of the delivery
185
+ * seams: `impel setup` and `impel update` have no `fetchImpl`, no `spoolDir`,
186
+ * no `sleep`. Those defaults are supplied here and caller keys win, so a test
187
+ * can inject a fake fetch and a command does not have to grow twelve fields it
188
+ * has no other use for.
189
+ */
190
+ export async function reportInstallFailure({ failure, config, io = {} }) {
191
+ try {
192
+ const environment = io.environment || process.env;
193
+ const homeDir = io.homeDir || os.homedir();
194
+
195
+ // KTD2's order. `firstPartyCli` leads: a rebranded fork must not inherit
196
+ // Impel's phone-home, and that answer does not depend on this machine's
197
+ // config, environment, or PAT — so it is also the only gate that can be
198
+ // decided without reading any of them.
199
+ if (!firstPartyCli()) return;
200
+ if (reportEnvOptOut(environment)) return;
201
+ // Before the marker check only because it is a property read and that one
202
+ // walks `realpath` up the ancestor chain. Nothing to authenticate with is
203
+ // just as final an answer, and this is a command that has already failed
204
+ // and wants to exit.
205
+ if (!config?.pat) return;
206
+ // Not a consent question (R7): a managed vendor app profile may reach the
207
+ // gateway and nothing else, so a report from inside one presents as an
208
+ // unexpected egress call from a profile holding app credentials.
209
+ if (managedMarkerPresent(environment, homeDir)) return;
210
+
211
+ const delivery = {
212
+ environment,
213
+ homeDir,
214
+ fetchImpl: fetchHttp1,
215
+ spoolDir: REPORT_SPOOL_DIR,
216
+ timeoutMs: AUTO_REPORT_TIMEOUT_MS,
217
+ now: Date.now(),
218
+ platform: process.platform,
219
+ sleep,
220
+ log: console.log,
221
+ warn: console.warn,
222
+ ...io,
223
+ };
224
+
225
+ // The same destination the manual command resolves, through the same
226
+ // check, for the same reason and one more: this sender has no user to
227
+ // notice that `appUrl` was hand-edited. A rejection is a gate like any
228
+ // other and returns silently — there is no `--app` flag to correct.
229
+ let appUrl;
230
+ try {
231
+ appUrl = resolveReportOrigin(config.appUrl || resolveDefaultAppUrl());
232
+ } catch {
233
+ return;
234
+ }
235
+
236
+ // Sanitize first, then build — not instead of building. The sanitizer
237
+ // removes credentials, home paths, and emails from the recovery envelope;
238
+ // `buildEnvelope` is what makes it a wire-legal bug report.
239
+ const sanitized = sanitizeInstallFailureEnvelope(failure, { homeDir });
240
+ const { envelope, reportId } = buildEnvelope({
241
+ fields: reportFields(sanitized),
242
+ config,
243
+ io: delivery,
244
+ });
245
+
246
+ // R4. Read before printing: a suppressed report prints nothing at all,
247
+ // because the disclosure line exists to explain a send that is happening.
248
+ const markerPath = autoReportMarkerPath(delivery.spoolDir);
249
+ const entries = readMarker(markerPath);
250
+ if (alreadyReported(entries, reportId, delivery.now)) return;
251
+
252
+ // R9/KTD6: before the request, and worded as an attempt. The id is a
253
+ // fingerprint of the envelope so it is already known, and up to ~17 s of
254
+ // retries against an unreachable control plane should not be a silent
255
+ // pause. A past-tense claim would be false in exactly the case that
256
+ // matters — a spooled report announcing a send that never happened.
257
+ delivery.log(`Reporting this failure automatically (id ${reportId}). Set ${DO_NOT_TRACK_ENV}=1 to disable.`);
258
+
259
+ const { outcome } = await deliverWithRetry({
260
+ envelope,
261
+ appUrl,
262
+ currentPat: config.pat,
263
+ io: delivery,
264
+ });
265
+
266
+ if (!outcome) {
267
+ // The outcome line that corrects the attempt line. Nothing drains this
268
+ // directory — the spool is a local paper trail, not a queue — so the next
269
+ // occurrence of this failure dispatches again rather than being
270
+ // suppressed, which is also why the marker below is not written here.
271
+ const filePath = spoolReport({ envelope, reportId, appUrl, io: delivery });
272
+ delivery.log(`The report could not be sent. It is saved at ${filePath}`);
273
+ return;
274
+ }
275
+
276
+ // A successful send prints nothing further, and is the only thing that
277
+ // suppresses a repeat. Marker failures are swallowed on purpose.
278
+ try {
279
+ writeMarker(markerPath, entries, reportId, delivery.now);
280
+ } catch {
281
+ // Costs one duplicate report. Refusing to report would cost the report.
282
+ }
283
+ } catch {
284
+ // KTD10. The command that called this is already failing; the reporting
285
+ // path must not add a second failure mode, and there is nobody here to
286
+ // tell. Deliberately empty: a warning printed from this catch would be the
287
+ // second error message R10 promises the user will not see.
288
+ }
289
+ }