impel-cli 0.20.45 → 0.20.46-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -0
- package/docs/native-agent-host-capability-matrix.md +2 -2
- package/package.json +1 -1
- package/src/apps.js +8 -8
- package/src/autoReport.js +289 -0
- package/src/bugReport.js +499 -0
- package/src/cli.js +61 -5
- 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/exitCodes.js +61 -0
- package/src/featureFlags.js +393 -0
- 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/status.js
CHANGED
|
@@ -10,10 +10,12 @@ import { findReviewedVendorCliBinary } from "../vendorCliBinaries.js";
|
|
|
10
10
|
import { windowsTenantShortcutName } from "../shellEntries.js";
|
|
11
11
|
import {
|
|
12
12
|
ensureTenantSelection,
|
|
13
|
+
normalizeCliUser,
|
|
13
14
|
PAT_SCOPE_CLAUDE,
|
|
14
15
|
PAT_SCOPE_CODEX,
|
|
15
16
|
productAccessLabel,
|
|
16
17
|
} from "../tenants.js";
|
|
18
|
+
import { telemetryNoticeLine } from "../posthog.js";
|
|
17
19
|
import { installedVersion, maybePrintUpdateNotice } from "../updates.js";
|
|
18
20
|
import { findManagedWindowsChatGPTApp, windowsClaudeUserData } from "../windowsApps.js";
|
|
19
21
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
@@ -85,6 +87,24 @@ function cliState({ supported, available = true, profileReady, binaryReady }) {
|
|
|
85
87
|
return profileReady && binaryReady ? "ready" : "missing";
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Everything `impel status` prints on its way out, from every exit.
|
|
92
|
+
*
|
|
93
|
+
* There are three: no PAT, an authentication check that did not come back, and
|
|
94
|
+
* the full report. The first two are precisely the states a broken install is
|
|
95
|
+
* in — a notice wired only to the tail would be invisible exactly when someone
|
|
96
|
+
* runs `status` to find out what is wrong.
|
|
97
|
+
*
|
|
98
|
+
* `doctor` deliberately does not carry this. It runs synthetic, billable
|
|
99
|
+
* end-to-end gateway checks; a local drop notice should not cost a request.
|
|
100
|
+
*/
|
|
101
|
+
function printLocalNotices(io) {
|
|
102
|
+
if (RUNTIME_BRAND.cli.packageName !== "impel-cli") return;
|
|
103
|
+
const telemetry = io.telemetryNoticeLine();
|
|
104
|
+
if (telemetry) console.log(telemetry);
|
|
105
|
+
io.maybePrintUpdateNotice();
|
|
106
|
+
}
|
|
107
|
+
|
|
88
108
|
export async function cmdStatus(overrides = {}) {
|
|
89
109
|
const io = {
|
|
90
110
|
loadConfig,
|
|
@@ -94,6 +114,7 @@ export async function cmdStatus(overrides = {}) {
|
|
|
94
114
|
installedVersion,
|
|
95
115
|
findNativeBinary: findReviewedVendorCliBinary,
|
|
96
116
|
maybePrintUpdateNotice,
|
|
117
|
+
telemetryNoticeLine,
|
|
97
118
|
platform: process.platform,
|
|
98
119
|
environment: process.env,
|
|
99
120
|
...overrides,
|
|
@@ -102,8 +123,12 @@ export async function cmdStatus(overrides = {}) {
|
|
|
102
123
|
const version = process.env.IMPEL_CLI_EXTENSION_VERSION || io.installedVersion() || "?";
|
|
103
124
|
console.log(`${RUNTIME_BRAND.cli.command}-cli: v${version}`);
|
|
104
125
|
console.log(`Authentication: ${config?.pat ? `configured (${maskSecret(config.pat)})` : "not configured - run `impel setup`"}`);
|
|
126
|
+
// Read back through the same normalizer that wrote it: config.json is a file
|
|
127
|
+
// on disk, so what it holds is untrusted input by the time it is printed.
|
|
128
|
+
const user = normalizeCliUser(config?.user, { allowMissing: true });
|
|
129
|
+
if (user) console.log(`Signed in as: ${user.displayName || user.id}`);
|
|
105
130
|
if (!config?.pat) {
|
|
106
|
-
|
|
131
|
+
printLocalNotices(io);
|
|
107
132
|
return;
|
|
108
133
|
}
|
|
109
134
|
|
|
@@ -117,7 +142,7 @@ export async function cmdStatus(overrides = {}) {
|
|
|
117
142
|
console.log(RUNTIME_BRAND.tenant.defaultId
|
|
118
143
|
? `Run \`${RUNTIME_BRAND.cli.command} setup\` to refresh authentication and repair the local tenant.`
|
|
119
144
|
: "Run `impel setup` to refresh authentication, then `impel update` to repair local tenants.");
|
|
120
|
-
|
|
145
|
+
printLocalNotices(io);
|
|
121
146
|
return;
|
|
122
147
|
}
|
|
123
148
|
|
|
@@ -162,5 +187,5 @@ export async function cmdStatus(overrides = {}) {
|
|
|
162
187
|
? `Repair or finish missing tenant surfaces with: ${RUNTIME_BRAND.cli.command} setup`
|
|
163
188
|
: "Repair or finish missing tenant surfaces with: impel update");
|
|
164
189
|
}
|
|
165
|
-
|
|
190
|
+
printLocalNotices(io);
|
|
166
191
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -10,6 +10,7 @@ import { loadConfig, redactSecretText } from "../config.js";
|
|
|
10
10
|
import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
11
11
|
import { withProgress } from "../progress.js";
|
|
12
12
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
13
|
+
import { reportInstallFailure } from "../autoReport.js";
|
|
13
14
|
import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
14
15
|
import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
|
|
15
16
|
import {
|
|
@@ -252,6 +253,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
252
253
|
environment: process.env,
|
|
253
254
|
progress: withProgress,
|
|
254
255
|
recoverInstall: runInstallRecovery,
|
|
256
|
+
reportInstallFailure,
|
|
255
257
|
postInstallVersion: postInstallCliVersion,
|
|
256
258
|
postInstallRunnable: postInstallCliRunnable,
|
|
257
259
|
resolveFreshInstall: resolveFreshGlobalEntrypoint,
|
|
@@ -315,6 +317,36 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
315
317
|
return;
|
|
316
318
|
}
|
|
317
319
|
|
|
320
|
+
// Read at most once, and only when something actually needs it. All three
|
|
321
|
+
// terminal failures do, which is why this is not still buried in the npm
|
|
322
|
+
// branch — but an `impel update` that succeeds must not pay a config read it
|
|
323
|
+
// has no use for, and this whole feature is supposed to cost the success
|
|
324
|
+
// path nothing.
|
|
325
|
+
let configLoaded = false;
|
|
326
|
+
let configValue;
|
|
327
|
+
const currentConfig = () => {
|
|
328
|
+
if (!configLoaded) {
|
|
329
|
+
configValue = io.loadConfig();
|
|
330
|
+
configLoaded = true;
|
|
331
|
+
}
|
|
332
|
+
return configValue;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* File one automatic report for a terminal `impel update` failure.
|
|
337
|
+
*
|
|
338
|
+
* Each caller has already printed the message the user needs to act on, so
|
|
339
|
+
* this must not add a second one: `reportInstallFailure` swallows its own
|
|
340
|
+
* errors and the `catch` covers whatever an injected reporter does (R10).
|
|
341
|
+
*/
|
|
342
|
+
const reportUpdateFailure = async (failure) => {
|
|
343
|
+
try {
|
|
344
|
+
await io.reportInstallFailure({ failure, config: currentConfig(), io });
|
|
345
|
+
} catch {
|
|
346
|
+
// R10: one error message, and it is the one the caller already printed.
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
318
350
|
// ── CLI ────────────────────────────────────────────────────────────────
|
|
319
351
|
let cascadeEntrypoint = null;
|
|
320
352
|
if (!remote) {
|
|
@@ -340,8 +372,8 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
340
372
|
console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
|
|
341
373
|
console.error(` Manual recovery: \`npm install --global ${installSpec}\`.`);
|
|
342
374
|
}
|
|
343
|
-
const config = io.loadConfig();
|
|
344
375
|
let recovered = false;
|
|
376
|
+
const config = currentConfig();
|
|
345
377
|
if (config?.pat && config?.tenantId) {
|
|
346
378
|
// The goal passes on the most recent reviewed retry of the npm
|
|
347
379
|
// install OR on a live disk probe of the package that owns the
|
|
@@ -399,6 +431,19 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
399
431
|
}
|
|
400
432
|
}
|
|
401
433
|
if (!recovered) {
|
|
434
|
+
// Past recovery, so this is a failure that ran out of fixes (KTD2).
|
|
435
|
+
await reportUpdateFailure({
|
|
436
|
+
scope: "shared",
|
|
437
|
+
platform: io.platform,
|
|
438
|
+
architecture: process.arch,
|
|
439
|
+
step: "install.impel_cli",
|
|
440
|
+
command: `npm install --global ${installSpec}`,
|
|
441
|
+
message: `The npm-verified global ${RUNTIME_BRAND.cli.packageName} update failed.`,
|
|
442
|
+
diagnostics: {
|
|
443
|
+
installedVersion: String(current ?? "unknown"),
|
|
444
|
+
remoteVersion: String(remote ?? "unknown"),
|
|
445
|
+
},
|
|
446
|
+
});
|
|
402
447
|
process.exitCode = 1;
|
|
403
448
|
return;
|
|
404
449
|
}
|
|
@@ -426,6 +471,22 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
426
471
|
console.error(` Running CLI: ${CLI_BIN}`);
|
|
427
472
|
console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
|
|
428
473
|
console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
|
|
474
|
+
// Its own step, not `install.impel_cli`: npm succeeded and the update
|
|
475
|
+
// landed somewhere real. The reportable fact is the prefix skew, and
|
|
476
|
+
// the two versions below are what makes a report of it actionable.
|
|
477
|
+
await reportUpdateFailure({
|
|
478
|
+
scope: "shared",
|
|
479
|
+
platform: io.platform,
|
|
480
|
+
architecture: process.arch,
|
|
481
|
+
step: "install.impel_cli_prefix_skew",
|
|
482
|
+
command: `npm install --global ${installSpec}`,
|
|
483
|
+
message: `npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}), and the freshly installed build could not be located.`,
|
|
484
|
+
diagnostics: {
|
|
485
|
+
runningVersion: String(postInstall ?? "unknown"),
|
|
486
|
+
expectedVersion: String(remote),
|
|
487
|
+
cliBin: CLI_BIN,
|
|
488
|
+
},
|
|
489
|
+
});
|
|
429
490
|
process.exitCode = 1;
|
|
430
491
|
return;
|
|
431
492
|
}
|
|
@@ -449,6 +510,26 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
449
510
|
};
|
|
450
511
|
if (!await io.runConvergence(convergenceArgs)) {
|
|
451
512
|
console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
|
|
513
|
+
// The child is `impel _converge`, which files no report of its own — and
|
|
514
|
+
// this parent only sees its exit status, because the child inherits stdio
|
|
515
|
+
// so its progress reaches the terminal live. So this report carries what
|
|
516
|
+
// the parent does know: that it was an update cascade, and which flags
|
|
517
|
+
// shaped the run it failed under. Which tenants failed is visible on the
|
|
518
|
+
// user's screen but not in the report; closing that gap needs a channel
|
|
519
|
+
// between the two processes, which R2 (one report per invocation) says must
|
|
520
|
+
// not simply be a second report from the child.
|
|
521
|
+
await reportUpdateFailure({
|
|
522
|
+
scope: "shared",
|
|
523
|
+
platform: io.platform,
|
|
524
|
+
architecture: process.arch,
|
|
525
|
+
step: "update.tenant_convergence",
|
|
526
|
+
message: "Tenant convergence failed during `impel update`.",
|
|
527
|
+
diagnostics: {
|
|
528
|
+
skipApps: String(Boolean(flags["skip-apps"])),
|
|
529
|
+
skipClis: String(Boolean(flags["skip-clis"])),
|
|
530
|
+
cascadedEntrypoint: String(Boolean(cascadeEntrypoint)),
|
|
531
|
+
},
|
|
532
|
+
});
|
|
452
533
|
process.exitCode = 1;
|
|
453
534
|
return;
|
|
454
535
|
}
|
package/src/cursorLocal.js
CHANGED
|
@@ -1473,6 +1473,14 @@ export function cursorLaunchSpec({
|
|
|
1473
1473
|
}
|
|
1474
1474
|
const env = { ...environment };
|
|
1475
1475
|
for (const key of [...DIRECT_PROVIDER_ENV_KEYS, ...RUNTIME_OVERRIDE_ENV_KEYS]) delete env[key];
|
|
1476
|
+
// The positive managed marker `telemetryConsent.js` denies on. It has to be
|
|
1477
|
+
// stamped here rather than inferred: the delete loop above removes
|
|
1478
|
+
// CLAUDE_CONFIG_DIR and CODEX_HOME, and `HOME` below points at the managed
|
|
1479
|
+
// profile, so every path the guard could otherwise key on either disappears
|
|
1480
|
+
// or stops looking managed. Without it a terminal inside managed Cursor —
|
|
1481
|
+
// holding CURSOR_LOCAL_AGENT_API_KEY — would capture and flush telemetry to a
|
|
1482
|
+
// host other than the gateway, which is the P1-8 egress boundary.
|
|
1483
|
+
env[brandedEnvironmentName("MANAGED_CURSOR")] = "1";
|
|
1476
1484
|
env.HOME = paths.home;
|
|
1477
1485
|
env.XDG_CACHE_HOME = path.join(paths.home, ".cache");
|
|
1478
1486
|
env.XDG_CONFIG_HOME = path.join(paths.home, ".config");
|
package/src/exitCodes.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Process exit codes that mean something specific.
|
|
2
|
+
//
|
|
3
|
+
// Three values are already spoken for and must never be reused:
|
|
4
|
+
//
|
|
5
|
+
// 1 — `bin/impel.js`'s catch-all for any escaped throw. A dedicated code
|
|
6
|
+
// that collides with this is indistinguishable from a crash.
|
|
7
|
+
// 78 — `MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE` (EX_CONFIG) in `src/apps.js`.
|
|
8
|
+
// 127 — the shell's "command not found"; a CLI that returns it is claiming
|
|
9
|
+
// it was never invoked.
|
|
10
|
+
//
|
|
11
|
+
// Everything else follows sysexits.h, so an operator reading a bare number
|
|
12
|
+
// gets the right intuition without a lookup.
|
|
13
|
+
|
|
14
|
+
/** Codes an Impel-specific exit code must not reuse. Asserted by tests. */
|
|
15
|
+
export const RESERVED_EXIT_CODES = Object.freeze([0, 1, 78, 127]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `impel _telemetry flush` could not deliver its batch (EX_TEMPFAIL): the
|
|
19
|
+
* spool is intact and a later flush retries.
|
|
20
|
+
*
|
|
21
|
+
* Nothing reads this in production — the sender is detached with stdio
|
|
22
|
+
* ignored, and the durable drop notice is the real observability surface. It
|
|
23
|
+
* exists because the send is an external call, and Law 6 wants every failure
|
|
24
|
+
* branch to have a distinct non-zero outcome that a test and a human running
|
|
25
|
+
* the command by hand can both see.
|
|
26
|
+
*/
|
|
27
|
+
export const TELEMETRY_FLUSH_FAILED_EXIT_CODE = 75;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A gated command refused because the feature-flag state could not be
|
|
31
|
+
* established (EX_UNAVAILABLE): no fresh evaluation, no cached one for this
|
|
32
|
+
* account, and no way to reach the control plane.
|
|
33
|
+
*
|
|
34
|
+
* Distinct from the catch-all 1 on purpose. "I could not find out whether you
|
|
35
|
+
* are allowed to run this" and "I crashed" are different states, and only the
|
|
36
|
+
* first one is worth retrying once the machine is back online.
|
|
37
|
+
*/
|
|
38
|
+
export const FLAG_STATE_UNKNOWN_EXIT_CODE = 69;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A gated command refused because the flag is off for this account
|
|
42
|
+
* (EX_NOPERM): the state *was* established, and the answer was no.
|
|
43
|
+
*
|
|
44
|
+
* Separate from `FLAG_STATE_UNKNOWN_EXIT_CODE` because the two say opposite
|
|
45
|
+
* things about retrying. "Not enabled for you" is a settled answer — running it
|
|
46
|
+
* again changes nothing until someone grants access. "Could not find out" is
|
|
47
|
+
* worth retrying the moment the machine is back online. A script that treats
|
|
48
|
+
* both as one number has to guess which it got.
|
|
49
|
+
*/
|
|
50
|
+
export const FEATURE_NOT_ENABLED_EXIT_CODE = 77;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `impel report` could not deliver the bug report and wrote the envelope to
|
|
54
|
+
* disk instead (EX_IOERR): the route was unreachable, absent, or rate-limited.
|
|
55
|
+
*
|
|
56
|
+
* The report is user-initiated, so unlike telemetry someone is watching this
|
|
57
|
+
* exit code. It says "your report was not lost, and it was not sent either" —
|
|
58
|
+
* a state that deserves its own number, because the printed spool path is the
|
|
59
|
+
* next action and 1 would read as a crash that produced nothing.
|
|
60
|
+
*/
|
|
61
|
+
export const REPORT_SPOOLED_EXIT_CODE = 74;
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Remote feature flags for the CLI: a disk cache in front of
|
|
2
|
+
// `POST /api/cli/flags`, evaluated fail-closed.
|
|
3
|
+
//
|
|
4
|
+
// The only consumer today is the Cursor experiment gate, and that shapes every
|
|
5
|
+
// decision here. A gate exists so an experiment can be turned off remotely for
|
|
6
|
+
// one account; if a machine that cannot reach the control plane guessed a
|
|
7
|
+
// value, the guess would be wrong exactly when the switch is being thrown. So
|
|
8
|
+
// there is no default: `evaluateFlag` either returns an answer it can trace to
|
|
9
|
+
// a real server evaluation — fresh, cached, or stale — or it throws
|
|
10
|
+
// `FlagStateUnknownError` for the command to turn into a distinct exit code.
|
|
11
|
+
//
|
|
12
|
+
// Two timers, mirroring `src/updates.js`: `fetchedAt` decides whether the
|
|
13
|
+
// cached answer is still current (6h), and `lastFailedAt` suppresses re-probing
|
|
14
|
+
// after a failure (1h). Without the second one an offline machine would attempt
|
|
15
|
+
// a fetch on every single invocation of the gated command.
|
|
16
|
+
//
|
|
17
|
+
// The cache records the principal it was fetched for. A read under a different
|
|
18
|
+
// `{orgId, userId}` is a miss rather than a hit: serving the previous org's
|
|
19
|
+
// answers for up to six hours after a tenant or PAT switch would defeat a kill
|
|
20
|
+
// switch precisely when one org is being killed and another is not.
|
|
21
|
+
//
|
|
22
|
+
// Everything written here lives under `CONFIG_DIR`, so `impel nuke` erases it.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
CONFIG_DIR,
|
|
30
|
+
loadConfig,
|
|
31
|
+
normalizeGatewayUrl,
|
|
32
|
+
resolveDefaultAppUrl,
|
|
33
|
+
} from "./config.js";
|
|
34
|
+
import { FLAG_STATE_UNKNOWN_EXIT_CODE } from "./exitCodes.js";
|
|
35
|
+
import { fetchHttp1 } from "./http1.js";
|
|
36
|
+
import { TELEMETRY_CONTRACT } from "./posthog.js";
|
|
37
|
+
import { RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
38
|
+
import { managedMarkerPresent } from "./telemetryConsent.js";
|
|
39
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
40
|
+
|
|
41
|
+
/** The route this module posts to. */
|
|
42
|
+
export const FLAGS_ENDPOINT_PATH = "/api/cli/flags";
|
|
43
|
+
|
|
44
|
+
/** Beside `update-check.json`, and erased by the same `impel nuke` sweep. */
|
|
45
|
+
export const FLAG_CACHE_PATH = path.join(CONFIG_DIR, "flags.json");
|
|
46
|
+
|
|
47
|
+
/** How long an evaluation stays authoritative. The repo's cache default. */
|
|
48
|
+
export const FLAG_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How long a failed check suppresses the next attempt. Shorter than the TTL:
|
|
52
|
+
* a failure should heal within the session, but must not turn an offline
|
|
53
|
+
* machine into one request per gated command.
|
|
54
|
+
*/
|
|
55
|
+
export const FLAG_FETCH_BACKOFF_MS = 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The flag the managed-Cursor experiment is gated on.
|
|
59
|
+
*
|
|
60
|
+
* Declared as a literal rather than read out of `TELEMETRY_CONTRACT` by index;
|
|
61
|
+
* `test/feature-flags.test.js` pins it against `featureFlagKeys` so the two
|
|
62
|
+
* cannot drift while keeping the reference here readable.
|
|
63
|
+
*/
|
|
64
|
+
export const CURSOR_EXPERIMENT_FLAG = "cli-cursor-experiment";
|
|
65
|
+
|
|
66
|
+
/** Long enough for a cold control-plane response, short enough to be a gate. */
|
|
67
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
68
|
+
|
|
69
|
+
/** Flag keys are bounded by the server schema; anything longer is not ours. */
|
|
70
|
+
const MAX_FLAG_KEY_LENGTH = 128;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The flag state could not be established, and no cached answer applies.
|
|
74
|
+
*
|
|
75
|
+
* Thrown rather than returned so a caller cannot accidentally treat it as a
|
|
76
|
+
* value. Commands catch it and set `exitCode` on `process.exitCode`: the
|
|
77
|
+
* `experimental` throw path always exits 1, so an escaping throw would be
|
|
78
|
+
* indistinguishable from a crash (see `src/exitCodes.js`).
|
|
79
|
+
*/
|
|
80
|
+
export class FlagStateUnknownError extends Error {
|
|
81
|
+
constructor(message, reason) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "FlagStateUnknownError";
|
|
84
|
+
/** Machine-readable cause; each maps to its own message. */
|
|
85
|
+
this.reason = reason;
|
|
86
|
+
this.exitCode = FLAG_STATE_UNKNOWN_EXIT_CODE;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Internal: a fetch that did not produce a usable evaluation. */
|
|
91
|
+
class FlagFetchError extends Error {
|
|
92
|
+
constructor(message, reason) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "FlagFetchError";
|
|
95
|
+
this.reason = reason;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* -------------------------------------------------------------------------- */
|
|
100
|
+
/* Cache */
|
|
101
|
+
/* -------------------------------------------------------------------------- */
|
|
102
|
+
|
|
103
|
+
export function readFlagCache() {
|
|
104
|
+
try {
|
|
105
|
+
const cache = JSON.parse(fs.readFileSync(FLAG_CACHE_PATH, "utf8"));
|
|
106
|
+
return cache && typeof cache === "object" && !Array.isArray(cache) ? cache : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Merge-and-replace, matching `writeUpdateCache`'s tmp+rename discipline. */
|
|
113
|
+
export function writeFlagCache(patch) {
|
|
114
|
+
const next = { ...(readFlagCache() || {}), ...patch };
|
|
115
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
116
|
+
const temporaryPath = `${FLAG_CACHE_PATH}.tmp-${process.pid}`;
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
119
|
+
renameWithWindowsRetry(temporaryPath, FLAG_CACHE_PATH);
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
123
|
+
} catch {
|
|
124
|
+
// Cleanup must never mask the write/rename outcome.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Who the flags were evaluated for.
|
|
132
|
+
*
|
|
133
|
+
* `userId` comes from `config.user`, which U10 persists from the tenants
|
|
134
|
+
* response. Before that field exists it is `null` on both the write and the
|
|
135
|
+
* read, so the comparison stays consistent — an install that has never seen a
|
|
136
|
+
* `user` is one principal, not a permanent mismatch.
|
|
137
|
+
*/
|
|
138
|
+
function flagPrincipal(config) {
|
|
139
|
+
return {
|
|
140
|
+
orgId: typeof config?.tenantId === "string" ? config.tenantId : null,
|
|
141
|
+
userId: typeof config?.user?.id === "string" ? config.user.id : null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function samePrincipal(cache, principal) {
|
|
146
|
+
return (cache?.orgId ?? null) === principal.orgId
|
|
147
|
+
&& (cache?.userId ?? null) === principal.userId;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The cached evaluation, or null when there is none *for this principal*. */
|
|
151
|
+
function cachedFlags(cache, principal) {
|
|
152
|
+
if (!cache || !samePrincipal(cache, principal)) return null;
|
|
153
|
+
const flags = cache.flags;
|
|
154
|
+
return flags && typeof flags === "object" && !Array.isArray(flags) ? flags : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function withinTtl(cache, now) {
|
|
158
|
+
return Number.isFinite(cache?.fetchedAt) && now - cache.fetchedAt < FLAG_CACHE_TTL_MS;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function backoffRemainingMs(cache, now) {
|
|
162
|
+
if (!Number.isFinite(cache?.lastFailedAt)) return 0;
|
|
163
|
+
return Math.max(0, cache.lastFailedAt + FLAG_FETCH_BACKOFF_MS - now);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* -------------------------------------------------------------------------- */
|
|
167
|
+
/* Evaluation */
|
|
168
|
+
/* -------------------------------------------------------------------------- */
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read one flag out of an evaluation.
|
|
172
|
+
*
|
|
173
|
+
* A key the evaluation does not mention resolves to `enabled: false`. That is
|
|
174
|
+
* not a fabricated default: the server answered, and an answer that does not
|
|
175
|
+
* name the flag is an answer that the flag is not on for this principal. The
|
|
176
|
+
* fabricated case — no answer at all — throws instead. `known` lets a caller
|
|
177
|
+
* that cares tell the two apart.
|
|
178
|
+
*/
|
|
179
|
+
function resolveFlag(name, flags, source) {
|
|
180
|
+
const flag = flags?.[name];
|
|
181
|
+
return {
|
|
182
|
+
name,
|
|
183
|
+
enabled: flag?.enabled === true,
|
|
184
|
+
variant: typeof flag?.variant === "string" ? flag.variant : null,
|
|
185
|
+
payload: flag && Object.hasOwn(flag, "payload") ? flag.payload : null,
|
|
186
|
+
known: Boolean(flag),
|
|
187
|
+
source,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Validate a response into the shape the cache stores, or return null.
|
|
193
|
+
*
|
|
194
|
+
* One malformed flag rejects the whole response rather than being skipped: a
|
|
195
|
+
* partially-read evaluation is exactly the "some flags computed, some not"
|
|
196
|
+
* state the route already refuses to send (`flags_unavailable`), and a kill
|
|
197
|
+
* switch dropped by a lenient parser reads as "not killed".
|
|
198
|
+
*/
|
|
199
|
+
function normalizeFlagResponse(payload) {
|
|
200
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
201
|
+
const { flags } = payload;
|
|
202
|
+
if (!flags || typeof flags !== "object" || Array.isArray(flags)) return null;
|
|
203
|
+
const keys = Object.keys(flags);
|
|
204
|
+
if (keys.length > TELEMETRY_CONTRACT.limits.maxFlagsPerResponse) return null;
|
|
205
|
+
|
|
206
|
+
const normalized = {};
|
|
207
|
+
for (const key of keys) {
|
|
208
|
+
if (key.length === 0 || key.length > MAX_FLAG_KEY_LENGTH) return null;
|
|
209
|
+
const value = flags[key];
|
|
210
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
211
|
+
if (typeof value.enabled !== "boolean") return null;
|
|
212
|
+
normalized[key] = { enabled: value.enabled };
|
|
213
|
+
if (typeof value.variant === "string" && value.variant.length > 0) {
|
|
214
|
+
normalized[key].variant = value.variant;
|
|
215
|
+
}
|
|
216
|
+
if (value.payload !== undefined) normalized[key].payload = value.payload;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
flags: normalized,
|
|
220
|
+
evaluatedAt: typeof payload.evaluatedAt === "string" ? payload.evaluatedAt : null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function jsonBody(response) {
|
|
225
|
+
try {
|
|
226
|
+
return await response.json();
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* One evaluation request.
|
|
234
|
+
*
|
|
235
|
+
* Server-supplied text is never echoed — only status codes and our own
|
|
236
|
+
* sentences — so no message can carry back something that needs redacting.
|
|
237
|
+
*/
|
|
238
|
+
async function fetchFlags({ config, fetchImpl, timeoutMs }) {
|
|
239
|
+
const appUrl = normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl());
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
242
|
+
let response;
|
|
243
|
+
try {
|
|
244
|
+
response = await fetchImpl(new URL(FLAGS_ENDPOINT_PATH, appUrl), {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: {
|
|
247
|
+
accept: "application/json",
|
|
248
|
+
authorization: `Bearer ${config.pat}`,
|
|
249
|
+
"content-type": "application/json",
|
|
250
|
+
},
|
|
251
|
+
body: "{}",
|
|
252
|
+
signal: controller.signal,
|
|
253
|
+
});
|
|
254
|
+
} catch {
|
|
255
|
+
throw new FlagFetchError(
|
|
256
|
+
`could not reach ${appUrl} to check feature flags; reconnect and try again`,
|
|
257
|
+
"unreachable",
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
clearTimeout(timeout);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
// R16: a `next` deployment that predates the route. Named separately from
|
|
265
|
+
// every other failure because the remedy is a deployment, not a retry.
|
|
266
|
+
if (response.status === 404) {
|
|
267
|
+
throw new FlagFetchError(
|
|
268
|
+
`${appUrl} does not serve feature flags yet; update the ${RUNTIME_BRAND.product.displayName} control plane and try again`,
|
|
269
|
+
"absent_route",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const body = await jsonBody(response);
|
|
273
|
+
if (body?.code === "flags_unavailable") {
|
|
274
|
+
throw new FlagFetchError(
|
|
275
|
+
`${appUrl} cannot evaluate feature flags right now; try again in a few minutes`,
|
|
276
|
+
"unavailable",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
throw new FlagFetchError(
|
|
280
|
+
`${appUrl} refused the feature-flag check (HTTP ${response.status}); try again later`,
|
|
281
|
+
"rejected",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const normalized = normalizeFlagResponse(await jsonBody(response));
|
|
286
|
+
if (!normalized) {
|
|
287
|
+
throw new FlagFetchError(
|
|
288
|
+
`${appUrl} returned a feature-flag response this CLI cannot read; update the CLI and try again`,
|
|
289
|
+
"malformed",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return normalized;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Why this process may not make the request, or null when it may.
|
|
297
|
+
*
|
|
298
|
+
* The managed-surface denial is the P1-8 egress boundary and is checked first:
|
|
299
|
+
* a managed vendor app profile may reach the gateway and nothing else, so a
|
|
300
|
+
* flag check from inside one presents as an unexpected call from a profile
|
|
301
|
+
* holding app credentials. Unlike analytics capture this is not a consent
|
|
302
|
+
* question — a kill switch that honors an opt-out is not a kill switch — but
|
|
303
|
+
* the context guard applies to every egress path without exception.
|
|
304
|
+
*/
|
|
305
|
+
function fetchBlocked({ config, env, homeDir, cache, now }) {
|
|
306
|
+
if (managedMarkerPresent(env, homeDir)) {
|
|
307
|
+
return {
|
|
308
|
+
reason: "managed_surface",
|
|
309
|
+
message: "feature flags cannot be checked from inside a managed Impel app profile; run this from a terminal",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (!config?.pat) {
|
|
313
|
+
return {
|
|
314
|
+
reason: "unauthenticated",
|
|
315
|
+
message: `not authenticated; run \`${RUNTIME_BRAND.cli.command} setup\` before using a gated feature`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const remaining = backoffRemainingMs(cache, now);
|
|
319
|
+
if (remaining > 0) {
|
|
320
|
+
const minutes = Math.max(1, Math.ceil(remaining / 60_000));
|
|
321
|
+
return {
|
|
322
|
+
reason: "backoff",
|
|
323
|
+
message: `a recent feature-flag check failed and no cached answer applies to this account; try again in ${minutes} minute${minutes === 1 ? "" : "s"}`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Resolve one flag for the current principal.
|
|
331
|
+
*
|
|
332
|
+
* The state machine, in the order the branches are taken:
|
|
333
|
+
*
|
|
334
|
+
* fresh cache -> use it, no request
|
|
335
|
+
* blocked (managed/no PAT/backoff) -> stale cache if there is one, else refuse
|
|
336
|
+
* fetch succeeds -> store and use
|
|
337
|
+
* fetch fails, cache exists -> stale value, failure stamped
|
|
338
|
+
* fetch fails, no cache -> refuse with that failure's own message
|
|
339
|
+
*
|
|
340
|
+
* "Cache" throughout means a cache written for *this* `{orgId, userId}`; one
|
|
341
|
+
* written for another principal is not a cache miss that degrades to stale, it
|
|
342
|
+
* is a miss that refuses.
|
|
343
|
+
*/
|
|
344
|
+
export async function evaluateFlag(name, options = {}) {
|
|
345
|
+
const {
|
|
346
|
+
config = loadConfig(),
|
|
347
|
+
fetchImpl = fetchHttp1,
|
|
348
|
+
env = process.env,
|
|
349
|
+
homeDir = os.homedir(),
|
|
350
|
+
now = Date.now(),
|
|
351
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
352
|
+
} = options;
|
|
353
|
+
|
|
354
|
+
const principal = flagPrincipal(config);
|
|
355
|
+
const cache = readFlagCache();
|
|
356
|
+
const cached = cachedFlags(cache, principal);
|
|
357
|
+
if (cached && withinTtl(cache, now)) return resolveFlag(name, cached, "cache");
|
|
358
|
+
|
|
359
|
+
const blocked = fetchBlocked({ config, env, homeDir, cache, now });
|
|
360
|
+
if (blocked) {
|
|
361
|
+
if (cached) return resolveFlag(name, cached, "stale");
|
|
362
|
+
throw new FlagStateUnknownError(blocked.message, blocked.reason);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let evaluation;
|
|
366
|
+
try {
|
|
367
|
+
evaluation = await fetchFlags({ config, fetchImpl, timeoutMs });
|
|
368
|
+
} catch (error) {
|
|
369
|
+
// Stamp the failure even when a stale answer covers this call: the point of
|
|
370
|
+
// the second timer is that the *next* invocation does not re-probe either.
|
|
371
|
+
try {
|
|
372
|
+
writeFlagCache({ lastFailedAt: now });
|
|
373
|
+
} catch {
|
|
374
|
+
// The stamp is a request-rate limiter, not required state.
|
|
375
|
+
}
|
|
376
|
+
if (cached) return resolveFlag(name, cached, "stale");
|
|
377
|
+
throw new FlagStateUnknownError(error.message, error.reason || "unreachable");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
writeFlagCache({
|
|
382
|
+
...principal,
|
|
383
|
+
flags: evaluation.flags,
|
|
384
|
+
evaluatedAt: evaluation.evaluatedAt,
|
|
385
|
+
fetchedAt: now,
|
|
386
|
+
lastFailedAt: 0,
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
// An unwritable config dir costs a cache, not an answer: this evaluation is
|
|
390
|
+
// authoritative and is returned either way.
|
|
391
|
+
}
|
|
392
|
+
return resolveFlag(name, evaluation.flags, "network");
|
|
393
|
+
}
|