@oxygen-agent/cli 1.286.13 → 1.309.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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-values.d.ts +18 -0
  3. package/dist/cli-values.js +66 -0
  4. package/dist/credentials.d.ts +22 -2
  5. package/dist/credentials.js +80 -17
  6. package/dist/help.js +1 -1
  7. package/dist/http-client.d.ts +2 -0
  8. package/dist/http-client.js +68 -30
  9. package/dist/index.js +789 -243
  10. package/dist/knowledge-mirror.d.ts +10 -0
  11. package/dist/knowledge-mirror.js +18 -0
  12. package/dist/run-wait.js +2 -26
  13. package/dist/runtime.d.ts +63 -4
  14. package/dist/runtime.js +113 -3
  15. package/node_modules/@oxygen/shared/dist/deprecation-registry.js +2 -18
  16. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +80 -0
  17. package/node_modules/@oxygen/shared/dist/error-redaction.js +223 -0
  18. package/node_modules/@oxygen/shared/dist/file-import.js +9 -27
  19. package/node_modules/@oxygen/shared/dist/identifiers.d.ts +23 -0
  20. package/node_modules/@oxygen/shared/dist/identifiers.js +48 -0
  21. package/node_modules/@oxygen/shared/dist/index.d.ts +7 -1
  22. package/node_modules/@oxygen/shared/dist/index.js +7 -1
  23. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +2 -0
  24. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +4 -0
  25. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +24 -0
  26. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +301 -0
  27. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +19 -0
  28. package/node_modules/@oxygen/shared/dist/linkedin-url.js +105 -0
  29. package/node_modules/@oxygen/shared/dist/log.d.ts +3 -0
  30. package/node_modules/@oxygen/shared/dist/log.js +65 -6
  31. package/node_modules/@oxygen/shared/dist/redaction.d.ts +1 -0
  32. package/node_modules/@oxygen/shared/dist/redaction.js +15 -3
  33. package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -3
  34. package/node_modules/@oxygen/shared/dist/sequences.js +11 -2
  35. package/node_modules/@oxygen/shared/dist/timing.d.ts +10 -0
  36. package/node_modules/@oxygen/shared/dist/timing.js +12 -0
  37. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +15 -0
  38. package/node_modules/@oxygen/shared/dist/type-guards.js +17 -0
  39. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
  40. package/node_modules/@oxygen/shared/dist/version.js +33 -2
  41. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -1
  42. package/node_modules/@oxygen/workflows/dist/index.js +1 -0
  43. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +41 -0
  44. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +203 -0
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.286.13
37
+ Version: 1.309.0
@@ -0,0 +1,18 @@
1
+ /** Parse a `--limit`/`--interval`-style flag into a positive integer, or undefined when absent. */
2
+ export declare function readPositiveInt(value: string | undefined): number | undefined;
3
+ /** Read a string field from an unknown record-shaped value, or null when it is missing/non-string. */
4
+ export declare function readRecordString(value: unknown, key: string): string | null;
5
+ /** Parse a JSON string that must decode to a plain object, throwing a typed invalid_json error otherwise. */
6
+ export declare function parseJsonObject(value: string): Record<string, unknown>;
7
+ /** Read a JSON-object CLI option (e.g. `--selection-json`, `--filter-tree-json`), or undefined when absent. */
8
+ export declare function readJsonObjectOption(value: string | undefined): Record<string, unknown> | undefined;
9
+ /** Reject the mutually-exclusive `--live` + `--dry-run` combination with a typed conflicting_flags error. */
10
+ export declare function assertModeFlagsExclusive(options: {
11
+ live?: boolean;
12
+ dryRun?: boolean;
13
+ }): void;
14
+ /** Resolve run mode from `--live`/`--dry-run` flags: live only when `--live` is set, otherwise dry_run. */
15
+ export declare function resolveLiveDryRunMode(options: {
16
+ live?: boolean;
17
+ dryRun?: boolean;
18
+ }): "live" | "dry_run";
@@ -0,0 +1,66 @@
1
+ // Shared CLI value/option parsers, extracted so the same reader has exactly one
2
+ // definition instead of byte-identical copies drifting apart across index.ts and
3
+ // run-wait.ts (OXY-3733). Every helper here is pure and depends only on the
4
+ // leaf `OxygenError` primitive plus `readOption` from `./util.js`, so this module
5
+ // is a dependency-free leaf that index.ts and run-wait.ts can both import without
6
+ // a cycle. All symbols throw the same typed OxygenError codes the copies did.
7
+ import { OxygenError } from "@oxygen/shared";
8
+ import { readOption } from "./util.js";
9
+ /** Parse a `--limit`/`--interval`-style flag into a positive integer, or undefined when absent. */
10
+ export function readPositiveInt(value) {
11
+ const trimmed = value?.trim();
12
+ if (!trimmed)
13
+ return undefined;
14
+ const parsed = Number(trimmed);
15
+ if (!Number.isInteger(parsed) || parsed < 1) {
16
+ throw new OxygenError("invalid_number", "Expected a positive integer.", {
17
+ details: { value },
18
+ exitCode: 1,
19
+ });
20
+ }
21
+ return parsed;
22
+ }
23
+ /** Read a string field from an unknown record-shaped value, or null when it is missing/non-string. */
24
+ export function readRecordString(value, key) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value))
26
+ return null;
27
+ const entry = value[key];
28
+ return typeof entry === "string" ? entry : null;
29
+ }
30
+ /** Parse a JSON string that must decode to a plain object, throwing a typed invalid_json error otherwise. */
31
+ export function parseJsonObject(value) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(value);
35
+ }
36
+ catch (error) {
37
+ throw new OxygenError("invalid_json", "Input must be valid JSON.", {
38
+ details: { reason: error instanceof Error ? error.message : "unknown" },
39
+ exitCode: 1,
40
+ });
41
+ }
42
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
43
+ throw new OxygenError("invalid_json", "Input JSON must be an object.", {
44
+ exitCode: 1,
45
+ });
46
+ }
47
+ return parsed;
48
+ }
49
+ /** Read a JSON-object CLI option (e.g. `--selection-json`, `--filter-tree-json`), or undefined when absent. */
50
+ export function readJsonObjectOption(value) {
51
+ const raw = readOption(value);
52
+ if (!raw)
53
+ return undefined;
54
+ return parseJsonObject(raw);
55
+ }
56
+ /** Reject the mutually-exclusive `--live` + `--dry-run` combination with a typed conflicting_flags error. */
57
+ export function assertModeFlagsExclusive(options) {
58
+ if (options.live === true && options.dryRun === true) {
59
+ throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
60
+ }
61
+ }
62
+ /** Resolve run mode from `--live`/`--dry-run` flags: live only when `--live` is set, otherwise dry_run. */
63
+ export function resolveLiveDryRunMode(options) {
64
+ assertModeFlagsExclusive(options);
65
+ return options.live === true ? "live" : "dry_run";
66
+ }
@@ -26,10 +26,30 @@ type CredentialProfilesState = {
26
26
  profiles: CredentialProfile[];
27
27
  };
