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.
@@ -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);
@@ -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 auth: ${io.configDir}`);
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 auth).`);
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
+ }
@@ -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
  }