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.
@@ -592,11 +592,11 @@ export async function reconcileMacTenantApps({
592
592
  .filter((status) => completedTargets.includes(status.target) && !io.bundleCurrent(status))
593
593
  .map((status) => status.target);
594
594
  let closedApps = [];
595
- if (staleTargets.length) {
596
- const result = await io.quitApps(staleTargets, { tenantId: config.tenantId, tenantName: config.tenantName });
595
+ if (completedTargets.length) {
596
+ const result = await io.quitApps(completedTargets, { tenantId: config.tenantId, tenantName: config.tenantName });
597
597
  if (Array.isArray(result)) closedApps = result;
598
598
  }
599
- const reopenTargets = new Set(staleTargets.filter((target) => closedApps.includes(
599
+ const reopenTargets = new Set(completedTargets.filter((target) => closedApps.includes(
600
600
  managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
601
601
  )));
602
602
  const installed = completedTargets.length ? await io.installFiles({
@@ -1054,19 +1054,34 @@ export async function cmdApps(argv, overrides = {}) {
1054
1054
  ? [...actionTargets]
1055
1055
  : statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
1056
1056
 
1057
- // A running vendor or Impel app only needs to close when its bundle will be
1058
- // swapped. Remember which managed launchers we closed so the update can
1059
- // reopen them afterward and feel in-place (the fast path never quits at all).
1057
+ if (flags["skip-vendor"]) {
1058
+ const missingVendorTargets = statuses
1059
+ .filter((status) => staleBundleTargets.includes(status.target) && !status.vendorPath)
1060
+ .map((status) => status.target);
1061
+ if (missingVendorTargets.length > 0) {
1062
+ throw new Error(
1063
+ `${missingVendorTargets.join(", ")} verified vendor app is unavailable; rerun without --skip-vendor`,
1064
+ );
1065
+ }
1066
+ }
1067
+
1068
+ // Every explicit install/update rewrites the isolated model profile and may
1069
+ // refresh skills or native agents. Close a running managed app even when its
1070
+ // bundle is current so its long-lived client process cannot keep serving the
1071
+ // pre-update catalog. Remember only the managed launchers we actually closed
1072
+ // and reopen those after every profile sync finishes.
1060
1073
  let closedApps = [];
1061
- if (staleBundleTargets.length > 0) {
1062
- const quitResult = await io.quitApps(staleBundleTargets, {
1074
+ if (actionTargets.length > 0) {
1075
+ const quitResult = await io.quitApps(actionTargets, {
1063
1076
  tenantId: config.tenantId,
1064
1077
  tenantName: config.tenantName,
1078
+ homeDir: io.homeDir,
1079
+ bundleTargets: staleBundleTargets,
1065
1080
  });
1066
1081
  if (Array.isArray(quitResult)) closedApps = quitResult;
1067
1082
  }
1068
1083
  const reopenTargets = new Set(
1069
- staleBundleTargets.filter((target) => closedApps.includes(
1084
+ actionTargets.filter((target) => closedApps.includes(
1070
1085
  managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
1071
1086
  )),
1072
1087
  );
@@ -1118,8 +1133,7 @@ export async function cmdApps(argv, overrides = {}) {
1118
1133
  }));
1119
1134
  }
1120
1135
 
1121
- // Reopen the managed apps we had to close for the bundle swap, so the update
1122
- // lands in place from the user's point of view. Apps that were already
1136
+ // Reopen the managed apps we closed for the update. Apps that were already
1123
1137
  // closed (not in reopenTargets) stay closed.
1124
1138
  for (const item of installed) {
1125
1139
  if (!reopenTargets.has(item.target)) continue;
@@ -9,7 +9,7 @@ import {
9
9
  maskSecret,
10
10
  } from "../config.js";
11
11
  import { promptSecret } from "../prompt.js";
12
- import { fetchTenants, normalizeTenantId } from "../tenants.js";
12
+ import { applyCliUser, fetchTenants, normalizeTenantId } from "../tenants.js";
13
13
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
14
14
 
15
15
  export async function cmdAuth(argv) {
@@ -61,6 +61,7 @@ export async function cmdAuth(argv) {
61
61
  else delete config.productAccess;
62
62
  if (listing.scopes) config.scopes = listing.scopes;
63
63
  else delete config.scopes;
64
+ applyCliUser(config, listing);
64
65
  config.tenantsUpdatedAt = new Date().toISOString();
65
66
  } catch (error) {
66
67
  if (flags.tenant) throw error;
@@ -72,6 +73,9 @@ export async function cmdAuth(argv) {
72
73
  delete config.tenantName;
73
74
  delete config.productAccess;
74
75
  delete config.scopes;
76
+ // A different PAT may resolve to a different person; carrying the old
77
+ // identity forward is worse than having none.
78
+ delete config.user;
75
79
  }
76
80
  console.warn(`impel: tenant discovery deferred (${error.message})`);
77
81
  }
@@ -7,7 +7,7 @@ import {
7
7
  reconcileAllTenants,
8
8
  selectDefaultTenant,
9
9
  } from "../provisioning.js";
10
- import { fetchTenants } from "../tenants.js";
10
+ import { applyCliUser, fetchTenants } from "../tenants.js";
11
11
  import { promptText } from "../prompt.js";
12
12
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
13
13
  import { restoreNativeProfiles } from "./use.js";
@@ -72,6 +72,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
72
72
  config.tenantName = selected.name;
73
73
  if (listing.productAccess) config.productAccess = listing.productAccess;
74
74
  if (listing.scopes) config.scopes = listing.scopes;
75
+ applyCliUser(config, listing);
75
76
  config.tenantsUpdatedAt = new Date().toISOString();
76
77
  io.saveConfig(config);
77
78
  try {
@@ -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
+ }