28
28
  export declare function defaultApiUrl(env?: NodeJS.ProcessEnv): string;
29
+ /**
30
+ * Which host this command will talk to, and what put it there — resolved from the
31
+ * same read that loads the credentials, so naming it costs nothing.
32
+ *
33
+ * A gate rejection that omits this is unreadable: dev's numbers and prod's numbers
34
+ * look identical, and a drifted profile turns a prod-labelled binary into a report
35
+ * about dev that reads as a production outage (OXY-4091).
36
+ */
37
+ export type CliEndpointContext = {
38
+ apiUrl: string;
39
+ /** Active profile name, or null when no named profile supplied the credentials. */
40
+ profile: string | null;
41
+ apiUrlSource: "env" | "profile" | "default";
42
+ };
43
+ export type LoadedCredentials = {
44
+ credentials: StoredCredentials | null;
45
+ endpoint: CliEndpointContext;
46
+ };
47
+ export declare function loadCredentialsWithEndpoint(env?: NodeJS.ProcessEnv): Promise<LoadedCredentials>;
29
48
  export declare function loadCredentials(env?: NodeJS.ProcessEnv): Promise<StoredCredentials | null>;
30
49
  export declare function saveCredentials(credentials: StoredCredentials, env?: NodeJS.ProcessEnv, options?: {
31
50
  profile?: string;
32
51
  activate?: boolean;
52
+ allowHostChange?: boolean;
33
53
  }): Promise<string>;
