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/runtimeBrand.js
CHANGED
|
@@ -10,8 +10,8 @@ const SAFE_BUNDLE_PREFIX = /^[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)+$/u;
|
|
|
10
10
|
const CONTROL_RE = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
11
11
|
const SUPPORTED_COMMANDS = new Set([
|
|
12
12
|
"setup", "auth", "pat", "token", "mcp", "sessions", "claude", "codex",
|
|
13
|
-
"status", "doctor", "tasks", "tenant", "app", "nuke", "skills",
|
|
14
|
-
"update", "use", "experimental",
|
|
13
|
+
"status", "doctor", "report", "tasks", "tenant", "app", "nuke", "skills",
|
|
14
|
+
"agents", "update", "use", "experimental",
|
|
15
15
|
]);
|
|
16
16
|
|
|
17
17
|
const DEFAULT = Object.freeze({
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// The single authority for three questions the telemetry paths must never
|
|
2
|
+
// decide for themselves:
|
|
3
|
+
//
|
|
4
|
+
// 1. May telemetry run *at all* in this process? — `telemetryCaptureAllowed`
|
|
5
|
+
// and `telemetryFlushAllowed`. A context question, answered by where the
|
|
6
|
+
// process is running, never by what the user consented to.
|
|
7
|
+
// 2. May analytics be sent? — `analyticsConsentGranted`. A consent question.
|
|
8
|
+
// 3. May a failure report be sent? — `reportEnvOptOut`. A suppression
|
|
9
|
+
// question, and deliberately a narrower env surface than the second: only
|
|
10
|
+
// the two branded names silence reports. Bare `CI` and bare `DO_NOT_TRACK`
|
|
11
|
+
// keep suppressing analytics and keep *not* suppressing reports, because a
|
|
12
|
+
// machine that never asked to stop reporting its own broken installs is
|
|
13
|
+
// the machine whose broken installs are hardest to hear about. The two
|
|
14
|
+
// predicates therefore stay separate rather than one widening into the
|
|
15
|
+
// other.
|
|
16
|
+
//
|
|
17
|
+
// The context question is the security boundary. A managed vendor app profile
|
|
18
|
+
// is only ever allowed to reach the gateway, so telemetry initializing inside
|
|
19
|
+
// one puts an unexpected egress call in a profile carrying app credentials —
|
|
20
|
+
// it presents as a PAT leak and fails the managed-app launch smoke. Detecting
|
|
21
|
+
// "this is managed" is unbounded (every new vendor surface is a new marker);
|
|
22
|
+
// detecting "a human typed `impel <command>`" is bounded. So capture is a
|
|
23
|
+
// positive allowlist over interactive commands, and the marker list denies on
|
|
24
|
+
// top of it.
|
|
25
|
+
//
|
|
26
|
+
// Flag reads and `impel report` deliberately bypass consent — a kill switch
|
|
27
|
+
// that respects an opt-out is not a kill switch, and a report is the user
|
|
28
|
+
// pressing send — but nothing bypasses the context guard.
|
|
29
|
+
//
|
|
30
|
+
// Every predicate reads its environment from an injected bag rather than
|
|
31
|
+
// `process.env` so the per-surface fixture table in
|
|
32
|
+
// `test/telemetry-consent.test.js` can assert each managed surface denies
|
|
33
|
+
// without spawning that surface.
|
|
34
|
+
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import os from "node:os";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
|
|
39
|
+
import { environmentValue } from "./nativeProcess.js";
|
|
40
|
+
import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
41
|
+
|
|
42
|
+
/** The detached sender. Hidden: never added to `SUPPORTED_COMMANDS`. */
|
|
43
|
+
export const TELEMETRY_FLUSH_COMMAND = "_telemetry";
|
|
44
|
+
|
|
45
|
+
const DISABLE_TELEMETRY_ENV = brandedEnvironmentName("DISABLE_TELEMETRY");
|
|
46
|
+
export const DO_NOT_TRACK_ENV = brandedEnvironmentName("DO_NOT_TRACK");
|
|
47
|
+
/** Exported alongside `DO_NOT_TRACK_ENV` so help text can name both switches. */
|
|
48
|
+
export const CI_ENV = brandedEnvironmentName("CI");
|
|
49
|
+
const ACCEPTED_TRUE = ["1", "true", "yes"];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Env names whose mere presence means this process is inside a managed
|
|
53
|
+
* surface. `IMPEL_DT` and the desktop-tasks group are written as literals by
|
|
54
|
+
* the launchers in `src/apps.js` and `src/desktopTasks.js`, so they are matched
|
|
55
|
+
* as literals here; the managed-MCP and managed-Cursor markers are branded at
|
|
56
|
+
* their writers, and both spellings are listed so a rebrand cannot open a hole.
|
|
57
|
+
*
|
|
58
|
+
* Managed Cursor is a presence marker rather than a profile path because
|
|
59
|
+
* `cursorLaunchSpec` deletes the vendor profile vars and repoints `HOME` at the
|
|
60
|
+
* managed root — there is no path left to key on.
|
|
61
|
+
*/
|
|
62
|
+
const MANAGED_PRESENCE_ENV_NAMES = Object.freeze([
|
|
63
|
+
brandedEnvironmentName("MANAGED_MCP"),
|
|
64
|
+
"IMPEL_MANAGED_MCP",
|
|
65
|
+
brandedEnvironmentName("MANAGED_CURSOR"),
|
|
66
|
+
"IMPEL_MANAGED_CURSOR",
|
|
67
|
+
"IMPEL_DT",
|
|
68
|
+
"IMPEL_DESKTOP_TASKS_TENANT",
|
|
69
|
+
"IMPEL_DESKTOP_TASKS_CLI",
|
|
70
|
+
"IMPEL_DESKTOP_TASKS_NODE",
|
|
71
|
+
"IMPEL_DESKTOP_TASKS_HOST_HTML",
|
|
72
|
+
"IMPEL_DESKTOP_TASKS_VIEW_PRELOAD",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Env names carrying a profile directory. These are managed only when the path
|
|
77
|
+
* lands under one of `managedProfileRoots`; a user's own `~/.claude` is not a
|
|
78
|
+
* managed surface and must not suppress their telemetry.
|
|
79
|
+
*/
|
|
80
|
+
const MANAGED_PROFILE_ENV_NAMES = Object.freeze([
|
|
81
|
+
"CLAUDE_CONFIG_DIR",
|
|
82
|
+
"CLAUDE_USER_DATA_DIR",
|
|
83
|
+
"CODEX_HOME",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Commands that are not a human at a terminal, and so never capture. The three
|
|
88
|
+
* hidden ones are already absent from `RUNTIME_BRAND.capabilities.commands`;
|
|
89
|
+
* naming them anyway means adding one to that list by mistake cannot silently
|
|
90
|
+
* open capture on a hidden surface.
|
|
91
|
+
*/
|
|
92
|
+
const NON_INTERACTIVE_COMMANDS = Object.freeze(new Set([
|
|
93
|
+
"sessions",
|
|
94
|
+
"mcp",
|
|
95
|
+
"token",
|
|
96
|
+
"_converge",
|
|
97
|
+
"_app-launch",
|
|
98
|
+
TELEMETRY_FLUSH_COMMAND,
|
|
99
|
+
]));
|
|
100
|
+
|
|
101
|
+
function nonEmpty(value) {
|
|
102
|
+
return typeof value === "string" && value.length > 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve symlinks through the longest ancestor that exists, then re-append the
|
|
107
|
+
* rest.
|
|
108
|
+
*
|
|
109
|
+
* Plain `realpathSync` throws on a path that does not exist yet, and a managed
|
|
110
|
+
* profile directory is routinely created *after* this guard runs. Falling back
|
|
111
|
+
* to the literal string in that case would compare the two sides asymmetrically:
|
|
112
|
+
* on macOS an existing root under `/tmp` resolves to `/private/tmp` while the
|
|
113
|
+
* not-yet-created profile beneath it stays `/tmp/...`, and the containment check
|
|
114
|
+
* would miss — the one direction that leaks. Walking up to the nearest real
|
|
115
|
+
* ancestor gives both sides the same treatment.
|
|
116
|
+
*
|
|
117
|
+
* Only paths this host would call absolute are resolved. A Windows path on a
|
|
118
|
+
* POSIX host has no existing ancestor to find, and `dirname` would collapse it
|
|
119
|
+
* to `.` and splice in the working directory.
|
|
120
|
+
*
|
|
121
|
+
* Absoluteness is `path.isAbsolute`, not a `/` prefix: on Windows every native
|
|
122
|
+
* path starts with a drive letter or a backslash, so a `/`-prefix test skips
|
|
123
|
+
* resolution for exactly the paths Windows would have resolved. That reopens
|
|
124
|
+
* the symlink escape this function exists to close, and — worse — leaves a
|
|
125
|
+
* `/tmp/x`-style candidate resolved against the current drive while the
|
|
126
|
+
* `\tmp\x` root beside it stays literal, which is the asymmetry described
|
|
127
|
+
* above. `path.isAbsolute` agrees with the old test on every POSIX path.
|
|
128
|
+
*/
|
|
129
|
+
function realPathThroughExisting(value) {
|
|
130
|
+
if (!path.isAbsolute(value)) return value;
|
|
131
|
+
const trailing = [];
|
|
132
|
+
let current = value;
|
|
133
|
+
for (;;) {
|
|
134
|
+
try {
|
|
135
|
+
return path.join(fs.realpathSync(current), ...trailing);
|
|
136
|
+
} catch {
|
|
137
|
+
const parent = path.dirname(current);
|
|
138
|
+
// `dirname("/")` is `"/"`; every absolute path terminates here at worst.
|
|
139
|
+
if (parent === current) return value;
|
|
140
|
+
trailing.unshift(path.basename(current));
|
|
141
|
+
current = parent;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Normalize for containment comparison: resolve symlinks so a profile path
|
|
148
|
+
* pointed into a managed root cannot escape the check, then fold separators and
|
|
149
|
+
* case. Folding case over-matches on a case-sensitive filesystem holding two
|
|
150
|
+
* directories that differ only by case — the safe direction for a predicate
|
|
151
|
+
* whose false answer is the one that leaks.
|
|
152
|
+
*/
|
|
153
|
+
function normalizedPath(value) {
|
|
154
|
+
return realPathThroughExisting(String(value))
|
|
155
|
+
.replace(/[\\/]+/gu, "/")
|
|
156
|
+
.replace(/\/+$/u, "")
|
|
157
|
+
.toLowerCase();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pathWithin(candidate, root) {
|
|
161
|
+
const child = normalizedPath(candidate);
|
|
162
|
+
const parent = normalizedPath(root);
|
|
163
|
+
if (!child || !parent) return false;
|
|
164
|
+
return child === parent || child.startsWith(`${parent}/`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The roots under which a profile path means "managed".
|
|
169
|
+
*
|
|
170
|
+
* Deliberately derived here rather than imported from `apps.js` /
|
|
171
|
+
* `cliProfiles.js`: this guard sits on the process-start path and must stay a
|
|
172
|
+
* leaf module, and both of those roots are otherwise frozen at module load,
|
|
173
|
+
* which an injected `homeDir` could never move. `test/telemetry-consent.test.js`
|
|
174
|
+
* pins each derivation against its real producer so the duplication cannot
|
|
175
|
+
* drift.
|
|
176
|
+
*
|
|
177
|
+
* The two bases differ on purpose, matching the code that creates them:
|
|
178
|
+
* `appPaths` builds the apps root from `homeDir` directly (ignoring
|
|
179
|
+
* `IMPEL_CONFIG_DIR`), while `IMPEL_CLI_PROFILES_DIR` builds from `CONFIG_DIR`.
|
|
180
|
+
*/
|
|
181
|
+
function managedProfileRoots(env, homeDir) {
|
|
182
|
+
const configDir = environmentValue(env, brandedEnvironmentName("CONFIG_DIR"))
|
|
183
|
+
|| path.join(homeDir, ".config", RUNTIME_BRAND.cli.configNamespace);
|
|
184
|
+
const localAppData = environmentValue(env, "LOCALAPPDATA")
|
|
185
|
+
|| path.win32.join(environmentValue(env, "USERPROFILE") || homeDir, "AppData", "Local");
|
|
186
|
+
return [
|
|
187
|
+
// Managed vendor desktop apps (`appPaths(homeDir).root`).
|
|
188
|
+
environmentValue(env, brandedEnvironmentName("APP_HOME"))
|
|
189
|
+
|| path.join(homeDir, ".config", RUNTIME_BRAND.cli.configNamespace, "apps"),
|
|
190
|
+
// Windows Claude keeps its profile outside the config dir entirely
|
|
191
|
+
// (`windowsClaudeUserData`). Denying the whole `Claude-3p` tree rather than
|
|
192
|
+
// just the Impel folder keeps a future tenant layout from slipping past.
|
|
193
|
+
path.win32.join(localAppData, "Claude-3p"),
|
|
194
|
+
// Isolated CLI profiles (`IMPEL_CLI_PROFILES_DIR`).
|
|
195
|
+
path.join(configDir, "cli"),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* True when this process is running inside an Impel-managed surface: a vendor
|
|
201
|
+
* desktop app, the desktop-tasks host, a managed MCP child, or an isolated CLI
|
|
202
|
+
* profile. Both context predicates consult exactly this function, so a new
|
|
203
|
+
* marker can never be added to only one of them.
|
|
204
|
+
*/
|
|
205
|
+
export function managedMarkerPresent(environment = process.env, homeDir = os.homedir()) {
|
|
206
|
+
const env = environment || {};
|
|
207
|
+
for (const name of MANAGED_PRESENCE_ENV_NAMES) {
|
|
208
|
+
// Every writer sets these to "1" or a path, but a deny gate must not be
|
|
209
|
+
// defeated by a different truthy spelling.
|
|
210
|
+
if (nonEmpty(environmentValue(env, name))) return true;
|
|
211
|
+
}
|
|
212
|
+
const roots = managedProfileRoots(env, homeDir);
|
|
213
|
+
for (const name of MANAGED_PROFILE_ENV_NAMES) {
|
|
214
|
+
const value = environmentValue(env, name);
|
|
215
|
+
if (nonEmpty(value) && roots.some((root) => pathWithin(value, root))) return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The public command a human invoked, or null when this is not an interactive
|
|
222
|
+
* foreground run. Aliases (`task`, `org`, `apps`, …) are not in the brand's
|
|
223
|
+
* command list and so read as null: an alias under-counts one event, which is
|
|
224
|
+
* the correct direction for a fail-closed gate and cheaper than duplicating
|
|
225
|
+
* `src/cli.js`'s alias table here.
|
|
226
|
+
*/
|
|
227
|
+
function interactiveCommand(argv) {
|
|
228
|
+
const command = Array.isArray(argv) ? argv[0] : undefined;
|
|
229
|
+
if (typeof command !== "string" || NON_INTERACTIVE_COMMANDS.has(command)) return null;
|
|
230
|
+
return RUNTIME_BRAND.capabilities.commands.includes(command) ? command : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A rebranded fork runs its own product; it must not report into Impel's
|
|
235
|
+
* project. This is the same first-party check `maybePrintUpdateNotice` and the
|
|
236
|
+
* stable-entrypoint work use.
|
|
237
|
+
*
|
|
238
|
+
* Exported because automatic failure reporting needs the identical answer: a
|
|
239
|
+
* fork's broken install is the fork's problem to hear about, and a phone-home
|
|
240
|
+
* that survives a rebrand is the one bug nobody downstream can see.
|
|
241
|
+
*/
|
|
242
|
+
export function firstPartyCli() {
|
|
243
|
+
return RUNTIME_BRAND.cli.packageName === "impel-cli";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** May this process *produce* telemetry events? */
|
|
247
|
+
export function telemetryCaptureAllowed(
|
|
248
|
+
environment = process.env,
|
|
249
|
+
argv = process.argv.slice(2),
|
|
250
|
+
homeDir = os.homedir(),
|
|
251
|
+
) {
|
|
252
|
+
if (!firstPartyCli()) return false;
|
|
253
|
+
if (interactiveCommand(argv) === null) return false;
|
|
254
|
+
return !managedMarkerPresent(environment, homeDir);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* May this process *send* a queued batch?
|
|
259
|
+
*
|
|
260
|
+
* Split from capture because the sender is not an interactive command: keying
|
|
261
|
+
* one predicate on interactivity would deny `_telemetry` by name and no batch
|
|
262
|
+
* would ever leave, while admitting `_telemetry` into the capture predicate
|
|
263
|
+
* would reopen capture on a hidden surface. The managed-marker denial is
|
|
264
|
+
* identical on both sides.
|
|
265
|
+
*/
|
|
266
|
+
export function telemetryFlushAllowed(
|
|
267
|
+
environment = process.env,
|
|
268
|
+
argv = process.argv.slice(2),
|
|
269
|
+
homeDir = os.homedir(),
|
|
270
|
+
) {
|
|
271
|
+
if (!firstPartyCli()) return false;
|
|
272
|
+
const command = Array.isArray(argv) ? argv[0] : undefined;
|
|
273
|
+
if (command !== TELEMETRY_FLUSH_COMMAND && interactiveCommand(argv) === null) return false;
|
|
274
|
+
return !managedMarkerPresent(environment, homeDir);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* True when the environment alone says no, whatever the config records.
|
|
279
|
+
*
|
|
280
|
+
* Separate from the config check because three surfaces need this answer for
|
|
281
|
+
* different reasons and must agree: sending consults it, the setup prompt skips
|
|
282
|
+
* asking a machine that has already refused, and the first-run notice stays
|
|
283
|
+
* quiet rather than announcing a feature that is already off here.
|
|
284
|
+
*/
|
|
285
|
+
export function analyticsEnvOptOut(environment = process.env) {
|
|
286
|
+
const env = environment || {};
|
|
287
|
+
const disabled = String(environmentValue(env, DISABLE_TELEMETRY_ENV) || "").toLowerCase();
|
|
288
|
+
if (ACCEPTED_TRUE.includes(disabled)) return true;
|
|
289
|
+
// consumer spec: exactly "1" is the opt-out signal.
|
|
290
|
+
if (String(environmentValue(env, "DO_NOT_TRACK") || "") === "1") return true;
|
|
291
|
+
// Any non-empty CI value suppresses, including "0" and "false" — a CI system
|
|
292
|
+
// that sets CI at all is a CI system. Under-collecting in CI costs a metric;
|
|
293
|
+
// over-collecting costs trust.
|
|
294
|
+
if (nonEmpty(environmentValue(env, "CI"))) return true;
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* True when this machine has asked not to send failure reports.
|
|
300
|
+
*
|
|
301
|
+
* Only the two branded names are read. `analyticsEnvOptOut` above additionally
|
|
302
|
+
* honors bare `DO_NOT_TRACK=1` and any non-empty `CI`, and must keep doing so —
|
|
303
|
+
* but neither is a statement about failure reports. A CI system that sets `CI`
|
|
304
|
+
* has said nothing about whether its operators want to hear that `impel setup`
|
|
305
|
+
* is broken there, and silently deciding for them is how a broken install stays
|
|
306
|
+
* broken. Suppressing reports takes the branded name, typed on purpose.
|
|
307
|
+
*
|
|
308
|
+
* Consequence, accepted rather than papered over: an unmodified CI runner sets
|
|
309
|
+
* `CI`, not `IMPEL_CI`, so a repeatedly failing `impel setup` in CI reports
|
|
310
|
+
* every run until someone sets the branded name. That is the cost of the same
|
|
311
|
+
* rule that makes the signal trustworthy everywhere else.
|
|
312
|
+
*/
|
|
313
|
+
export function reportEnvOptOut(environment = process.env) {
|
|
314
|
+
const env = environment || {};
|
|
315
|
+
for (const name of [DO_NOT_TRACK_ENV, CI_ENV]) {
|
|
316
|
+
const value = String(environmentValue(env, name) || "").toLowerCase();
|
|
317
|
+
if (ACCEPTED_TRUE.includes(value)) return true;
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Has the user consented to analytics? Opt-in only: an absent or non-boolean
|
|
324
|
+
* `telemetry.enabled` is a no, and each env opt-out independently overrides a
|
|
325
|
+
* stored yes.
|
|
326
|
+
*
|
|
327
|
+
* This answers consent alone — it does not consult the managed-profile guard.
|
|
328
|
+
* Callers AND the two, so consent can never talk a managed surface into
|
|
329
|
+
* sending.
|
|
330
|
+
*/
|
|
331
|
+
export function analyticsConsentGranted(config, environment = process.env) {
|
|
332
|
+
if (config?.telemetry?.enabled !== true) return false;
|
|
333
|
+
return !analyticsEnvOptOut(environment);
|
|
334
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The one-time "this CLI can now send product analytics" notice.
|
|
2
|
+
//
|
|
3
|
+
// Nothing asks. Analytics are opt-in through `impel setup --analytics on`, and
|
|
4
|
+
// a flag nobody knows about is not much of an opt-in — so this notice is the
|
|
5
|
+
// sole disclosure path, for new installs and old ones alike. Without it the
|
|
6
|
+
// first someone would hear of telemetry is a changelog they did not read.
|
|
7
|
+
// Analytics stay off either way — consent is opt-in — but "off by default and
|
|
8
|
+
// we told you" and "off by default and we said nothing" are different products.
|
|
9
|
+
//
|
|
10
|
+
// Two rules shape everything here.
|
|
11
|
+
//
|
|
12
|
+
// **Printing is not consent.** The shown-marker is a separate file from
|
|
13
|
+
// `config.telemetry.enabled` on purpose: no sequence of prints can add up to an
|
|
14
|
+
// opt-in, and no reader of the consent key has to know this file exists.
|
|
15
|
+
//
|
|
16
|
+
// **At most once.** The marker is written *before* the notice prints, and a
|
|
17
|
+
// marker that cannot be persisted suppresses the notice entirely. A config
|
|
18
|
+
// directory this CLI cannot write is a directory where "once" cannot be
|
|
19
|
+
// promised, and a nag on every command is worse than silence — nothing is being
|
|
20
|
+
// collected in the meantime regardless.
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import { CONFIG_DIR, loadConfig } from "./config.js";
|
|
27
|
+
import { RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
28
|
+
import {
|
|
29
|
+
analyticsEnvOptOut,
|
|
30
|
+
telemetryCaptureAllowed,
|
|
31
|
+
} from "./telemetryConsent.js";
|
|
32
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Beside `update-check.json` and `flags.json` rather than inside the telemetry
|
|
36
|
+
* outbox directory: the outbox is redirectable per-process
|
|
37
|
+
* (`IMPEL_TELEMETRY_STATE_DIR`) and is erased when a batch drains, and neither
|
|
38
|
+
* is true of a fact about the person using this machine. Still under
|
|
39
|
+
* `CONFIG_DIR`, so `impel nuke` takes it with everything else.
|
|
40
|
+
*/
|
|
41
|
+
const TELEMETRY_NOTICE_FILE = "telemetry-notice.json";
|
|
42
|
+
|
|
43
|
+
export const TELEMETRY_NOTICE_PATH = path.join(CONFIG_DIR, TELEMETRY_NOTICE_FILE);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every entry point below takes `configDir` for the same reason they take
|
|
47
|
+
* `homeDir` and `stream`: `CONFIG_DIR` is frozen when `src/config.js` is first
|
|
48
|
+
* imported, so a caller that wants this marker somewhere else — a test, or any
|
|
49
|
+
* future caller reasoning about a directory other than its own — cannot get
|
|
50
|
+
* there by moving the environment afterwards. The default is the frozen path,
|
|
51
|
+
* which is what production always wants.
|
|
52
|
+
*/
|
|
53
|
+
function noticePath(configDir) {
|
|
54
|
+
return path.join(configDir, TELEMETRY_NOTICE_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function telemetryNoticeText() {
|
|
58
|
+
const command = RUNTIME_BRAND.cli.command;
|
|
59
|
+
return [
|
|
60
|
+
`${RUNTIME_BRAND.product.displayName} can now report anonymous CLI usage — which command ran, whether`,
|
|
61
|
+
"it succeeded, how long it took as a coarse bucket, the CLI version, release",
|
|
62
|
+
"channel, and platform. Never arguments, file contents, prompts, or session data.",
|
|
63
|
+
`It is off. Turn it on with \`${command} setup --analytics on\`, or keep it off`,
|
|
64
|
+
`permanently with \`IMPEL_DISABLE_TELEMETRY=1\`, \`DO_NOT_TRACK=1\`, or any \`CI\` value.`,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readMarker(configDir) {
|
|
69
|
+
try {
|
|
70
|
+
const marker = JSON.parse(fs.readFileSync(noticePath(configDir), "utf8"));
|
|
71
|
+
return marker && typeof marker === "object" ? marker : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True once the notice has been printed on this machine. */
|
|
78
|
+
export function telemetryNoticeShown({ configDir = CONFIG_DIR } = {}) {
|
|
79
|
+
return typeof readMarker(configDir)?.shownAt === "string";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Persist the shown-marker. Returns false when it could not be written, which
|
|
84
|
+
* both callers read as "do not print".
|
|
85
|
+
*
|
|
86
|
+
* `impel setup` calls this too, on every path including the silent ones: having
|
|
87
|
+
* run setup at all is the event the first-run notice exists to announce, so
|
|
88
|
+
* someone who has just been through it is not told again on their next command.
|
|
89
|
+
*/
|
|
90
|
+
export function markTelemetryNoticeShown({ now = Date.now(), configDir = CONFIG_DIR } = {}) {
|
|
91
|
+
const contents = `${JSON.stringify({ shownAt: new Date(now).toISOString() }, null, 2)}\n`;
|
|
92
|
+
const markerPath = noticePath(configDir);
|
|
93
|
+
const temporaryPath = `${markerPath}.tmp-${process.pid}`;
|
|
94
|
+
try {
|
|
95
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
96
|
+
fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
|
|
97
|
+
renameWithWindowsRetry(temporaryPath, markerPath);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
} finally {
|
|
102
|
+
try {
|
|
103
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
104
|
+
} catch {
|
|
105
|
+
// A successful rename already removed it; cleanup must not mask the write.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Print the notice at most once, or do nothing.
|
|
112
|
+
*
|
|
113
|
+
* Called from `main` before dispatch rather than from `maybePrintUpdateNotice`:
|
|
114
|
+
* that helper is wired into `status` and the app commands only, so the people
|
|
115
|
+
* who live in `impel claude` and `impel codex` — the ones this notice is for —
|
|
116
|
+
* would never see it. The same reasoning rejected it as the flush hook.
|
|
117
|
+
*
|
|
118
|
+
* `telemetryCaptureAllowed` is the gate because it already answers the exact
|
|
119
|
+
* question: first-party CLI, interactive foreground command, no managed-surface
|
|
120
|
+
* marker. Anything that cannot emit an event has nothing to disclose.
|
|
121
|
+
*/
|
|
122
|
+
export function maybePrintTelemetryNotice({
|
|
123
|
+
argv = process.argv.slice(2),
|
|
124
|
+
environment = process.env,
|
|
125
|
+
homeDir = os.homedir(),
|
|
126
|
+
stream = process.stderr,
|
|
127
|
+
config = undefined,
|
|
128
|
+
now = Date.now(),
|
|
129
|
+
configDir = CONFIG_DIR,
|
|
130
|
+
} = {}) {
|
|
131
|
+
try {
|
|
132
|
+
if (!stream?.isTTY) return false;
|
|
133
|
+
if (!telemetryCaptureAllowed(environment, argv, homeDir)) return false;
|
|
134
|
+
// Already answered yes, or already switched off by the environment — in
|
|
135
|
+
// both cases there is no pending question to raise.
|
|
136
|
+
if (analyticsEnvOptOut(environment)) return false;
|
|
137
|
+
if ((config === undefined ? loadConfig() : config)?.telemetry?.enabled === true) return false;
|
|
138
|
+
if (telemetryNoticeShown({ configDir })) return false;
|
|
139
|
+
|
|
140
|
+
if (!markTelemetryNoticeShown({ now, configDir })) return false;
|
|
141
|
+
stream.write(`${telemetryNoticeText()}\n`);
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
// A notice is a courtesy. It must never be the reason a command fails.
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/tenants.js
CHANGED
|
@@ -42,6 +42,33 @@ export function productAccessLabel(value) {
|
|
|
42
42
|
return "Unknown";
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Who the PAT resolves to, as reported by `/api/cli/tenants`.
|
|
47
|
+
*
|
|
48
|
+
* Local display and a correlation hint only — the server stamps identity on
|
|
49
|
+
* everything it records and never trusts this copy. So a control plane that
|
|
50
|
+
* predates the field, or one that returns something unreadable, is not an
|
|
51
|
+
* error: `allowMissing` yields `null`, and every writer deletes the key rather
|
|
52
|
+
* than leaving a stale user attached to a new PAT.
|
|
53
|
+
*
|
|
54
|
+
* `displayName` is omitted rather than fabricated when identity has none, so a
|
|
55
|
+
* caller can fall back to the id instead of rendering an empty string.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeCliUser(value, { allowMissing = false } = {}) {
|
|
58
|
+
if ((value === undefined || value === null) && allowMissing) return null;
|
|
59
|
+
const id = typeof value?.id === "string" ? value.id.trim() : "";
|
|
60
|
+
if (!id || id.length > 256) {
|
|
61
|
+
if (allowMissing) return null;
|
|
62
|
+
throw new Error("tenant API returned an invalid user");
|
|
63
|
+
}
|
|
64
|
+
const user = { id: redactSecretText(id) };
|
|
65
|
+
const displayName = typeof value.displayName === "string"
|
|
66
|
+
? redactSecretText(value.displayName).trim().slice(0, 256)
|
|
67
|
+
: "";
|
|
68
|
+
if (displayName) user.displayName = displayName;
|
|
69
|
+
return user;
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
export function normalizePatScopes(value, { allowMissing = false } = {}) {
|
|
46
73
|
if ((value === undefined || value === null) && allowMissing) return null;
|
|
47
74
|
if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !PAT_SCOPE_RE.test(scope))) {
|
|
@@ -131,6 +158,7 @@ export async function fetchTenants(config, fetchImpl = fetchHttp1) {
|
|
|
131
158
|
// during a staged rollout, but never infer a privileged access level here.
|
|
132
159
|
const productAccess = normalizeProductAccess(payload.productAccess, { allowMissing: true });
|
|
133
160
|
const scopes = normalizePatScopes(payload.scopes, { allowMissing: true });
|
|
161
|
+
const user = normalizeCliUser(payload.user, { allowMissing: true });
|
|
134
162
|
const tenants = payload.tenants.map((tenant) => {
|
|
135
163
|
const id = normalizeTenantId(tenant?.id || tenant?.slug);
|
|
136
164
|
return {
|
|
@@ -156,9 +184,22 @@ export async function fetchTenants(config, fetchImpl = fetchHttp1) {
|
|
|
156
184
|
patTenantId: payload.patTenantId || null,
|
|
157
185
|
productAccess,
|
|
158
186
|
scopes,
|
|
187
|
+
user,
|
|
159
188
|
};
|
|
160
189
|
}
|
|
161
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Apply a listing's `user` to a config object.
|
|
193
|
+
*
|
|
194
|
+
* Every config writer calls this instead of inlining the clause: the field
|
|
195
|
+
* would otherwise appear and vanish depending on which command last wrote
|
|
196
|
+
* config, and a `user` left over from a previous PAT is worse than none.
|
|
197
|
+
*/
|
|
198
|
+
export function applyCliUser(config, listing) {
|
|
199
|
+
if (listing?.user) config.user = listing.user;
|
|
200
|
+
else delete config.user;
|
|
201
|
+
}
|
|
202
|
+
|
|
162
203
|
export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
163
204
|
if (!config?.pat) throw new Error(`not authenticated; run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first`);
|
|
164
205
|
if (config.tenantId && !refresh) {
|
|
@@ -176,6 +217,7 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
|
176
217
|
defaultTenantId: null,
|
|
177
218
|
productAccess: normalizeProductAccess(config.productAccess, { allowMissing: true }),
|
|
178
219
|
scopes: normalizePatScopes(config.scopes, { allowMissing: true }),
|
|
220
|
+
user: normalizeCliUser(config.user, { allowMissing: true }),
|
|
179
221
|
};
|
|
180
222
|
}
|
|
181
223
|
|
|
@@ -190,6 +232,7 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
|
190
232
|
else delete config.productAccess;
|
|
191
233
|
if (listing.scopes) config.scopes = listing.scopes;
|
|
192
234
|
else delete config.scopes;
|
|
235
|
+
applyCliUser(config, listing);
|
|
193
236
|
config.tenantsUpdatedAt = new Date().toISOString();
|
|
194
237
|
saveConfig(config);
|
|
195
238
|
return {
|
|
@@ -201,6 +244,10 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
|
201
244
|
defaultTenantId: listing.defaultTenantId,
|
|
202
245
|
productAccess: listing.productAccess,
|
|
203
246
|
scopes: listing.scopes,
|
|
247
|
+
// The cached branch above returns `user`, so omitting it here would make
|
|
248
|
+
// the field's presence depend on whether the caller passed `refresh` —
|
|
249
|
+
// and the refresh branch is the one that just fetched a fresh identity.
|
|
250
|
+
user: listing.user,
|
|
204
251
|
};
|
|
205
252
|
}
|
|
206
253
|
|
|
@@ -217,6 +264,7 @@ export async function selectTenant(config, tenantId) {
|
|
|
217
264
|
else delete config.productAccess;
|
|
218
265
|
if (listing.scopes) config.scopes = listing.scopes;
|
|
219
266
|
else delete config.scopes;
|
|
267
|
+
applyCliUser(config, listing);
|
|
220
268
|
config.tenantsUpdatedAt = new Date().toISOString();
|
|
221
269
|
saveConfig(config);
|
|
222
270
|
return { tenant, listing };
|
package/src/updates.js
CHANGED
|
@@ -295,9 +295,26 @@ export function spawnDetachedAppRefresh(tenantId = null) {
|
|
|
295
295
|
]);
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Background telemetry send. Lives here beside the other detached spawns so
|
|
300
|
+
* `spawnDetached`'s Windows-console handling has exactly one implementation,
|
|
301
|
+
* and so `src/posthog.js` — which decides *whether* to send — does not also
|
|
302
|
+
* own the process-spawning mechanics.
|
|
303
|
+
*
|
|
304
|
+
* The child is marked so it can adopt the flush lock its parent already took
|
|
305
|
+
* out; without that it would find the lock held, exit having sent nothing, and
|
|
306
|
+
* leave the spool to drain only after the lock went stale.
|
|
307
|
+
*/
|
|
308
|
+
export function spawnDetachedTelemetryFlush() {
|
|
309
|
+
spawnDetached(["_telemetry", "flush"], {
|
|
310
|
+
[brandedEnvironmentName("TELEMETRY_FLUSH_CHILD")]: "1",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function spawnDetached(args, extraEnvironment = null) {
|
|
299
315
|
try {
|
|
300
316
|
const child = spawn(process.execPath, [IMPEL_CLI_ENTRYPOINT, ...args], {
|
|
317
|
+
...(extraEnvironment ? { env: { ...process.env, ...extraEnvironment } } : {}),
|
|
301
318
|
detached: true,
|
|
302
319
|
stdio: "ignore",
|
|
303
320
|
// Windows: a detached console-subsystem child gets its OWN console — a
|
|
@@ -305,6 +322,14 @@ function spawnDetached(args) {
|
|
|
305
322
|
// the same option startDetachedFlush already passes.
|
|
306
323
|
windowsHide: true,
|
|
307
324
|
});
|
|
325
|
+
// `spawn` reports an OS-level exec failure asynchronously, as an 'error'
|
|
326
|
+
// event — the try/catch around it cannot see one. An 'error' event with no
|
|
327
|
+
// listener is thrown, so without this the parent dies with exit code 1 the
|
|
328
|
+
// instant the spawn fails, overwriting the exit code its own command
|
|
329
|
+
// already decided on. The telemetry flush runs from `main()`'s finally on
|
|
330
|
+
// every command exit, so that would be a crash on the one path whose whole
|
|
331
|
+
// contract is that the outcome survives unchanged.
|
|
332
|
+
child.on("error", () => {});
|
|
308
333
|
child.unref();
|
|
309
334
|
} catch {
|
|
310
335
|
// Purely opportunistic; the next explicit `impel update` still works.
|