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.
@@ -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
  }
@@ -10,10 +10,12 @@ import { findReviewedVendorCliBinary } from "../vendorCliBinaries.js";
10
10
  import { windowsTenantShortcutName } from "../shellEntries.js";
11
11
  import {
12
12
  ensureTenantSelection,
13
+ normalizeCliUser,
13
14
  PAT_SCOPE_CLAUDE,
14
15
  PAT_SCOPE_CODEX,
15
16
  productAccessLabel,
16
17
  } from "../tenants.js";
18
+ import { telemetryNoticeLine } from "../posthog.js";
17
19
  import { installedVersion, maybePrintUpdateNotice } from "../updates.js";
18
20
  import { findManagedWindowsChatGPTApp, windowsClaudeUserData } from "../windowsApps.js";
19
21
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
@@ -85,6 +87,24 @@ function cliState({ supported, available = true, profileReady, binaryReady }) {
85
87
  return profileReady && binaryReady ? "ready" : "missing";
86
88
  }
87
89
 
90
+ /**
91
+ * Everything `impel status` prints on its way out, from every exit.
92
+ *
93
+ * There are three: no PAT, an authentication check that did not come back, and
94
+ * the full report. The first two are precisely the states a broken install is
95
+ * in — a notice wired only to the tail would be invisible exactly when someone
96
+ * runs `status` to find out what is wrong.
97
+ *
98
+ * `doctor` deliberately does not carry this. It runs synthetic, billable
99
+ * end-to-end gateway checks; a local drop notice should not cost a request.
100
+ */
101
+ function printLocalNotices(io) {
102
+ if (RUNTIME_BRAND.cli.packageName !== "impel-cli") return;
103
+ const telemetry = io.telemetryNoticeLine();
104
+ if (telemetry) console.log(telemetry);
105
+ io.maybePrintUpdateNotice();
106
+ }
107
+
88
108
  export async function cmdStatus(overrides = {}) {
89
109
  const io = {
90
110
  loadConfig,
@@ -94,6 +114,7 @@ export async function cmdStatus(overrides = {}) {
94
114
  installedVersion,
95
115
  findNativeBinary: findReviewedVendorCliBinary,
96
116
  maybePrintUpdateNotice,
117
+ telemetryNoticeLine,
97
118
  platform: process.platform,
98
119
  environment: process.env,
99
120
  ...overrides,
@@ -102,8 +123,12 @@ export async function cmdStatus(overrides = {}) {
102
123
  const version = process.env.IMPEL_CLI_EXTENSION_VERSION || io.installedVersion() || "?";
103
124
  console.log(`${RUNTIME_BRAND.cli.command}-cli: v${version}`);
104
125
  console.log(`Authentication: ${config?.pat ? `configured (${maskSecret(config.pat)})` : "not configured - run `impel setup`"}`);
126
+ // Read back through the same normalizer that wrote it: config.json is a file
127
+ // on disk, so what it holds is untrusted input by the time it is printed.
128
+ const user = normalizeCliUser(config?.user, { allowMissing: true });
129
+ if (user) console.log(`Signed in as: ${user.displayName || user.id}`);
105
130
  if (!config?.pat) {
106
- if (RUNTIME_BRAND.cli.packageName === "impel-cli") io.maybePrintUpdateNotice();
131
+ printLocalNotices(io);
107
132
  return;
108
133
  }
109
134
 
@@ -117,7 +142,7 @@ export async function cmdStatus(overrides = {}) {
117
142
  console.log(RUNTIME_BRAND.tenant.defaultId
118
143
  ? `Run \`${RUNTIME_BRAND.cli.command} setup\` to refresh authentication and repair the local tenant.`
119
144
  : "Run `impel setup` to refresh authentication, then `impel update` to repair local tenants.");
120
- if (RUNTIME_BRAND.cli.packageName === "impel-cli") io.maybePrintUpdateNotice();
145
+ printLocalNotices(io);
121
146
  return;
122
147
  }
123
148
 
@@ -162,5 +187,5 @@ export async function cmdStatus(overrides = {}) {
162
187
  ? `Repair or finish missing tenant surfaces with: ${RUNTIME_BRAND.cli.command} setup`
163
188
  : "Repair or finish missing tenant surfaces with: impel update");
164
189
  }
165
- if (RUNTIME_BRAND.cli.packageName === "impel-cli") io.maybePrintUpdateNotice();
190
+ printLocalNotices(io);
166
191
  }
@@ -10,6 +10,7 @@ import { loadConfig, redactSecretText } from "../config.js";
10
10
  import { nativeCommandInvocation } from "../nativeProcess.js";
11
11
  import { withProgress } from "../progress.js";
12
12
  import { runInstallRecovery } from "../installRecovery/engine.js";
13
+ import { reportInstallFailure } from "../autoReport.js";
13
14
  import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
14
15
  import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
15
16
  import {
@@ -252,6 +253,7 @@ export async function cmdUpdate(argv, overrides = {}) {
252
253
  environment: process.env,
253
254
  progress: withProgress,
254
255
  recoverInstall: runInstallRecovery,
256
+ reportInstallFailure,
255
257
  postInstallVersion: postInstallCliVersion,
256
258
  postInstallRunnable: postInstallCliRunnable,
257
259
  resolveFreshInstall: resolveFreshGlobalEntrypoint,
@@ -315,6 +317,36 @@ export async function cmdUpdate(argv, overrides = {}) {
315
317
  return;
316
318
  }
317
319
 
320
+ // Read at most once, and only when something actually needs it. All three
321
+ // terminal failures do, which is why this is not still buried in the npm
322
+ // branch — but an `impel update` that succeeds must not pay a config read it
323
+ // has no use for, and this whole feature is supposed to cost the success
324
+ // path nothing.
325
+ let configLoaded = false;
326
+ let configValue;
327
+ const currentConfig = () => {
328
+ if (!configLoaded) {
329
+ configValue = io.loadConfig();
330
+ configLoaded = true;
331
+ }
332
+ return configValue;
333
+ };
334
+
335
+ /**
336
+ * File one automatic report for a terminal `impel update` failure.
337
+ *
338
+ * Each caller has already printed the message the user needs to act on, so
339
+ * this must not add a second one: `reportInstallFailure` swallows its own
340
+ * errors and the `catch` covers whatever an injected reporter does (R10).
341
+ */
342
+ const reportUpdateFailure = async (failure) => {
343
+ try {
344
+ await io.reportInstallFailure({ failure, config: currentConfig(), io });
345
+ } catch {
346
+ // R10: one error message, and it is the one the caller already printed.
347
+ }
348
+ };
349
+
318
350
  // ── CLI ────────────────────────────────────────────────────────────────
319
351
  let cascadeEntrypoint = null;
320
352
  if (!remote) {
@@ -340,8 +372,8 @@ export async function cmdUpdate(argv, overrides = {}) {
340
372
  console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
341
373
  console.error(` Manual recovery: \`npm install --global ${installSpec}\`.`);
342
374
  }
343
- const config = io.loadConfig();
344
375
  let recovered = false;
376
+ const config = currentConfig();
345
377
  if (config?.pat && config?.tenantId) {
346
378
  // The goal passes on the most recent reviewed retry of the npm
347
379
  // install OR on a live disk probe of the package that owns the
@@ -399,6 +431,19 @@ export async function cmdUpdate(argv, overrides = {}) {
399
431
  }
400
432
  }
401
433
  if (!recovered) {
434
+ // Past recovery, so this is a failure that ran out of fixes (KTD2).
435
+ await reportUpdateFailure({
436
+ scope: "shared",
437
+ platform: io.platform,
438
+ architecture: process.arch,
439
+ step: "install.impel_cli",
440
+ command: `npm install --global ${installSpec}`,
441
+ message: `The npm-verified global ${RUNTIME_BRAND.cli.packageName} update failed.`,
442
+ diagnostics: {
443
+ installedVersion: String(current ?? "unknown"),
444
+ remoteVersion: String(remote ?? "unknown"),
445
+ },
446
+ });
402
447
  process.exitCode = 1;
403
448
  return;
404
449
  }
@@ -426,6 +471,22 @@ export async function cmdUpdate(argv, overrides = {}) {
426
471
  console.error(` Running CLI: ${CLI_BIN}`);
427
472
  console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
428
473
  console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
474
+ // Its own step, not `install.impel_cli`: npm succeeded and the update
475
+ // landed somewhere real. The reportable fact is the prefix skew, and
476
+ // the two versions below are what makes a report of it actionable.
477
+ await reportUpdateFailure({
478
+ scope: "shared",
479
+ platform: io.platform,
480
+ architecture: process.arch,
481
+ step: "install.impel_cli_prefix_skew",
482
+ command: `npm install --global ${installSpec}`,
483
+ message: `npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}), and the freshly installed build could not be located.`,
484
+ diagnostics: {
485
+ runningVersion: String(postInstall ?? "unknown"),
486
+ expectedVersion: String(remote),
487
+ cliBin: CLI_BIN,
488
+ },
489
+ });
429
490
  process.exitCode = 1;
430
491
  return;
431
492
  }
@@ -449,6 +510,26 @@ export async function cmdUpdate(argv, overrides = {}) {
449
510
  };
450
511
  if (!await io.runConvergence(convergenceArgs)) {
451
512
  console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
513
+ // The child is `impel _converge`, which files no report of its own — and
514
+ // this parent only sees its exit status, because the child inherits stdio
515
+ // so its progress reaches the terminal live. So this report carries what
516
+ // the parent does know: that it was an update cascade, and which flags
517
+ // shaped the run it failed under. Which tenants failed is visible on the
518
+ // user's screen but not in the report; closing that gap needs a channel
519
+ // between the two processes, which R2 (one report per invocation) says must
520
+ // not simply be a second report from the child.
521
+ await reportUpdateFailure({
522
+ scope: "shared",
523
+ platform: io.platform,
524
+ architecture: process.arch,
525
+ step: "update.tenant_convergence",
526
+ message: "Tenant convergence failed during `impel update`.",
527
+ diagnostics: {
528
+ skipApps: String(Boolean(flags["skip-apps"])),
529
+ skipClis: String(Boolean(flags["skip-clis"])),
530
+ cascadedEntrypoint: String(Boolean(cascadeEntrypoint)),
531
+ },
532
+ });
452
533
  process.exitCode = 1;
453
534
  return;
454
535
  }
@@ -1473,6 +1473,14 @@ export function cursorLaunchSpec({
1473
1473
  }
1474
1474
  const env = { ...environment };
1475
1475
  for (const key of [...DIRECT_PROVIDER_ENV_KEYS, ...RUNTIME_OVERRIDE_ENV_KEYS]) delete env[key];
1476
+ // The positive managed marker `telemetryConsent.js` denies on. It has to be
1477
+ // stamped here rather than inferred: the delete loop above removes
1478
+ // CLAUDE_CONFIG_DIR and CODEX_HOME, and `HOME` below points at the managed
1479
+ // profile, so every path the guard could otherwise key on either disappears
1480
+ // or stops looking managed. Without it a terminal inside managed Cursor —
1481
+ // holding CURSOR_LOCAL_AGENT_API_KEY — would capture and flush telemetry to a
1482
+ // host other than the gateway, which is the P1-8 egress boundary.
1483
+ env[brandedEnvironmentName("MANAGED_CURSOR")] = "1";
1476
1484
  env.HOME = paths.home;
1477
1485
  env.XDG_CACHE_HOME = path.join(paths.home, ".cache");
1478
1486
  env.XDG_CONFIG_HOME = path.join(paths.home, ".config");