34
54
  export declare function clearCredentials(env?: NodeJS.ProcessEnv, options?: {
35
55
  profile?: string;
@@ -46,11 +66,11 @@ export type ProfileResolution = {
46
66
  exists: boolean;
47
67
  };
48
68
  export declare function resolveActiveProfile(env?: NodeJS.ProcessEnv): Promise<ProfileResolution>;
49
- export declare function pickProfileNameForIdentity(organizationId: string, candidateSeed: string, env?: NodeJS.ProcessEnv): Promise<{
69
+ export declare function pickProfileNameForIdentity(organizationId: string, candidateSeed: string, apiUrl: string, env?: NodeJS.ProcessEnv): Promise<{
50
70
  name: string;
51
71
  renamed: boolean;
52
72
  }>;
53
- export declare function pickProfileNameForUserSession(userId: string, candidateSeed: string, env?: NodeJS.ProcessEnv): Promise<{
73
+ export declare function pickProfileNameForUserSession(userId: string, candidateSeed: string, apiUrl: string, env?: NodeJS.ProcessEnv): Promise<{
54
74
  name: string;
55
75
  renamed: boolean;
56
76
  }>;
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { OxygenError } from "@oxygen/shared";
7
+ import { sameApiOrigin } from "./runtime.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  const SERVICE_NAME = "oxygen-cli";
9
10
  const ACCOUNT_NAME = "default";
@@ -27,7 +28,11 @@ function withApiUrlOverride(credentials, env) {
27
28
  return credentials;
28
29
  return { ...credentials, apiUrl: override };
29
30
  }
30
- export async function loadCredentials(env = process.env) {
31
+ export async function loadCredentialsWithEndpoint(env = process.env) {
32
+ // OXYGEN_API_URL outranks every stored apiUrl (see withApiUrlOverride), so when
33
+ // it is set it IS the reason we are calling this host — and unsetting it, not
34
+ // switching profiles, is the remedy.
35
+ const envApiUrl = readEnvApiUrl(env);
31
36
  const envToken = readEnvToken(env);
32
37
  if (envToken) {
33
38
  const credentials = {
@@ -37,17 +42,41 @@ export async function loadCredentials(env = process.env) {
37
42
  const authKind = inferAuthKind(envToken);
38
43
  if (authKind)
39
44
  credentials.authKind = authKind;
40
- return credentials;
45
+ return {
46
+ credentials,
47
+ endpoint: {
48
+ apiUrl: credentials.apiUrl,
49
+ profile: null,
50
+ apiUrlSource: envApiUrl ? "env" : "default",
51
+ },
52
+ };
41
53
  }
54
+ const unauthenticated = {
55
+ credentials: null,
56
+ endpoint: {
57
+ apiUrl: defaultApiUrl(env),
58
+ profile: null,
59
+ apiUrlSource: envApiUrl ? "env" : "default",
60
+ },
61
+ };
42
62
  const document = await readSystemCredentialDocument(env);
43
63
  if (!document)
44
- return null;
64
+ return unauthenticated;
45
65
  const profiles = readProfiles(document);
46
66
  const requestedProfile = readEnvProfile(env);
47
67
  const activeProfile = requestedProfile ?? readProfileName(document.activeProfile) ?? DEFAULT_PROFILE_NAME;
48
68
  const profileCredentials = profiles[activeProfile];
49
- if (profileCredentials)
50
- return withApiUrlOverride(profileCredentials, env);
69
+ if (profileCredentials) {
70
+ const credentials = withApiUrlOverride(profileCredentials, env);
71
+ return {
72
+ credentials,
73
+ endpoint: {
74
+ apiUrl: credentials.apiUrl,
75
+ profile: activeProfile,
76
+ apiUrlSource: envApiUrl ? "env" : "profile",
77
+ },
78
+ };
79
+ }
51
80
  if (requestedProfile) {
52
81
  throw new OxygenError("profile_not_found", `Oxygen CLI profile "${requestedProfile}" is not stored.`, {
53
82
  details: { profile: requestedProfile },
@@ -55,18 +84,47 @@ export async function loadCredentials(env = process.env) {
55
84
  });
56
85
  }
57
86
  const storedCredentials = parseStoredCredentialsObject(document);
58
- return storedCredentials ? withApiUrlOverride(storedCredentials, env) : null;
87
+ if (!storedCredentials)
88
+ return unauthenticated;
89
+ const credentials = withApiUrlOverride(storedCredentials, env);
90
+ return {
91
+ credentials,
92
+ endpoint: {
93
+ apiUrl: credentials.apiUrl,
94
+ profile: null,
95
+ apiUrlSource: envApiUrl ? "env" : "profile",
96
+ },
97
+ };
98
+ }
99
+ export async function loadCredentials(env = process.env) {
100
+ return (await loadCredentialsWithEndpoint(env)).credentials;
59
101
  }
60
102
  export async function saveCredentials(credentials, env = process.env, options = {}) {
61
103
  const normalized = normalizeStoredCredentialsRecord(credentials);
62
104
  const existing = await readSystemCredentialDocument(env);
63
105
  const profile = normalizeProfileName(options.profile ?? readEnvProfile(env) ?? existing?.activeProfile ?? DEFAULT_PROFILE_NAME);
64
106
  const profiles = existing ? readProfiles(existing) : {};
65
- if (!normalized.identity) {
66
- const prior = profiles[profile];
67
- if (prior?.identity && prior.token === normalized.token) {
68
- normalized.identity = prior.identity;
69
- }
107
+ // A stored profile is bound to the host it was logged into. Without this, a dev
108
+ // login that names no profile writes its `apiUrl` into whatever profile is
109
+ // active — which is how the profile literally named `default` came to point at
110
+ // dev, taking every prod runbook with it (OXY-4109). Repointing is still
111
+ // possible, but it has to be asked for.
112
+ const prior = profiles[profile];
113
+ if (prior && !options.allowHostChange && !sameApiOrigin(prior.apiUrl, normalized.apiUrl)) {
114
+ throw new OxygenError("profile_host_conflict", `CLI profile "${profile}" is bound to ${prior.apiUrl}, and this login targets ${normalized.apiUrl}. `
115
+ + `Storing it here would silently repoint every command that uses "${profile}" at the other `
116
+ + "environment. Re-run with `--profile <name>` to keep them apart, or `--force` to repoint "
117
+ + `"${profile}" on purpose.`, {
118
+ details: {
119
+ profile,
120
+ stored_api_url: prior.apiUrl,
121
+ requested_api_url: normalized.apiUrl,
122
+ },
123
+ exitCode: 1,
124
+ });
125
+ }
126
+ if (!normalized.identity && prior?.identity && prior.token === normalized.token) {
127
+ normalized.identity = prior.identity;
70
128
  }
71
129
  profiles[profile] = normalized;
72
130
  const activeProfile = options.activate === false
@@ -159,10 +217,15 @@ export async function resolveActiveProfile(env = process.env) {
159
217
  : "default";
160
218
  return { name: candidate, source, credentials, exists: Boolean(credentials) };
161
219
  }
162
- async function pickProfileName(matches, candidateSeed, env) {
220
+ async function pickProfileName(matches, candidateSeed, apiUrl, env) {
163
221
  const document = await readSystemCredentialDocument(env);
164
222
  const profiles = document ? readProfiles(document) : {};
165
- const existing = Object.entries(profiles).find(([, credentials]) => matches(credentials));
223
+ // Identity alone is not enough to reuse a profile: the same person and the same
224
+ // org slug exist on dev and on prod, so matching on identity across hosts is
225
+ // what lets a dev login land on top of a production profile (OXY-4109). A
226
+ // profile is only reusable when it already points at the host we are logging in
227
+ // to; otherwise we name a new one.
228
+ const existing = Object.entries(profiles).find(([, credentials]) => sameApiOrigin(credentials.apiUrl, apiUrl) && matches(credentials));
166
229
  if (existing)
167
230
  return { name: existing[0], renamed: false };
168
231
  const base = normalizeProfileName(candidateSeed);
@@ -178,13 +241,13 @@ async function pickProfileName(matches, candidateSeed, env) {
178
241
  exitCode: 1,
179
242
  });
180
243
  }
181
- export async function pickProfileNameForIdentity(organizationId, candidateSeed, env = process.env) {
244
+ export async function pickProfileNameForIdentity(organizationId, candidateSeed, apiUrl, env = process.env) {
182
245
  return pickProfileName((credentials) => credentials.identity?.organization?.id === organizationId ||
183
- credentials.activeOrganization?.id === organizationId, candidateSeed, env);
246
+ credentials.activeOrganization?.id === organizationId, candidateSeed, apiUrl, env);
184
247
  }
185
- export async function pickProfileNameForUserSession(userId, candidateSeed, env = process.env) {
248
+ export async function pickProfileNameForUserSession(userId, candidateSeed, apiUrl, env = process.env) {
186
249
  return pickProfileName((credentials) => credentials.authKind === "user_session" &&
187
- credentials.identity?.user.id === userId, candidateSeed, env);
250
+ credentials.identity?.user.id === userId, candidateSeed, apiUrl, env);
188
251
  }
189
252
  export async function switchCredentialProfile(profile, env = process.env) {
190
253
  const document = await readSystemCredentialDocument(env);
package/dist/help.js CHANGED
@@ -22,7 +22,7 @@ const HELP_GROUPS = [
22
22
  heading: "Data (tables, CRM, dashboards):",
23
23
  commands: [
24
24
  "tables", "rows", "cells", "columns", "action-column", "enrich-column",
25
- "enrichment", "table-runs", "table-ingestions", "runs", "crm",
25
+ "enrichment", "table-runs", "table-ingestions", "runs", "crm", "signals",
26
26
  "dashboards", "projects", "signup-leads",
27
27
  ],
28
28
  },
@@ -1,4 +1,5 @@
1
1
  import { type StoredCredentials } from "./credentials.js";
2
+ import { type CliEndpoint } from "./runtime.js";
2
3
  type RequestOptions = {
3
4
  method?: "GET" | "POST" | "PATCH" | "DELETE";
4
5
  body?: Record<string, unknown>;
@@ -16,5 +17,6 @@ export declare function requestOxygen<T>(// skipcq: JS-R1005
16
17
  path: string, options?: RequestOptions): Promise<T>;
17
18
  export declare function ensureFreshCliForApiUrl(apiUrl: string, options?: {
18
19
  fetch?: typeof fetch;
20
+ endpoint?: CliEndpoint;
19
21
  }): Promise<void>;
20
22
  export {};
@@ -1,23 +1,33 @@
1
1
  import { OXYGEN_VERSION, OxygenError, isCliResult, isVersionGreater, readEnvelopeCompatibility, vercelProtectionBypassHeaders, withRetryAfterDetails, } from "@oxygen/shared";
2
2
  import { randomUUID } from "node:crypto";
3
- import { defaultApiUrl, loadCredentials } from "./credentials.js";
4
- import { isProdApiUrl, resolveCliUpdateGuidance } from "./runtime.js";
3
+ import { defaultApiUrl, loadCredentialsWithEndpoint } from "./credentials.js";
4
+ import { assertResolvedApiUrl, cliEndpointDetails, describeCliEndpoint, isProdApiUrl, resolveCliUpdateGuidance, } from "./runtime.js";
5
5
  const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
6
6
  const CLI_COMPATIBILITY_CHECK_TIMEOUT_MS = 5_000;
7
7
  const cliCompatibilityCheckKeys = new Set();
8
8
  export async function requestOxygen(// skipcq: JS-R1005
9
9
  path, options = {}) {
10
- const credentials = options.credentials
11
- ?? (options.requireAuth === false ? null : await loadCredentials());
10
+ const loaded = options.credentials === undefined && options.requireAuth !== false
11
+ ? await loadCredentialsWithEndpoint()
12
+ : null;
13
+ const credentials = options.credentials ?? loaded?.credentials ?? null;
12
14
  if (!credentials && options.requireAuth !== false) {
13
15
  throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", {
14
16
  exitCode: 1,
15
17
  });
16
18
  }
17
19
  const apiUrl = credentials?.apiUrl ?? defaultApiUrl();
20
+ // Which host, on whose behalf. Carried into every rejection below — a gate error
21
+ // that names only version numbers describes dev and prod identically (OXY-4091).
22
+ const endpoint = loaded?.endpoint ?? { apiUrl, profile: null };
23
+ // Before anything is dialed: if the caller pinned a host, this is where a
24
+ // command aimed at the wrong environment stops. It has to run ahead of the
25
+ // freshness probe, or the first thing a mispointed prod runbook gets is a
26
+ // version complaint from dev instead of the host mismatch that explains it.
27
+ assertResolvedApiUrl(endpoint);
18
28
  const fetchImpl = options.fetch ?? fetch;
19
29
  if (path !== "/api/health" && shouldCheckCliCompatibility(options)) {
20
- await ensureFreshCliForApiUrl(apiUrl, { fetch: fetchImpl });
30
+ await ensureFreshCliForApiUrl(apiUrl, { fetch: fetchImpl, endpoint });
21
31
  }
22
32
  const traceId = options.traceId ?? randomUUID();
23
33
  const headers = {
@@ -93,14 +103,14 @@ path, options = {}) {
93
103
  }
94
104
  const envelope = await readEnvelope(response);
95
105
  const compatibility = readEnvelopeCompatibility(envelope);
96
- assertCliMeetsMinimumApiVersion(compatibility, options, apiUrl);
97
- warnIfCliIsOlderThanApi(compatibility.version, apiUrl);
106
+ assertCliMeetsMinimumApiVersion(compatibility, options, endpoint);
107
+ warnIfCliIsOlderThanApi(compatibility.version, endpoint);
98
108
  if (!response.ok || !envelope.ok) {
99
109
  const failure = envelope;
100
110
  const responseTraceId = response.headers.get("x-oxygen-trace-id") ?? traceId;
101
111
  const details = withRetryAfterDetails(failure.error.details, response);
102
- throw new OxygenError(failure.error.code, failure.error.message, {
103
- details: withTraceDetails(details, responseTraceId, compatibility, apiUrl),
112
+ throw new OxygenError(failure.error.code, describeFailureAgainstEndpoint(failure.error.code, failure.error.message, endpoint), {
113
+ details: withTraceDetails(details, responseTraceId, compatibility, endpoint),
104
114
  exitCode: 1,
105
115
  });
106
116
  }
@@ -114,10 +124,31 @@ export async function ensureFreshCliForApiUrl(apiUrl, options = {}) {
114
124
  if (!compatibility) {
115
125
  return;
116
126
  }
117
- assertCliMeetsMinimumApiVersion(compatibility, {}, apiUrl);
118
- warnIfCliIsOlderThanApi(compatibility.version, apiUrl);
127
+ const endpoint = options.endpoint ?? { apiUrl, profile: null };
128
+ assertCliMeetsMinimumApiVersion(compatibility, {}, endpoint);
129
+ warnIfCliIsOlderThanApi(compatibility.version, endpoint);
119
130
  cliCompatibilityCheckKeys.add(checkKey);
120
131
  }
132
+ // The codes that mean "you may be talking to the wrong place": a floor rejection
133
+ // and an auth rejection both read as catastrophic when the reader assumes prod and
134
+ // the request went to dev. The server cannot know the caller's profile, so the
135
+ // client appends what only it knows. `cli_update_required` is rebuilt outright —
136
+ // the server's stock "run `oxygen update`" is actively wrong advice for a CLI that
137
+ // is merely pointed at the wrong host (OXY-4091).
138
+ const ENDPOINT_CONTEXT_CODES = new Set(["unauthorized", "forbidden"]);
139
+ function describeFailureAgainstEndpoint(code, message, endpoint) {
140
+ const target = describeCliEndpoint(endpoint);
141
+ if (!target)
142
+ return message;
143
+ if (code === "cli_update_required") {
144
+ return `This Oxygen API requires a newer CLI. You are calling ${target}. `
145
+ + `${resolveCliUpdateGuidance(endpoint).failureInstruction}`;
146
+ }
147
+ if (ENDPOINT_CONTEXT_CODES.has(code)) {
148
+ return `${message} You are calling ${target}.`;
149
+ }
150
+ return message;
151
+ }
121
152
  function resolveSelectedOrganization(credentials, explicit) {
122
153
  const fromOption = explicit?.trim();
123
154
  if (fromOption)
@@ -161,12 +192,12 @@ async function readHealthCompatibility(apiUrl, fetchImpl) {
161
192
  }
162
193
  }
163
194
  const staleCliWarningVersions = new Set();
164
- function warnIfCliIsOlderThanApi(serverVersion, apiUrl) {
195
+ function warnIfCliIsOlderThanApi(serverVersion, endpoint) {
165
196
  if (!serverVersion)
166
197
  return;
167
198
  if (!isVersionGreater(serverVersion, OXYGEN_VERSION))
168
199
  return;
169
- const guidance = resolveCliUpdateGuidance(apiUrl);
200
+ const guidance = resolveCliUpdateGuidance(endpoint);
170
201
  const warningKey = `${serverVersion}|${guidance.channel}|${guidance.binaryName}`;
171
202
  if (staleCliWarningVersions.has(warningKey))
172
203
  return;
@@ -174,28 +205,35 @@ function warnIfCliIsOlderThanApi(serverVersion, apiUrl) {
174
205
  process.stderr.write(`[${guidance.binaryName}] CLI version ${OXYGEN_VERSION} is older than Oxygen API version ${serverVersion}. `
175
206
  + `${guidance.warningInstruction}\n`);
176
207
  }
177
- function assertCliMeetsMinimumApiVersion(compatibility, options, apiUrl) {
208
+ function assertCliMeetsMinimumApiVersion(compatibility, options, endpoint) {
178
209
  if (options.enforceMinimumCliVersion === false)
179
210
  return;
180
211
  const minimumCliVersion = compatibility.minimumCliVersion;
181
212
  if (!minimumCliVersion || !isVersionGreater(minimumCliVersion, OXYGEN_VERSION))
182
213
  return;
183
- const guidance = resolveCliUpdateGuidance(apiUrl);
184
- throw new OxygenError("cli_update_required", `This Oxygen API requires a newer CLI. ${guidance.failureInstruction}`, {
214
+ const guidance = resolveCliUpdateGuidance(endpoint);
215
+ const target = describeCliEndpoint(endpoint);
216
+ throw new OxygenError("cli_update_required", `This Oxygen API requires a newer CLI.${target ? ` You are calling ${target}.` : ""} `
217
+ + `${guidance.failureInstruction}`, {
185
218
  details: {
186
219
  client_version: OXYGEN_VERSION,
187
220
  ...(compatibility.version ? { server_version: compatibility.version } : {}),
188
221
  minimum_cli_version: minimumCliVersion,
222
+ ...cliEndpointDetails(endpoint),
189
223
  ...guidance.details,
190
224
  },
191
225
  exitCode: 1,
192
226
  });
193
227
  }
194
- function withTraceDetails(details, traceId, compatibility, apiUrl) {
228
+ function withTraceDetails(details, traceId, compatibility, endpoint) {
195
229
  const serverVersion = compatibility.version;
230
+ const guidance = resolveCliUpdateGuidance(endpoint);
196
231
  const fields = {
197
232
  trace_id: traceId,
198
233
  client_version: OXYGEN_VERSION,
234
+ // Every failure carries the host it was sent to. `unauthorized` on the wrong
235
+ // host looks exactly like a revoked token on the right one (OXY-4091).
236
+ ...cliEndpointDetails(endpoint),
199
237
  };
200
238
  if (serverVersion)
201
239
  fields.server_version = serverVersion;
@@ -204,20 +242,20 @@ function withTraceDetails(details, traceId, compatibility, apiUrl) {
204
242
  if ((serverVersion && isVersionGreater(serverVersion, OXYGEN_VERSION))
205
243
  || (compatibility.minimumCliVersion
206
244
  && isVersionGreater(compatibility.minimumCliVersion, OXYGEN_VERSION))) {
207
- Object.assign(fields, resolveCliUpdateGuidance(apiUrl).details);
208
- }
209
- if (details && typeof details === "object" && !Array.isArray(details)) {
210
- return {
211
- ...details,
212
- ...fields,
213
- };
245
+ Object.assign(fields, guidance.details);
214
246
  }
215
- if (details === undefined)
216
- return fields;
217
- return {
218
- details,
219
- ...fields,
220
- };
247
+ const merged = details && typeof details === "object" && !Array.isArray(details)
248
+ ? { ...details, ...fields }
249
+ : details === undefined
250
+ ? { ...fields }
251
+ : { details, ...fields };
252
+ // The server tells every rejected CLI to run `oxygen update`, because it cannot
253
+ // see which binary or profile called it. On a non-production endpoint that is a
254
+ // closed loop — npm can only reinstall the version this floor is rejecting — so
255
+ // the client, which does know, removes the fix that cannot work (OXY-4091).
256
+ if (guidance.channel === "profile")
257
+ delete merged.cli_update_command;
258
+ return merged;
221
259
  }
222
260
  function resolveRequestTimeoutMs(value) {
223
261
  if (value === undefined)