impel-cli 0.20.45 → 0.20.46-beta.0
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 +134 -0
- package/docs/native-agent-host-capability-matrix.md +2 -2
- package/package.json +1 -1
- package/src/apps.js +8 -8
- package/src/autoReport.js +289 -0
- package/src/bugReport.js +499 -0
- package/src/cli.js +61 -5
- 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/exitCodes.js +61 -0
- package/src/featureFlags.js +393 -0
- 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
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
prepareManagedCursor,
|
|
10
10
|
reusePreparedManagedCursor,
|
|
11
11
|
} from "../cursorLocal.js";
|
|
12
|
+
import { FEATURE_NOT_ENABLED_EXIT_CODE } from "../exitCodes.js";
|
|
13
|
+
import { CURSOR_EXPERIMENT_FLAG, evaluateFlag, FlagStateUnknownError } from "../featureFlags.js";
|
|
12
14
|
import { fetchHttp1 } from "../http1.js";
|
|
13
15
|
import { loadConfig, normalizeGatewayUrl, redactSecretText, resolveDefaultGateway } from "../config.js";
|
|
16
|
+
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
14
17
|
import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
|
|
15
18
|
|
|
16
19
|
export const CURSOR_EXPERIMENT_HELP = `impel experimental cursor prepare|open|status
|
|
@@ -93,6 +96,55 @@ async function cursorGatewayCatalog(options, dependencies = {}) {
|
|
|
93
96
|
return payload;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the Cursor experiment flag into a decision, without throwing.
|
|
101
|
+
*
|
|
102
|
+
* Returned as data rather than raised for two reasons. `status` has to keep
|
|
103
|
+
* working with the flag off — a diagnostic that refuses to run when the thing
|
|
104
|
+
* it diagnoses is disabled is useless — so it needs the state as a value. And
|
|
105
|
+
* a refusal carries its own exit code, which a throw cannot: `experimental`
|
|
106
|
+
* lets errors escape to `bin/impel.js`, whose catch-all 1 is indistinguishable
|
|
107
|
+
* from a crash.
|
|
108
|
+
*
|
|
109
|
+
* The two refusals are deliberately different numbers. "Off for this account"
|
|
110
|
+
* is a settled answer and retrying changes nothing; "could not establish the
|
|
111
|
+
* state" is worth retrying the moment the machine is back online.
|
|
112
|
+
*/
|
|
113
|
+
async function cursorFlagDecision({ config, homeDir }, dependencies) {
|
|
114
|
+
const evaluate = dependencies.evaluateFlag || evaluateFlag;
|
|
115
|
+
try {
|
|
116
|
+
const flag = await evaluate(CURSOR_EXPERIMENT_FLAG, {
|
|
117
|
+
config,
|
|
118
|
+
env: dependencies.environment || process.env,
|
|
119
|
+
homeDir,
|
|
120
|
+
});
|
|
121
|
+
if (flag.enabled) {
|
|
122
|
+
return { allowed: true, source: flag.source, stateLabel: `enabled (${flag.source})` };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
allowed: false,
|
|
126
|
+
reason: "disabled",
|
|
127
|
+
source: flag.source,
|
|
128
|
+
stateLabel: `disabled for this account (${flag.source})`,
|
|
129
|
+
exitCode: FEATURE_NOT_ENABLED_EXIT_CODE,
|
|
130
|
+
message: `managed Cursor is not enabled for this account; ask your ${RUNTIME_BRAND.product.displayName} workspace admin to turn on the Cursor experiment, or run \`${RUNTIME_BRAND.cli.command} report --message "requesting Cursor access"\``,
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!(error instanceof FlagStateUnknownError)) throw error;
|
|
134
|
+
// The message is already one actionable sentence naming the specific cause
|
|
135
|
+
// — unreachable, undeployed route, backoff, managed surface — so it is
|
|
136
|
+
// passed through rather than flattened into a generic refusal.
|
|
137
|
+
return {
|
|
138
|
+
allowed: false,
|
|
139
|
+
reason: error.reason,
|
|
140
|
+
source: null,
|
|
141
|
+
stateLabel: `unknown — ${error.message}`,
|
|
142
|
+
exitCode: error.exitCode,
|
|
143
|
+
message: `managed Cursor cannot start: ${error.message}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
96
148
|
function statusLines(status, tenantId) {
|
|
97
149
|
const vendorLabel = status.vendor
|
|
98
150
|
? `${status.vendor.version} (${status.vendor.commit.slice(0, 12)})`
|
|
@@ -138,20 +190,40 @@ export async function cmdCursorExperimental(argv, dependencies = {}) {
|
|
|
138
190
|
};
|
|
139
191
|
const credential = tenantCredential(config.pat, tenantId);
|
|
140
192
|
|
|
193
|
+
// The fourth gate, after authentication, tenant selection, and scopes, and
|
|
194
|
+
// before anything touches the tenant's runtime. Those three answer "who are
|
|
195
|
+
// you"; this one answers "should this experiment run at all", which is the
|
|
196
|
+
// question that has to be answerable remotely — an experiment nobody can
|
|
197
|
+
// turn off from the control plane is not an experiment, it is a release.
|
|
198
|
+
const flag = await cursorFlagDecision({ config, homeDir }, dependencies);
|
|
199
|
+
|
|
141
200
|
if (action === "status") {
|
|
142
201
|
const status = (dependencies.managedCursorStatus || managedCursorStatus)(common, dependencies);
|
|
143
202
|
for (const line of statusLines(status, tenantId)) log(line);
|
|
203
|
+
// `status` is exempt from the refusal on purpose: the flag state is one of
|
|
204
|
+
// the things someone runs `status` to find out, and reporting it is the
|
|
205
|
+
// opposite of leaking access.
|
|
206
|
+
log(`Cursor experiment access: ${flag.stateLabel}`);
|
|
144
207
|
try {
|
|
145
208
|
const catalog = await cursorGatewayCatalog({ gatewayUrl, credential, tenantId }, dependencies);
|
|
146
209
|
log(`Gateway Cursor catalog: ready (${catalog.data.length} model${catalog.data.length === 1 ? "" : "s"})`);
|
|
147
|
-
return { ...status, gatewayReady: true, catalog };
|
|
210
|
+
return { ...status, flag, gatewayReady: true, catalog };
|
|
148
211
|
} catch (error) {
|
|
149
212
|
const gatewayError = redactSecretText(error?.message || error);
|
|
150
213
|
log(`Gateway Cursor catalog: unavailable — ${gatewayError}`);
|
|
151
|
-
return { ...status, gatewayReady: false, gatewayError };
|
|
214
|
+
return { ...status, flag, gatewayReady: false, gatewayError };
|
|
152
215
|
}
|
|
153
216
|
}
|
|
154
217
|
|
|
218
|
+
if (!flag.allowed) {
|
|
219
|
+
// One line, a distinct code, and no runtime work — the same contract the
|
|
220
|
+
// managed-runtime preflight follows. `process.exitCode` rather than a throw
|
|
221
|
+
// keeps this distinguishable from a crash.
|
|
222
|
+
(dependencies.reportError || console.error)(flag.message);
|
|
223
|
+
process.exitCode = flag.exitCode;
|
|
224
|
+
return { refused: true, flag };
|
|
225
|
+
}
|
|
226
|
+
|
|
155
227
|
let prepared;
|
|
156
228
|
try {
|
|
157
229
|
prepared = (dependencies.prepareManagedCursor || prepareManagedCursor)(common, dependencies);
|
package/src/commands/nuke.js
CHANGED
|
@@ -190,7 +190,7 @@ export async function cmdNuke(argv = [], overrides = {}) {
|
|
|
190
190
|
if (libraryRemnants.length) io.log(` macOS caches/preferences/state entries: ${libraryRemnants.length}`);
|
|
191
191
|
if (keychainItems.length) io.log(` Keychain Safe Storage items: ${keychainItems.length}`);
|
|
192
192
|
if (windowsRemnants.length) io.log(` Windows managed profiles and entry point: ${windowsRemnants.length}`);
|
|
193
|
-
if (configDirExists) io.log(` CLI state, profiles, vendor cache, and
|
|
193
|
+
if (configDirExists) io.log(` CLI state, profiles, vendor cache, auth, analytics consent, queued analytics, flag cache, and saved bug reports: ${io.configDir}`);
|
|
194
194
|
io.log("Vendor apps and native ~/.claude and ~/.codex profiles are not touched.");
|
|
195
195
|
|
|
196
196
|
if (!flags.yes) {
|
|
@@ -233,7 +233,7 @@ export async function cmdNuke(argv = [], overrides = {}) {
|
|
|
233
233
|
|
|
234
234
|
if (configDirExists) {
|
|
235
235
|
fs.rmSync(io.configDir, { recursive: true, force: true });
|
|
236
|
-
io.log(`Removed ${io.configDir} (profiles, vendor cache, sessions, and
|
|
236
|
+
io.log(`Removed ${io.configDir} (profiles, vendor cache, sessions, auth, analytics consent and queue, flag cache, and saved bug reports).`);
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
io.log("impel nuke: complete. Run `impel setup` for a clean install.");
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// `impel report` — the user-initiated bug report path.
|
|
2
|
+
//
|
|
3
|
+
// This command exists for the moment something else is broken, and every
|
|
4
|
+
// decision below follows from that:
|
|
5
|
+
//
|
|
6
|
+
// - Typing the command *is* the decision to send (KD1). There is no payload
|
|
7
|
+
// preview to approve and no `[y/N]`, because there is nothing left to
|
|
8
|
+
// decide: the user asked for this. It follows that a non-TTY sends too —
|
|
9
|
+
// `impel report` inside a script is still someone reporting a bug.
|
|
10
|
+
// - It does not consult analytics consent, and it does not consult
|
|
11
|
+
// `reportEnvOptOut` either. Reporting a bug is a request the user typed,
|
|
12
|
+
// not passive measurement, so `telemetry.enabled: false` must not silence
|
|
13
|
+
// it (R3/KD5) — and `IMPEL_DO_NOT_TRACK` stops the CLI acting on its own
|
|
14
|
+
// rather than disabling a command someone ran on purpose.
|
|
15
|
+
// - The managed-surface guard still applies, because the P1-8 egress boundary
|
|
16
|
+
// is not a consent question (R7): a profile holding vendor app credentials
|
|
17
|
+
// may reach the gateway and nothing else.
|
|
18
|
+
// - It never loses the report. Anything short of an accepted response writes
|
|
19
|
+
// the envelope under `CONFIG_DIR` and prints the path, exiting with
|
|
20
|
+
// `REPORT_SPOOLED_EXIT_CODE` so a script can tell "saved, not sent" from a
|
|
21
|
+
// crash (R4).
|
|
22
|
+
//
|
|
23
|
+
// Everything about *what* is sent lives in `src/bugReport.js`, shared with the
|
|
24
|
+
// automatic sender. What is left here is the part that has a person attached:
|
|
25
|
+
// flags, files, printed sentences, and an exit code.
|
|
26
|
+
//
|
|
27
|
+
// The spool lives under `CONFIG_DIR`, so `impel nuke` erases it.
|
|
28
|
+
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
|
|
33
|
+
import { parseFlags } from "../args.js";
|
|
34
|
+
import {
|
|
35
|
+
BugReportError,
|
|
36
|
+
buildEnvelope,
|
|
37
|
+
deliverWithRetry,
|
|
38
|
+
REASON_NOTES,
|
|
39
|
+
REPORT_ENDPOINT_PATH,
|
|
40
|
+
REPORT_SPOOL_DIR,
|
|
41
|
+
resolveReportOrigin,
|
|
42
|
+
sleep,
|
|
43
|
+
SPOOL_MAX_FILES,
|
|
44
|
+
spoolReport,
|
|
45
|
+
} from "../bugReport.js";
|
|
46
|
+
import { loadConfig, redactSecretText, resolveDefaultAppUrl } from "../config.js";
|
|
47
|
+
import { REPORT_SPOOLED_EXIT_CODE } from "../exitCodes.js";
|
|
48
|
+
import { fetchHttp1 } from "../http1.js";
|
|
49
|
+
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
50
|
+
import { CI_ENV, DO_NOT_TRACK_ENV, managedMarkerPresent } from "../telemetryConsent.js";
|
|
51
|
+
|
|
52
|
+
// Re-exported: the shared core owns these now, but they are this command's
|
|
53
|
+
// public surface and callers should not have to know where they moved.
|
|
54
|
+
export { REASON_NOTES, REPORT_ENDPOINT_PATH, REPORT_SPOOL_DIR };
|
|
55
|
+
|
|
56
|
+
const REPORT_COMMAND = `${RUNTIME_BRAND.cli.command} report`;
|
|
57
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
58
|
+
|
|
59
|
+
const MIN_EXIT_CODE = -1;
|
|
60
|
+
const MAX_EXIT_CODE = 65_535;
|
|
61
|
+
|
|
62
|
+
const FLAG_SPEC = Object.freeze({
|
|
63
|
+
app: { type: "string" },
|
|
64
|
+
command: { type: "string" },
|
|
65
|
+
diagnostics: { type: "string" },
|
|
66
|
+
"error-code": { type: "string" },
|
|
67
|
+
"exit-code": { type: "string" },
|
|
68
|
+
help: { type: "boolean" },
|
|
69
|
+
log: { type: "string" },
|
|
70
|
+
message: { type: "string" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const FLAG_NAMES = new Set(Object.keys(FLAG_SPEC));
|
|
74
|
+
|
|
75
|
+
const HELP = `${REPORT_COMMAND} - send a bug report to ${RUNTIME_BRAND.product.displayName}
|
|
76
|
+
|
|
77
|
+
Usage:
|
|
78
|
+
${REPORT_COMMAND} --message "<what went wrong>" [options]
|
|
79
|
+
|
|
80
|
+
Options:
|
|
81
|
+
--message <text> What went wrong, in your words.
|
|
82
|
+
--command <name> The ${RUNTIME_BRAND.cli.command} command that failed.
|
|
83
|
+
--exit-code <n> The exit code it returned.
|
|
84
|
+
--error-code <code> The error code it printed, when there was one.
|
|
85
|
+
--log <file> A file holding that command's output.
|
|
86
|
+
--diagnostics <file> Output from \`${RUNTIME_BRAND.cli.command} doctor --json\` to attach.
|
|
87
|
+
--app <url> Override the app/control-plane URL for this command.
|
|
88
|
+
|
|
89
|
+
The CLI version, Node version, platform, architecture, release channel, and your
|
|
90
|
+
current organization are collected automatically. Credentials, home directory
|
|
91
|
+
paths, and email addresses are removed before anything is sent. Running this
|
|
92
|
+
command sends the report — reports work whether or not analytics are enabled.
|
|
93
|
+
|
|
94
|
+
When \`${RUNTIME_BRAND.cli.command} setup\` or \`${RUNTIME_BRAND.cli.command} update\` runs out of ways to fix a failure, the CLI
|
|
95
|
+
files one of these reports on its own and says so. To stop that, set
|
|
96
|
+
${DO_NOT_TRACK_ENV}=1 or ${CI_ENV}=1. Neither switch disables this command, which
|
|
97
|
+
is a message you chose to send. Bare \`DO_NOT_TRACK\` and \`CI\` turn off analytics
|
|
98
|
+
but not failure reports — silence about a broken install is not something an
|
|
99
|
+
unrelated environment variable should decide.
|
|
100
|
+
|
|
101
|
+
If the report cannot be delivered it is written under
|
|
102
|
+
${path.join("~", ".config", RUNTIME_BRAND.cli.configNamespace, "reports")}
|
|
103
|
+
and the path is printed, so it is never lost. That directory keeps the last
|
|
104
|
+
${SPOOL_MAX_FILES} reports and nothing older than 30 days.
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
class ReportCommandError extends Error {}
|
|
108
|
+
|
|
109
|
+
function fail(message) {
|
|
110
|
+
throw new ReportCommandError(message);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* -------------------------------------------------------------------------- */
|
|
114
|
+
/* Flags */
|
|
115
|
+
/* -------------------------------------------------------------------------- */
|
|
116
|
+
|
|
117
|
+
function readAttachment(filePath, label) {
|
|
118
|
+
try {
|
|
119
|
+
return fs.readFileSync(filePath, "utf8");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
fail(`${REPORT_COMMAND}: could not read ${label} file: ${redactSecretText(error?.message || error)}`);
|
|
122
|
+
}
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requestedExitCode(value) {
|
|
127
|
+
if (value === undefined) return undefined;
|
|
128
|
+
const raw = String(value).trim();
|
|
129
|
+
if (!/^-?\d+$/u.test(raw)) {
|
|
130
|
+
fail(`${REPORT_COMMAND}: --exit-code must be an integer from ${MIN_EXIT_CODE} to ${MAX_EXIT_CODE}.`);
|
|
131
|
+
}
|
|
132
|
+
const exitCode = Number(raw);
|
|
133
|
+
if (!Number.isSafeInteger(exitCode) || exitCode < MIN_EXIT_CODE || exitCode > MAX_EXIT_CODE) {
|
|
134
|
+
fail(`${REPORT_COMMAND}: --exit-code must be an integer from ${MIN_EXIT_CODE} to ${MAX_EXIT_CODE}.`);
|
|
135
|
+
}
|
|
136
|
+
return exitCode;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Turn parsed flags into the core's normalized field bag.
|
|
141
|
+
*
|
|
142
|
+
* Attachment reads and exit-code parsing stay here because they are this
|
|
143
|
+
* front-end's concerns: only the manual command has files to read and a
|
|
144
|
+
* `--exit-code` string to validate. The core takes values, not flags.
|
|
145
|
+
*/
|
|
146
|
+
function envelopeFields(flags) {
|
|
147
|
+
return {
|
|
148
|
+
message: flags.message,
|
|
149
|
+
command: flags.command,
|
|
150
|
+
errorCode: flags["error-code"],
|
|
151
|
+
exitCode: requestedExitCode(flags["exit-code"]),
|
|
152
|
+
log: flags.log === undefined ? "" : readAttachment(flags.log, "--log"),
|
|
153
|
+
doctor: flags.diagnostics === undefined
|
|
154
|
+
? ""
|
|
155
|
+
: readAttachment(flags.diagnostics, "--diagnostics"),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* -------------------------------------------------------------------------- */
|
|
160
|
+
/* Spool */
|
|
161
|
+
/* -------------------------------------------------------------------------- */
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The single "not sent" exit.
|
|
165
|
+
*
|
|
166
|
+
* Every failure lands here — unreachable, absent route, rate limited, refused,
|
|
167
|
+
* unreadable response — because from the reporter's side they are one state:
|
|
168
|
+
* the report exists, it is on disk, and it was not delivered.
|
|
169
|
+
*/
|
|
170
|
+
function reportNotSent({ envelope, reportId, appUrl, io, message }) {
|
|
171
|
+
io.warn(`${REPORT_COMMAND}: ${message}`);
|
|
172
|
+
let filePath = null;
|
|
173
|
+
try {
|
|
174
|
+
filePath = spoolReport({ envelope, reportId, appUrl, io });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
io.warn(`${REPORT_COMMAND}: the report could not be saved either: ${redactSecretText(error?.message || error)}`);
|
|
177
|
+
// Nothing was printed to copy — the envelope is never dumped to the
|
|
178
|
+
// terminal (KD1 removed the confirmation that used to show it), and this
|
|
179
|
+
// branch is the one place where it also failed to reach disk. So print it
|
|
180
|
+
// here, redacted, as the only remaining copy.
|
|
181
|
+
io.warn(`${REPORT_COMMAND}: this is the whole report; copy it before closing this terminal.`);
|
|
182
|
+
io.warn(redactSecretText(JSON.stringify(envelope, null, 2)));
|
|
183
|
+
process.exitCode = REPORT_SPOOLED_EXIT_CODE;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
io.log(`Nothing was lost. The report is saved at ${filePath}`);
|
|
187
|
+
io.log(`Attach that file when you contact ${RUNTIME_BRAND.product.displayName} support, or run \`${REPORT_COMMAND}\` again once the connection is back.`);
|
|
188
|
+
process.exitCode = REPORT_SPOOLED_EXIT_CODE;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* -------------------------------------------------------------------------- */
|
|
192
|
+
/* Command */
|
|
193
|
+
/* -------------------------------------------------------------------------- */
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The core's origin check, wearing this command's error messages.
|
|
197
|
+
*
|
|
198
|
+
* The rejection reasons are identical, but the wording belongs to the front-end
|
|
199
|
+
* that has a `--app` flag to name. The automatic sender has no flag and no
|
|
200
|
+
* stderr, so it reads the same `BugReportError` codes and stays silent instead.
|
|
201
|
+
*/
|
|
202
|
+
function requireAppUrl(flags, config) {
|
|
203
|
+
try {
|
|
204
|
+
return resolveReportOrigin(flags.app || config.appUrl || resolveDefaultAppUrl());
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error instanceof BugReportError && error.code === "invalid_url") {
|
|
207
|
+
fail(`${REPORT_COMMAND}: --app must be an HTTP or HTTPS URL.`);
|
|
208
|
+
}
|
|
209
|
+
if (error instanceof BugReportError && error.code === "unsafe_origin") {
|
|
210
|
+
fail(`${REPORT_COMMAND}: --app must be a bare HTTP or HTTPS origin without credentials, a path, query, or fragment.`);
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function cmdReport(argv = [], overrides = {}) {
|
|
218
|
+
const io = {
|
|
219
|
+
loadConfig,
|
|
220
|
+
fetchImpl: fetchHttp1,
|
|
221
|
+
environment: process.env,
|
|
222
|
+
homeDir: os.homedir(),
|
|
223
|
+
platform: process.platform,
|
|
224
|
+
now: Date.now(),
|
|
225
|
+
spoolDir: REPORT_SPOOL_DIR,
|
|
226
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
227
|
+
sleep,
|
|
228
|
+
log: console.log,
|
|
229
|
+
warn: console.warn,
|
|
230
|
+
...overrides,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const { flags, positionals } = parseFlags(argv, FLAG_SPEC);
|
|
234
|
+
try {
|
|
235
|
+
if (flags.help) {
|
|
236
|
+
io.log(HELP);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const unknownFlag = Object.keys(flags).find((name) => !FLAG_NAMES.has(name));
|
|
240
|
+
if (unknownFlag) fail(`${REPORT_COMMAND}: unknown option --${redactSecretText(unknownFlag)}.`);
|
|
241
|
+
if (positionals.length) {
|
|
242
|
+
fail(`${REPORT_COMMAND}: unexpected argument "${redactSecretText(positionals[0])}"; put your description in --message.`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The P1-8 egress boundary, checked before anything is collected. A managed
|
|
246
|
+
// vendor app profile may reach the gateway and nothing else, so a report
|
|
247
|
+
// sent from inside one presents as an unexpected call from a profile
|
|
248
|
+
// holding app credentials. Unlike analytics this is not a consent question.
|
|
249
|
+
if (managedMarkerPresent(io.environment, io.homeDir)) {
|
|
250
|
+
fail(`${REPORT_COMMAND}: bug reports cannot be sent from inside a managed ${RUNTIME_BRAND.product.displayName} app profile; run this from a terminal.`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Deliberately not `analyticsConsentGranted`: opting out of measurement is
|
|
254
|
+
// not opting out of being able to report a bug (R3/KD5).
|
|
255
|
+
const config = io.loadConfig();
|
|
256
|
+
if (!config?.pat) {
|
|
257
|
+
fail(`${REPORT_COMMAND}: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` first, then report the bug.`);
|
|
258
|
+
}
|
|
259
|
+
const appUrl = requireAppUrl(flags, config);
|
|
260
|
+
let envelope;
|
|
261
|
+
let reportId;
|
|
262
|
+
try {
|
|
263
|
+
({ envelope, reportId } = buildEnvelope({ fields: envelopeFields(flags), config, io }));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error instanceof BugReportError && error.code === "too_many_diagnostics") {
|
|
266
|
+
fail(`${REPORT_COMMAND}: too many diagnostic fields to send.`);
|
|
267
|
+
}
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Printed before the request, not after (KTD6): the id is a fingerprint of
|
|
272
|
+
// the envelope, so it is known now, and three attempts against an
|
|
273
|
+
// unreachable control plane is a long enough pause to owe the user a line
|
|
274
|
+
// first. Worded as an attempt for the same reason — the outcome, when it is
|
|
275
|
+
// not a plain success, is a separate sentence below.
|
|
276
|
+
io.log(`Sending this report (id ${reportId}). Set ${DO_NOT_TRACK_ENV}=1 to disable.`);
|
|
277
|
+
|
|
278
|
+
const { outcome, failure } = await deliverWithRetry({
|
|
279
|
+
envelope,
|
|
280
|
+
appUrl,
|
|
281
|
+
currentPat: config.pat,
|
|
282
|
+
io,
|
|
283
|
+
});
|
|
284
|
+
if (failure) {
|
|
285
|
+
reportNotSent({ envelope, reportId, appUrl, io, message: failure });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (outcome.taskCreated) {
|
|
289
|
+
io.log(`Reported. Task ${outcome.taskId} was created in your ${RUNTIME_BRAND.product.displayName} workspace.`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// `BUG_REPORT_REASONS` comes from the contract, so a reason added there
|
|
293
|
+
// reaches this line before anyone writes it a sentence. Fall back to the
|
|
294
|
+
// bare code rather than interpolating `undefined` at the user; the
|
|
295
|
+
// contract-derived test below keeps that fallback from becoming the
|
|
296
|
+
// normal path.
|
|
297
|
+
const sentence = REASON_NOTES[outcome.reason];
|
|
298
|
+
const note = sentence ? `: ${sentence}` : "";
|
|
299
|
+
const reason = outcome.reason ? ` (${outcome.reason})` : "";
|
|
300
|
+
io.log(`Recorded, no task${reason}${note}. Report ID ${reportId}.`);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (error instanceof ReportCommandError) {
|
|
303
|
+
console.error(error.message);
|
|
304
|
+
process.exitCode = 1;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/commands/setup.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
redactSecretText,
|
|
12
12
|
} from "../config.js";
|
|
13
13
|
import { promptSecret, promptText } from "../prompt.js";
|
|
14
|
-
import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
14
|
+
import { applyCliUser, fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
15
15
|
import {
|
|
16
16
|
printReconciliationSummary,
|
|
17
17
|
reconcileAllTenants,
|
|
@@ -19,9 +19,11 @@ import {
|
|
|
19
19
|
} from "../provisioning.js";
|
|
20
20
|
import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
|
|
21
21
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
22
|
+
import { reportInstallFailure } from "../autoReport.js";
|
|
22
23
|
import { refreshUpdateCache, updateNoticeLine } from "../updates.js";
|
|
23
24
|
import { restoreNativeProfiles } from "./use.js";
|
|
24
25
|
import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
26
|
+
import { markTelemetryNoticeShown } from "../telemetryNotice.js";
|
|
25
27
|
import {
|
|
26
28
|
cacheProviderReadiness,
|
|
27
29
|
probeGatewayProviderReadiness,
|
|
@@ -44,6 +46,11 @@ Usage:
|
|
|
44
46
|
impel setup --skip-apps Skip desktop apps
|
|
45
47
|
impel setup --skip-clis Do not install missing vendor CLIs
|
|
46
48
|
impel setup --no-recovery Disable automatic install recovery
|
|
49
|
+
impel setup --analytics on|off Turn anonymous usage analytics on or off
|
|
50
|
+
|
|
51
|
+
Analytics are off unless you turn them on. Separately, when setup or update
|
|
52
|
+
fails, Impel files a bug report so the failure can be fixed; see \`impel report
|
|
53
|
+
--help\`.
|
|
47
54
|
`);
|
|
48
55
|
|
|
49
56
|
/** Kept as a pure compatibility helper for callers/tests. */
|
|
@@ -119,10 +126,66 @@ function recomputeReport(report) {
|
|
|
119
126
|
return report;
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The message for an automatic report about a tenant-convergence failure.
|
|
131
|
+
*
|
|
132
|
+
* Every failed tenant contributes, because a setup that fails two tenants for
|
|
133
|
+
* two different reasons and reports only the first has reported the wrong
|
|
134
|
+
* thing. The tenant id prefixes each error since the ids are otherwise absent
|
|
135
|
+
* from the joined string. `sanitizeInstallFailureEnvelope` bounds the result.
|
|
136
|
+
*/
|
|
137
|
+
function failedTenantMessage(report) {
|
|
138
|
+
const failed = report.tenants.filter((tenant) => tenant.status === "failed");
|
|
139
|
+
const described = failed
|
|
140
|
+
.map((tenant) => `${tenant.tenantId}: ${tenant.errors.join("; ")}`)
|
|
141
|
+
.filter((line) => !line.endsWith(": "));
|
|
142
|
+
return described.join(" | ") || "Tenant readiness verification failed.";
|
|
143
|
+
}
|
|
144
|
+
|
|
122
145
|
function confirmed(answer) {
|
|
123
146
|
return /^(?:y|yes)$/iu.test(String(answer || "").trim());
|
|
124
147
|
}
|
|
125
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Record the analytics preference `--analytics` names, and normalize the key.
|
|
151
|
+
*
|
|
152
|
+
* Setup no longer asks. A question with a default answer is not much of a
|
|
153
|
+
* question, and the one thing `impel setup` must do — get a broken machine
|
|
154
|
+
* working — is not the moment to interrupt with an unrelated one. Analytics
|
|
155
|
+
* stay opt-in and stay switchable: `--analytics on` turns them on, and
|
|
156
|
+
* `--analytics off` (or nothing at all) leaves them off.
|
|
157
|
+
*
|
|
158
|
+
* Every path that is not an explicit `on` removes the key rather than storing
|
|
159
|
+
* `false`, which keeps one meaning for "absent" across the whole codebase —
|
|
160
|
+
* `analyticsConsentGranted` tests for exactly `true`.
|
|
161
|
+
*
|
|
162
|
+
* A branded extension runs its own product and must not inherit Impel's
|
|
163
|
+
* phone-home (KTD11), so `--analytics on` is a no-op there.
|
|
164
|
+
*/
|
|
165
|
+
function applyAnalyticsPreference(config, io, preference) {
|
|
166
|
+
if (RUNTIME_BRAND.cli.packageName !== "impel-cli") return;
|
|
167
|
+
if (preference === "on") {
|
|
168
|
+
config.telemetry = { ...(config.telemetry || {}), enabled: true };
|
|
169
|
+
io.saveConfig(config);
|
|
170
|
+
console.log("✓ Analytics on. Turn them off any time with `impel setup --analytics off`.");
|
|
171
|
+
io.markTelemetryNoticeShown();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Only an explicit `off` revokes a stored yes. A plain `impel setup` — which
|
|
176
|
+
// is also the repair command — must not silently undo a decision on file.
|
|
177
|
+
if (preference === "off" || config.telemetry?.enabled !== true) {
|
|
178
|
+
if (config.telemetry) {
|
|
179
|
+
delete config.telemetry.enabled;
|
|
180
|
+
if (Object.keys(config.telemetry).length === 0) delete config.telemetry;
|
|
181
|
+
io.saveConfig(config);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Written on every path, including the ones that print nothing: having run
|
|
185
|
+
// setup at all is what the first-run notice exists to announce.
|
|
186
|
+
io.markTelemetryNoticeShown();
|
|
187
|
+
}
|
|
188
|
+
|
|
126
189
|
export async function cmdSetup(argv, overrides = {}) {
|
|
127
190
|
const io = {
|
|
128
191
|
loadConfig,
|
|
@@ -134,12 +197,14 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
134
197
|
reconcile: reconcileAllTenants,
|
|
135
198
|
probe: probeGateway,
|
|
136
199
|
recoverInstall: runInstallRecovery,
|
|
200
|
+
reportInstallFailure,
|
|
137
201
|
restoreNativeProfiles,
|
|
138
202
|
isTTY: process.stdin.isTTY,
|
|
139
203
|
platform: process.platform,
|
|
140
204
|
environment: process.env,
|
|
141
205
|
refreshUpdateCache,
|
|
142
206
|
updateNoticeLine,
|
|
207
|
+
markTelemetryNoticeShown,
|
|
143
208
|
...overrides,
|
|
144
209
|
};
|
|
145
210
|
if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
|
|
@@ -154,6 +219,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
154
219
|
"skip-clis": { type: "boolean" },
|
|
155
220
|
repair: { type: "boolean" }, // deprecated compatibility no-op
|
|
156
221
|
"no-recovery": { type: "boolean" },
|
|
222
|
+
analytics: { type: "string" },
|
|
157
223
|
help: { type: "boolean" },
|
|
158
224
|
});
|
|
159
225
|
if (flags.help) {
|
|
@@ -165,6 +231,15 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
165
231
|
process.exitCode = 1;
|
|
166
232
|
return;
|
|
167
233
|
}
|
|
234
|
+
// Rejected before the token prompt and any network call: a typo in a flag
|
|
235
|
+
// about analytics must not cost the user a setup run to discover.
|
|
236
|
+
// `in`, not `!== undefined`: a trailing bare `--analytics` parses to
|
|
237
|
+
// `undefined`, and silently ignoring it would look like it took effect.
|
|
238
|
+
if ("analytics" in flags && !["on", "off"].includes(flags.analytics)) {
|
|
239
|
+
console.error(`impel setup: --analytics accepts "on" or "off", not "${redactSecretText(flags.analytics)}".`);
|
|
240
|
+
process.exitCode = 1;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
168
243
|
|
|
169
244
|
const existing = io.loadConfig();
|
|
170
245
|
const gatewayUrl = normalizeGatewayUrl(flags.gateway || existing?.gatewayUrl || resolveDefaultGateway());
|
|
@@ -203,6 +278,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
203
278
|
config.tenantName = selected.name;
|
|
204
279
|
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
205
280
|
if (listing.scopes) config.scopes = listing.scopes;
|
|
281
|
+
applyCliUser(config, listing);
|
|
206
282
|
config.tenantsUpdatedAt = new Date().toISOString();
|
|
207
283
|
io.saveConfig(config);
|
|
208
284
|
|
|
@@ -212,6 +288,11 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
212
288
|
console.log(` ${tenant.id}${tenant.id === selected.id ? " (CLI default)" : ""} — ${tenant.name}`);
|
|
213
289
|
}
|
|
214
290
|
|
|
291
|
+
// After the credential is stored and the tenant chosen, so the preference is
|
|
292
|
+
// written into a config that already exists, and before convergence starts
|
|
293
|
+
// printing per-tenant progress the confirmation would be buried in.
|
|
294
|
+
applyAnalyticsPreference(config, io, flags.analytics);
|
|
295
|
+
|
|
215
296
|
// Setup is where a stale CLI hurts most (it re-hits installer bugs newer
|
|
216
297
|
// releases already fixed), and on a first run the launch-time notice cache
|
|
217
298
|
// is still empty — so check synchronously here. The token was just verified
|
|
@@ -503,6 +584,58 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
503
584
|
if (sharedFailure) console.error(`Shared setup failure: ${sharedFailure}`);
|
|
504
585
|
const setupCommand = io.platform === "win32" ? "impel.cmd setup" : "impel setup";
|
|
505
586
|
console.error(`Setup is incomplete. Fix the reported issue or rerun \`${setupCommand}\`.`);
|
|
587
|
+
const failedTenantIds = report.tenants
|
|
588
|
+
.filter((tenant) => tenant.status === "failed")
|
|
589
|
+
.map((tenant) => tenant.tenantId);
|
|
590
|
+
// The one dispatch point for this command (R1). It sits here rather than
|
|
591
|
+
// in either recovery block above because a failure recovery repaired is
|
|
592
|
+
// not a failure (KTD2) — this branch is reached only by a setup that ran
|
|
593
|
+
// out of ways to fix itself. One report per run, whatever the tenant count.
|
|
594
|
+
//
|
|
595
|
+
// After the two `console.error`s so the actionable "rerun `impel setup`"
|
|
596
|
+
// stays the last thing a reader scrolls back to, and before
|
|
597
|
+
// `process.exitCode` because the report must not depend on the exit path.
|
|
598
|
+
//
|
|
599
|
+
// `reportInstallFailure` swallows its own errors, but it is an injection
|
|
600
|
+
// seam: this `catch` is the promise that whatever is behind it, a setup
|
|
601
|
+
// that failed with a clear message never becomes a setup that crashed.
|
|
602
|
+
try {
|
|
603
|
+
await io.reportInstallFailure({
|
|
604
|
+
failure: sharedFailure
|
|
605
|
+
? {
|
|
606
|
+
scope: "shared",
|
|
607
|
+
platform: io.platform,
|
|
608
|
+
architecture: process.arch,
|
|
609
|
+
step: "setup.shared_vendor_clis",
|
|
610
|
+
message: sharedFailure,
|
|
611
|
+
diagnostics: { tenantId: selected.id },
|
|
612
|
+
}
|
|
613
|
+
: {
|
|
614
|
+
// Attribute the report to a tenant that actually failed, not to
|
|
615
|
+
// the CLI default: `selected.id` is whichever tenant this machine
|
|
616
|
+
// points at, which may well have converged fine. With more than one
|
|
617
|
+
// failure there is no single owner, so the report is `shared` and
|
|
618
|
+
// the ids ride along in diagnostics — the message already names
|
|
619
|
+
// every one of them.
|
|
620
|
+
...(failedTenantIds.length === 1
|
|
621
|
+
? { scope: "tenant", tenantId: failedTenantIds[0] }
|
|
622
|
+
: { scope: "shared" }),
|
|
623
|
+
platform: io.platform,
|
|
624
|
+
architecture: process.arch,
|
|
625
|
+
step: "setup.tenant_convergence",
|
|
626
|
+
message: failedTenantMessage(report),
|
|
627
|
+
diagnostics: {
|
|
628
|
+
cliTenantId: selected.id,
|
|
629
|
+
failedTenantIds: failedTenantIds.join(","),
|
|
630
|
+
failedTenants: String(failedTenantIds.length),
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
config,
|
|
634
|
+
io,
|
|
635
|
+
});
|
|
636
|
+
} catch {
|
|
637
|
+
// R10: one error message, and it is the one above.
|
|
638
|
+
}
|
|
506
639
|
process.exitCode = 1;
|
|
507
640
|
return report;
|
|
508
641
|
}
|