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.
- package/README.md +148 -8
- package/RELEASE_NOTES.md +59 -0
- package/docs/native-agent-host-capability-matrix.md +5 -5
- package/package.json +1 -1
- package/src/agents.js +4 -4
- package/src/apps.js +84 -36
- package/src/autoReport.js +289 -0
- package/src/bugReport.js +499 -0
- package/src/cli.js +61 -5
- package/src/codexSecurity.js +0 -18
- package/src/commands/apps.js +25 -11
- package/src/commands/auth.js +5 -1
- package/src/commands/converge.js +2 -1
- package/src/commands/cursorExperimental.js +74 -2
- package/src/commands/nuke.js +2 -2
- package/src/commands/report.js +309 -0
- package/src/commands/setup.js +134 -1
- package/src/commands/status.js +28 -3
- package/src/commands/update.js +82 -1
- package/src/cursorLocal.js +8 -0
- package/src/desktopTasks.js +420 -787
- package/src/doctor.js +17 -1
- package/src/exitCodes.js +61 -0
- package/src/featureFlags.js +393 -0
- package/src/managedProfileVersion.js +3 -1
- package/src/posthog.js +833 -0
- package/src/runtimeBrand.js +2 -2
- package/src/telemetryConsent.js +334 -0
- package/src/telemetryNotice.js +147 -0
- package/src/tenants.js +48 -0
- package/src/updates.js +26 -1
|
@@ -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
|
+
}
|