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