impel-cli 0.20.45 → 0.20.46-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -8
- package/RELEASE_NOTES.md +59 -0
- package/docs/native-agent-host-capability-matrix.md +5 -5
- package/package.json +1 -1
- package/src/agents.js +4 -4
- package/src/apps.js +84 -36
- package/src/autoReport.js +289 -0
- package/src/bugReport.js +499 -0
- package/src/cli.js +61 -5
- package/src/codexSecurity.js +0 -18
- package/src/commands/apps.js +25 -11
- package/src/commands/auth.js +5 -1
- package/src/commands/converge.js +2 -1
- package/src/commands/cursorExperimental.js +74 -2
- package/src/commands/nuke.js +2 -2
- package/src/commands/report.js +309 -0
- package/src/commands/setup.js +134 -1
- package/src/commands/status.js +28 -3
- package/src/commands/update.js +82 -1
- package/src/cursorLocal.js +8 -0
- package/src/desktopTasks.js +420 -787
- package/src/doctor.js +17 -1
- package/src/exitCodes.js +61 -0
- package/src/featureFlags.js +393 -0
- package/src/managedProfileVersion.js +3 -1
- package/src/posthog.js +833 -0
- package/src/runtimeBrand.js +2 -2
- package/src/telemetryConsent.js +334 -0
- package/src/telemetryNotice.js +147 -0
- package/src/tenants.js +48 -0
- package/src/updates.js +26 -1
package/src/commands/apps.js
CHANGED
|
@@ -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 (
|
|
596
|
-
const result = await io.quitApps(
|
|
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(
|
|
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
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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 (
|
|
1062
|
-
const quitResult = await io.quitApps(
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/commands/auth.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/converge.js
CHANGED
|
@@ -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);
|
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
|
+
}
|