diff --git a/README.md b/README.md index 6e8b4f5..b82e7c5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ norbix scheduler list | `norbix files list/info/get-by-id/upload/download/sign/delete/publish/unpublish` | Upload, download, manage and publish files | | `norbix files integrations test ` | Check that a files integration really works (live upload/read/list/delete probe) | | `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) | -| `norbix env list/use/delete` | Manage project environments | +| `norbix env list/use/delete` | Manage project environments (see *Environments* below) | | `norbix logs list/trail` | Read project logs, follow one correlation ID | | `norbix scheduler list/get/save/enable/disable/delete` | Scheduler tasks — a cron that sends an email campaign; every scheduler endpoint, see [docs/scheduler.md](docs/scheduler.md) | | `norbix apikeys list/create/regenerate/revoke` | Service-user API keys for scripts, CI, SDKs and agents | @@ -259,6 +259,16 @@ The Hub version in every path (`/v3/...`) comes from the Hub's `/echo`; set `NORBIX_HUB_VERSION` or `hub_version` in a profile only to override it. The full design is in `AUTH_DESIGN.md`. +### Environments + +A project with only PROD needs no env: every command works in PROD, with or +without `--env PROD`. A project with more than one environment (PROD, TEST, …) +needs the env named on every project command — `--env TEST`, `--prod` (short +for `--env PROD`), `NORBIX_ENV`, or a default saved with `norbix env use TEST` +(then printed on stderr by every command). Without it the command stops with +exit 2 `ENV_REQUIRED` and lists the environments; nothing is sent. Apps that +use the API or an SDK directly are not affected: with no env they work in PROD. + ## JSON output Every command supports `--json` and prints clean JSON on stdout, so you can diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 9e65559..646fddf 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -51,6 +51,26 @@ command exits 4 with `error.code` `SESSION_EXPIRED`: sign in again as above. Never print, echo or paste a key or token, and never read `~/.norbix`. +### Environments: name one when the project has several + +A project always has PROD and may have more (TEST, STAGING, …). Check first: +`norbix whoami --json` (`environments`, `envRule`) or `norbix env list --json`. + +- **Only PROD:** pass no env — every command works in PROD (`--env PROD` / + `--prod` changes nothing). +- **More than one:** every project command needs the env: `--env ` + (`--prod` is short for `--env PROD`) or `NORBIX_ENV`. Ask the user which one + when the task does not say it — never assume PROD. Without it the command + exits 2 with `error.code` `ENV_REQUIRED` and the list of environments, and + sends nothing (a `--dry-run` fails the same way). +- A default saved with `norbix env use ` (or `env =` in a profile) + also counts; each command then prints `env: ()` + on stderr. Read that line. +- Account commands (no project) and `norbix env …` are not checked. + +The list of environments is cached for 5 minutes per project; `env list / +use / delete` and an "environment does not exist" error refresh it. + ## 2. Always `--json` Stdout is then exactly one JSON document: the result, or diff --git a/docs/agent-contract.md b/docs/agent-contract.md index ebadb25..5455de1 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -46,7 +46,7 @@ prints points back here through its `hint` and `docs` fields. |---|---|---| | 0 | success | | | 1 | unexpected / internal error | file an issue, include `traceId` | -| 2 | usage: bad flags or args, invalid JSON input, unknown command, missing config | `norbix --help` / `norbix schema --json` | +| 2 | usage: bad flags or args, invalid JSON input, unknown command, missing config; `ENV_REQUIRED` — the project has more than one environment and none was named | `norbix --help` / `norbix schema --json`; for `ENV_REQUIRED` add `--env ` (the message lists them) | | 3 | confirmation required (non-interactive shell, no `--yes`) | re-run with `--yes` after a `--dry-run` | | 4 | not authenticated / auth rejected (401, 403, no key); a browser sign-in denied (`ACCESS_DENIED`), not approved in time (`EXPIRED_TOKEN`), refused by the Hub (`INVALID_GRANT`, `INVALID_REQUEST`) or ended (`SESSION_EXPIRED`); `login --wait` not approved yet (`AUTHORIZATION_PENDING` — run it again) | `norbix login` (agents: `--no-browser --json`, then `--wait`), `--api-key`, `--profile`, `NORBIX_API_KEY` | | 5 | not found (404) | check the id, `--env`, `--project` | diff --git a/src/base.ts b/src/base.ts index 7662245..c0fd2f9 100644 --- a/src/base.ts +++ b/src/base.ts @@ -2,11 +2,15 @@ import {Norbix} from '@norbix.ai/ts' import {Command, Flags, ux} from '@oclif/core' import {CliError, formatErrorText, toEnvelope, usageError} from './lib/cli-error.js' +import {checkEnv, envsOf, type EnvSource} from './lib/env-rule.js' import {EXIT} from './lib/exit-codes.js' import { DEFAULT_HOST, cachedHost, + cachedProjectEnvs, cachedProjectRegion, + forgetProjectEnvs, + saveProjectEnvs, fetchProjectRegion, saveProjectRegion, forgetHost, @@ -88,6 +92,7 @@ export interface GlobalFlags { host?: string project?: string env?: string + prod?: boolean region?: string language?: string 'api-key'?: string @@ -101,6 +106,8 @@ export interface ResolvedContext { projectId?: string accountId?: string env?: string + /** Where `env` came from (the env rule: src/lib/env-rule.ts). */ + envSource: EnvSource region?: string apiKey?: string bearerToken?: string @@ -215,10 +222,15 @@ export abstract class BaseCommand extends Command { helpGroup: 'GLOBAL', }), env: Flags.string({ - description: 'Project environment (e.g. PROD, TEST)', + description: + 'Project environment (e.g. PROD, TEST). Required when the project has more than one (`norbix env list`); optional when it has only PROD', env: 'NORBIX_ENV', helpGroup: 'GLOBAL', }), + prod: Flags.boolean({ + description: 'Short for --env PROD', + helpGroup: 'GLOBAL', + }), region: Flags.string({ description: 'Region code (e.g. nb-eu-germany)', env: 'NORBIX_REGION', @@ -556,11 +568,19 @@ export abstract class BaseCommand extends Command { const usesDefaultEndpoints = apiUrlBase === DEFAULT_API_URL || hubUrlBase === DEFAULT_HUB_URL + const {env, envSource} = this.resolveEnv(flags, { + profile: prof.env, + profileName, + session: session?.env, + legacy: fromLegacy(legacy.env), + }) + return { usesDefaultEndpoints, projectId, accountId: flags.account ?? prof.account_id ?? session?.accountId ?? fromLegacy(legacy.accountId), - env: flags.env ?? prof.env ?? session?.env ?? fromLegacy(legacy.env), + env, + envSource, region, apiKey, bearerToken, @@ -589,6 +609,80 @@ export abstract class BaseCommand extends Command { } } + /** + * The env of this run and where it came from: --prod, --env, NORBIX_ENV, + * then a saved one (profile, `norbix env use` in the session, old config). + */ + private resolveEnv( + flags: GlobalFlags, + saved: {profile?: string; profileName?: string; session?: string; legacy?: string}, + ): {env?: string; envSource: EnvSource} { + const typedEnv = this.argv.some((a) => a === '--env' || a.startsWith('--env=')) + if (flags.prod) { + if (typedEnv && flags.env && flags.env.trim().toUpperCase() !== 'PROD') { + throw usageError(`--prod and --env ${flags.env} name different environments.`, 'Pass one of them.', 'norbix env list --help') + } + + return {env: 'PROD', envSource: '--prod'} + } + + const clean = (v?: string) => v?.trim().toUpperCase() || undefined + if (clean(flags.env)) { + const fromVariable = !typedEnv && clean(process.env.NORBIX_ENV) === clean(flags.env) + return {env: clean(flags.env), envSource: fromVariable ? 'NORBIX_ENV' : '--env'} + } + + if (clean(saved.profile)) return {env: clean(saved.profile), envSource: `profile [${saved.profileName ?? 'default'}]`} + if (clean(saved.session)) return {env: clean(saved.session), envSource: 'saved by norbix env use'} + if (clean(saved.legacy)) return {env: clean(saved.legacy), envSource: 'old config.json'} + return {envSource: 'none'} + } + + /** The context of the last client built — `catch` uses it to drop a stale env list. */ + private lastCtx?: ResolvedContext + + /** Drop the cached environment list of the project (env commands, env errors). */ + protected forgetEnvs(ctx: ResolvedContext | undefined = this.lastCtx): void { + if (ctx?.projectId) forgetProjectEnvs(ctx.hubKey, ctx.projectId) + } + + /** Remember a freshly read environment list of the project (`env list`). */ + protected rememberEnvs(ctx: ResolvedContext, response: unknown): void { + const envs = envsOf(response) + if (ctx.projectId && envs) saveProjectEnvs(ctx.hubKey, ctx.projectId, envs) + } + + /** The env rule ran once in this process (one check per command). */ + private envChecked?: Promise + + /** + * The env rule (src/lib/env-rule.ts) for a project-scoped call: read the + * project's environments (cache, else the Hub — through the same client), + * refuse when there are several and none is named, print a saved env on + * stderr. A dry run sends nothing, so it uses the cache only. + */ + private checkProjectEnv(ctx: ResolvedContext, client: () => Norbix, dryRun: boolean): Promise { + this.envChecked ??= (async () => { + if (!ctx.projectId) return + let envs = cachedProjectEnvs(ctx.hubKey, ctx.projectId) + if (!envs && !dryRun) { + try { + envs = envsOf(await client().hub.environments.list()) + if (envs) saveProjectEnvs(ctx.hubKey, ctx.projectId, envs) + } catch (error) { + // The sign-in ended, the Hub is down: let the command itself report it. + const raw = (error as {raw?: unknown}).raw + if (error instanceof CliError || raw instanceof CliError) throw error + envs = undefined + } + } + + const verdict = checkEnv(dryRun && !envs ? ['PROD'] : envs, {env: ctx.env, source: ctx.envSource}) + if (verdict.notice) process.stderr.write(verdict.notice + '\n') + })() + return this.envChecked + } + /** One warning per deprecated setting and run, on stderr (stdout stays one JSON document). */ private warnDeprecated(names: string[], origin: string): void { for (const name of names) { @@ -675,6 +769,7 @@ export abstract class BaseCommand extends Command { opts: {requireAuth?: boolean; requireProject?: boolean; account?: boolean} = {}, ): Norbix { const ctx = this.resolveContext(flags) + this.lastCtx = ctx const noProject = !ctx.projectId && opts.requireProject === false // No project first: on norbix.ai the region comes from the project, so // "Region is required" would hide the real cause (and its project list). @@ -694,13 +789,22 @@ export abstract class BaseCommand extends Command { }) } - // The last middleware runs first: a dry run stops before any refresh. + // The last middleware runs first: a dry run stops before any refresh, + // and the env rule runs before both (a dry run must fail like the real call). const middleware: NorbixMiddleware[] = noProject ? [dropProjectHeaders] : [] const refresher = this.sessionRefresher(ctx) if (refresher) middleware.push(sessionMiddleware(refresher)) if (flags['dry-run']) middleware.push(dryRunMiddleware) + if (!noProject && opts.account !== true) { + middleware.push(async (call) => { + if (!isEnvManagementCall(call.url)) await this.checkProjectEnv(ctx, () => client, Boolean(flags['dry-run'])) + return call.next() + }) + } - const client = new Norbix( + // eslint-disable-next-line prefer-const -- the env-rule middleware above reads it later + let client: Norbix + client = new Norbix( { projectId: noProject ? NO_PROJECT_PLACEHOLDER : ctx.projectId, middleware, @@ -887,6 +991,8 @@ export abstract class BaseCommand extends Command { const raw = (error as {raw?: unknown}).raw const reported = error instanceof NoProjectError ? await this.explainNoProject(error) : raw instanceof CliError ? raw : error const envelope = toEnvelope(reported, {command: this.commandName}) + // The project lost (or never had) that environment: read its list again next time. + if (envelope.code === ENV_NOT_FOUND_CODE) this.forgetEnvs() // A Hub that cannot be reached may have moved: discover it again next time. if (envelope.exit === EXIT.NETWORK) for (const origin of this.hostInfos.keys()) forgetHost(origin) if (this.jsonEnabled()) { @@ -905,6 +1011,22 @@ export abstract class BaseCommand extends Command { } } +/** The gateway's "environment does not exist on this project" error. */ +const ENV_NOT_FOUND_CODE = 'CM-ERRORS-ENVIRONMENTS-001' + +/** + * The project's environment list and environment management (list, create, + * delete, rank, promote) are not checked by the env rule: they are how the + * caller learns and changes which environments exist. + */ +export function isEnvManagementCall(url: string): boolean { + try { + return /\/account\/projects\/environments(\/|$)/.test(new URL(url).pathname) + } catch { + return false + } +} + /** `Finlo (66b2…)`, or the id alone. */ export function projectLabel(p: ProjectChoice): string { return p.name ? `${p.name} (${p.id})` : p.id diff --git a/src/commands/env/delete.ts b/src/commands/env/delete.ts index e9e3dfc..fc7c4c3 100644 --- a/src/commands/env/delete.ts +++ b/src/commands/env/delete.ts @@ -25,6 +25,8 @@ export default class EnvDelete extends BaseCommand { await this.confirmOrFail(`Delete environment "${args.name}" and everything inside it?`, flags) const res = await client.hub.environments.delete({environmentName: args.name}) + // The project has one environment less: read the list again next time. + this.forgetEnvs(this.resolveContext(flags)) this.print(`Environment ${args.name} deleted.`) return res } diff --git a/src/commands/env/list.ts b/src/commands/env/list.ts index d89dfd3..90e9de5 100644 --- a/src/commands/env/list.ts +++ b/src/commands/env/list.ts @@ -1,15 +1,20 @@ import {BaseCommand} from '../../base.js' export default class EnvList extends BaseCommand { - static description = "List the project's environments (PROD is always first)" + static description = `List the project's environments (PROD is always first) - static examples = ['<%= config.bin %> env list'] +When the project has more than one, every project-scoped command needs --env +(or --prod, NORBIX_ENV, or a default saved with \`norbix env use\`). With only PROD, no env is needed.` + + static examples = ['<%= config.bin %> env list', '<%= config.bin %> env list --json'] async run(): Promise { const {flags} = await this.parse(EnvList) const client = this.client(flags) const res = await client.hub.environments.list() + // A fresh list: the env rule of the next commands uses it. + this.rememberEnvs(this.resolveContext(flags), res) this.print(res) return res } diff --git a/src/commands/env/use.ts b/src/commands/env/use.ts index dabfcac..2b70302 100644 --- a/src/commands/env/use.ts +++ b/src/commands/env/use.ts @@ -8,7 +8,10 @@ export default class EnvUse extends BaseCommand { Written to the browser session of the host when you are signed in there; with --profile it is written into that profile in ~/.norbix/config; otherwise to the [default] -profile (created when missing). Override per command with --env.` +profile (created when missing). Override per command with --env. + +On a project with more than one environment, a saved default counts as naming the env, +and every project-scoped command prints it on stderr ("env: TEST (saved by norbix env use)").` static examples = [ '<%= config.bin %> env use TEST', @@ -27,6 +30,8 @@ profile (created when missing). Override per command with --env.` async run(): Promise { const {args, flags} = await this.parse(EnvUse) + // The next command reads the project's environment list again. + if (!flags['dry-run']) this.forgetEnvs(this.resolveContext(flags)) const dry = (target: string, file: string) => this.dryRun({method: 'env.use', request: {env: args.name, target, file}}) diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index b11c95f..5c7d481 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -37,6 +37,7 @@ export default class Whoami extends BaseCommand { projectId: ctx.projectId, accountId: ctx.accountId, env: ctx.env ?? 'PROD', + envSource: ctx.envSource, region: ctx.region ?? (ctx.usesDefaultEndpoints @@ -60,6 +61,7 @@ export default class Whoami extends BaseCommand { environments = (item?.environments ?? []).map((e) => typeof e === 'string' ? e : (e.name ?? String(e)), ) + this.rememberEnvs(ctx, res) verified = true } catch (error) { const raw = (error as {raw?: unknown}).raw @@ -81,18 +83,26 @@ export default class Whoami extends BaseCommand { ...(signIn ? signInLines(signIn) : []), `Project: ${summary.projectId ?? '(not set)'}`, `Account: ${summary.accountId ?? '(not set)'}`, - `Env: ${summary.env}`, + `Env: ${ctx.env ? `${ctx.env} (${ctx.envSource})` : 'PROD (none named)'}`, `Region: ${summary.region}`, verified ? `Server: OK${environments?.length ? ` — environments: ${environments.join(', ')}` : ''}` : `Server: not verified${verifyError ? ` (${verifyError})` : ''}`, + ...(environments ? [`Env rule: ${envRule(environments)}`] : []), ].join('\n'), ) - return {...summary, session: signIn, verified, environments, verifyError} + return {...summary, session: signIn, verified, environments, envRule: environments ? envRule(environments) : undefined, verifyError} } } +/** The env rule for this project, in one line (src/lib/env-rule.ts). */ +function envRule(environments: string[]): string { + return environments.length > 1 + ? `this project has ${environments.length} environments — every project command needs --env (or --prod, NORBIX_ENV, or \`norbix env use\`)` + : 'only PROD — no --env needed' +} + interface SignIn { method: 'browser' | 'password' userId?: string diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts index b9e63d6..7bb06fe 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -57,6 +57,7 @@ export function claudeSkill(): string { ' - `norbix login --no-browser --json` → `{userCode, verificationUriComplete, expiresIn}`. Show the user the link and the code: they open it, check the code, pick the roles you get, and press Allow. Never open the link yourself.', ' - `norbix login --wait --json` — waits up to 90 s. Exit 0: signed in. Exit 4 `AUTHORIZATION_PENDING`: not approved yet, run it again. `ACCESS_DENIED` or `EXPIRED_TOKEN`: start over with step one.', ' - Not on norbix.ai? Add `--host ` (or `--profile `) to both steps.', + ' Environments: `whoami` lists the project\'s `environments`. With only PROD, pass no env. With more than one (e.g. PROD, TEST), every project command needs `--env ` (`--prod` = `--env PROD`): ask the user which one, never assume PROD. Exit 2 `ENV_REQUIRED` lists them. `norbix env list --json` shows them too.', '2. Find the command: `norbix schema --json` lists every command with `destructive` and `supportsDryRun`; `norbix --help` has a realistic example. No named command? `norbix hub --json` (or `norbix api --json`) lists the SDK methods with their fields; call one with plain words and `--body \'\'`.', '3. Read with `--json` and parse the one document on stdout. Never parse the text output.', ' Designing a database schema? Run `norbix db schema rules` first and follow it: emails and links need a format (`email` / `uri`), language / currency / country and every list people pick from are taxonomy pickers (enum only for developer flags), user ids are user references, no createdBy / owner fields. A schema save that breaks a rule still succeeds but returns `warnings`: fix each named field and save again.', @@ -76,7 +77,7 @@ export function claudeSkill(): string { 'norbix hub scheduler --json # methods + fields', '```', '', - 'Context flags on every command: `--host`, `--profile`, `--project`, `--env`, `--region` (or `NORBIX_*` variables). Full contract: docs/agent-contract.md in norbix-code/cli.', + 'Context flags on every command: `--host`, `--profile`, `--project`, `--env` / `--prod`, `--region` (or `NORBIX_*` variables). Full contract: docs/agent-contract.md in norbix-code/cli.', '', ].join('\n') } diff --git a/src/lib/env-rule.ts b/src/lib/env-rule.ts new file mode 100644 index 0000000..550e1d3 --- /dev/null +++ b/src/lib/env-rule.ts @@ -0,0 +1,98 @@ +import {CliError} from './cli-error.js' +import {EXIT} from './exit-codes.js' + +/** + * The env rule (owner, 2026-10-14): + * - a project with only PROD needs no env — with or without `--env PROD` it works in PROD; + * - a project with more than one environment needs the caller to say which one. + * + * "Said" means `--env`, `--prod`, NORBIX_ENV, or an env saved on purpose + * (`norbix env use`, `env =` in a profile). A saved env is printed on stderr + * for every project-scoped command, so an agent always sees where it writes. + * + * The plain REST API and the SDKs are not touched: an app that sends no env + * still works in PROD. + */ + +/** Where the env of this run came from. */ +export type EnvSource = + | '--env' + | '--prod' + | 'NORBIX_ENV' + | 'saved by norbix env use' + | `profile [${string}]` + | 'old config.json' + | 'none' + +/** A source the user typed for this run (not a saved default). */ +export function isPerRunSource(source: EnvSource): boolean { + return source === '--env' || source === '--prod' || source === 'NORBIX_ENV' +} + +/** PROD first, then the rest alphabetically; upper case. */ +export function orderEnvs(envs: string[]): string[] { + const upper = [...new Set(envs.map((e) => e.trim().toUpperCase()).filter(Boolean))] + return upper.sort((a, b) => (a === 'PROD' ? -1 : b === 'PROD' ? 1 : a.localeCompare(b))) +} + +/** The environment names in a `GetProjectEnvironments` answer, or undefined when it has none. */ +export function envsOf(response: unknown): string[] | undefined { + const item = (response as {item?: {environments?: unknown}} | undefined)?.item + const list = item?.environments + if (!Array.isArray(list)) return undefined + const names = list + .map((e) => (typeof e === 'string' ? e : typeof e === 'object' && e && 'name' in e ? String((e as {name: unknown}).name) : '')) + .filter(Boolean) + return names.length > 0 ? orderEnvs(names) : undefined +} + +export interface EnvCheck { + /** The env named for this run, upper case; undefined = none. */ + env?: string + source: EnvSource +} + +/** What the check decided: go on (with an optional stderr line). */ +export interface EnvVerdict { + /** One line for stderr, or undefined. */ + notice?: string +} + +/** The usage error of a multi-env project with no env named. */ +export function envRequiredError(envs: string[]): CliError { + const list = envs.join(', ') + return new CliError({ + exit: EXIT.USAGE, + code: 'ENV_REQUIRED', + message: `This project has environments ${list}. Pass --env (or set NORBIX_ENV).`, + hint: + `Name the environment for this command: ${envs.map((e) => `--env ${e}`).join(' or ')} (--prod is short for --env PROD). ` + + 'To make one the default, run `norbix env use ` — it is then printed on every command. Ask the user when it is not clear.', + docs: 'norbix env list --help', + }) +} + +/** + * Decide for one project-scoped command. `envs` = the project's environments + * (undefined = could not be read: the command runs as before, with a warning). + */ +export function checkEnv(envs: string[] | undefined, check: EnvCheck): EnvVerdict { + if (!envs) { + return check.env + ? {} + : { + notice: + 'Warning: could not read the project\'s environments (`norbix env list`); running without an env, which means PROD. ' + + 'Pass --env if the project has more than one.', + } + } + + if (envs.length <= 1) { + // Only PROD: nothing to choose. A wrong name is left to the gateway, which refuses it. + return {} + } + + if (!check.env) throw envRequiredError(envs) + if (!isPerRunSource(check.source)) return {notice: `env: ${check.env} (${check.source})`} + return {} +} diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts index ccab724..101eb26 100644 --- a/src/lib/hosts.ts +++ b/src/lib/hosts.ts @@ -452,3 +452,41 @@ export async function fetchProjectRegion( return undefined } } + +// ---------- a project's environments ---------- + +/** + * Which environments a project has decides whether `--env` is required + * (src/lib/env-rule.ts). Environments change rarely but matter a lot, so the + * answer is kept for 5 minutes only, and `env list / use / delete` and an + * env error from the gateway drop it. + */ +export const PROJECT_ENVS_TTL_MS = 5 * 60 * 1000 + +type ProjectEnvs = Record + +function projectEnvsPath(hubKey: string): string { + return join(HOSTS_DIR, `${hubKey}.envs.json`) +} + +/** The cached environments of a project on this Hub, while fresh. */ +export function cachedProjectEnvs(hubKey: string, projectId: string, now = Date.now()): string[] | undefined { + const entry = readJson(projectEnvsPath(hubKey))?.[projectId] + if (!Array.isArray(entry?.environments) || entry.environments.length === 0) return undefined + const age = now - Date.parse(entry.fetchedAt) + return age >= 0 && age < PROJECT_ENVS_TTL_MS ? entry.environments : undefined +} + +export function saveProjectEnvs(hubKey: string, projectId: string, environments: string[], now = Date.now()): void { + const all = readJson(projectEnvsPath(hubKey)) ?? {} + all[projectId] = {environments, fetchedAt: new Date(now).toISOString()} + writeJson(projectEnvsPath(hubKey), all) +} + +/** Drop the cached environments of a project (after `env` commands or an env error). */ +export function forgetProjectEnvs(hubKey: string, projectId: string): void { + const all = readJson(projectEnvsPath(hubKey)) + if (!all?.[projectId]) return + delete all[projectId] + writeJson(projectEnvsPath(hubKey), all) +} diff --git a/test/files.test.ts b/test/files.test.ts index 62e68e0..7433c24 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -3,6 +3,7 @@ import {mkdtempSync, readFileSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {envListAnswer} from '../tests/_env.js' /** * The `norbix files` commands. @@ -38,6 +39,8 @@ let realFetch: typeof globalThis.fetch */ function fakeFetch(responses: Array<[string, unknown]> = []): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url const method = init?.method ?? 'GET' let body: unknown @@ -330,6 +333,8 @@ describe('norbix files publish / unpublish', () => { it('sends the session as a bearer token, as every other command does', async () => { const seen: Array> = [] globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url calls.push({method: init?.method ?? 'GET', url}) const headers = new Headers(init?.headers ?? {}) @@ -403,6 +408,8 @@ describe('norbix files publish / unpublish', () => { // The real case: a file cannot be made private on its own while a folder // above it is public (CM-ERRORS-FILES-021). globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url calls.push({method: init?.method ?? 'GET', url}) return new Response( @@ -437,6 +444,8 @@ describe('norbix files publish / unpublish', () => { function answer403(): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url calls.push({method: init?.method ?? 'GET', url}) return new Response(JSON.stringify(forbidden), {status: 403, headers: {'Content-Type': 'application/json'}}) @@ -490,6 +499,8 @@ describe('norbix files integrations test', () => { function answer(payload: unknown, status = 200): Array> { const seen: Array> = [] globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url calls.push({method: init?.method ?? 'GET', url, body: init?.body}) seen.push(Object.fromEntries(new Headers(init?.headers ?? {}).entries())) diff --git a/tests/_cli.ts b/tests/_cli.ts index ad8fa6d..6b0796f 100644 --- a/tests/_cli.ts +++ b/tests/_cli.ts @@ -21,20 +21,28 @@ export interface Hit { method: string url: string body: string + /** The `norbix-env` header the call carried (the SDK sends none for PROD). */ + env?: string } export interface FakeGateway { port: number hits: Hit[] + /** Reads of the project's environment list (the env rule) — kept out of `hits`. */ + envHits: Hit[] close: () => Promise } +/** The environments path the env rule reads before a project-scoped command. */ +export const ENVS_PATH = '/v3/account/projects/environments' + /** * A gateway that answers by path: `answers[path]` when given, else * missing→404, bad→400, unauth→401, boom→500, else 200 {ok:true}. */ export async function startFakeGateway(answers: Record = {}): Promise { const hits: Hit[] = [] + const envHits: Hit[] = [] const server = http.createServer((req, res) => { let body = '' req.on('data', (chunk) => (body += chunk)) @@ -53,9 +61,23 @@ export async function startFakeGateway(answers: Record = {}): P return res.end(JSON.stringify({hubUrl: self, apiUrl: self, hubVersion: 'v3', apiVersion: 'v3', regions: []})) } - hits.push({method: req.method ?? '', url, body}) + const env = req.headers['norbix-env'] + const hit = {method: req.method ?? '', url, body, ...(typeof env === 'string' ? {env} : {})} + // The env rule's read of the environment list: a project with only PROD + // unless the test answers otherwise. Not a hit of the command itself. + if (req.method === 'GET' && url.split('?')[0] === ENVS_PATH) { + envHits.push(hit) + return res.end(JSON.stringify(answers[ENVS_PATH] ?? {item: {environments: ['PROD']}})) + } + + hits.push(hit) const answer = answers[url.split('?')[0]] - if (answer !== undefined) return res.end(JSON.stringify(answer)) + if (answer !== undefined) { + // `{__status: 404, ...body}` answers with that status. + const {__status, ...rest} = (answer ?? {}) as {__status?: number} + if (typeof __status === 'number') res.statusCode = __status + return res.end(JSON.stringify(typeof __status === 'number' ? rest : answer)) + } if (url.includes('missing')) { res.statusCode = 404 return res.end('{"message":"User not found","code":"USER_NOT_FOUND","traceId":"t-1"}') @@ -86,6 +108,7 @@ export async function startFakeGateway(answers: Record = {}): P return { port, hits, + envHits, close: () => new Promise((resolve) => server.close(() => resolve())), } } diff --git a/tests/_env.ts b/tests/_env.ts new file mode 100644 index 0000000..7bac798 --- /dev/null +++ b/tests/_env.ts @@ -0,0 +1,11 @@ +/** + * The env rule (src/lib/env-rule.ts) reads the project's environment list + * before a project-scoped command. Route tests that replace `fetch` answer + * that read here — a project with only PROD, so the command runs as before — + * and keep it out of the calls they count. `env-rule.test.ts` tests the rule. + */ +export function envListAnswer(input: RequestInfo | URL, init?: RequestInit, environments: string[] = ['PROD']): Response | undefined { + const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if ((init?.method ?? 'GET') !== 'GET' || !/\/account\/projects\/environments$/.test(new URL(href).pathname)) return undefined + return new Response(JSON.stringify({item: {environments}}), {status: 200, headers: {'Content-Type': 'application/json'}}) +} diff --git a/tests/account-routes.test.ts b/tests/account-routes.test.ts index fb8c880..9b9eb25 100644 --- a/tests/account-routes.test.ts +++ b/tests/account-routes.test.ts @@ -1,5 +1,6 @@ import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * `account team`, `account me` and `account me set-phone` through oclif and the @@ -23,6 +24,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET', diff --git a/tests/code-marketplace-routes.test.ts b/tests/code-marketplace-routes.test.ts index 204613f..4a147a4 100644 --- a/tests/code-marketplace-routes.test.ts +++ b/tests/code-marketplace-routes.test.ts @@ -6,6 +6,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {accessCell, formatAccessMatrix, revokeOutcome, formatToolCatalog, formatValidation, functionsGate, parseAccessPackage, parseInputs, parseKeyValues, readArguments, toolAccess} from '../src/lib/marketplace.js' +import {envListAnswer} from './_env.js' /** * Every `norbix code marketplace` command, run the way a user types it, @@ -58,6 +59,8 @@ beforeEach(() => { answer = {ok: true} realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET', @@ -689,6 +692,8 @@ describe('norbix code marketplace runs', () => { } const realFake = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) answer = url.pathname.endsWith('/logs/audit') ? lines : runs return realFake(input, init) diff --git a/tests/code-routes.test.ts b/tests/code-routes.test.ts index 27ca03f..4fa600e 100644 --- a/tests/code-routes.test.ts +++ b/tests/code-routes.test.ts @@ -5,6 +5,7 @@ import {join} from 'node:path' import {afterEach, beforeEach, describe, expect, it} from 'vitest' import {formatRunInput, formatRuns} from '../src/lib/code.js' +import {envListAnswer} from './_env.js' /** * Every `norbix code` command, run the way a user types it, through oclif and @@ -32,6 +33,8 @@ beforeEach(() => { answer = {ok: true} realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET', diff --git a/tests/db-routes.test.ts b/tests/db-routes.test.ts index f9e8bb1..a2b7ad3 100644 --- a/tests/db-routes.test.ts +++ b/tests/db-routes.test.ts @@ -6,6 +6,7 @@ import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' import DbSchemaDelete from '../src/commands/db/schema/delete.js' +import {envListAnswer} from './_env.js' /** * One row per `db` command: run it the way a user types it, through oclif and @@ -35,6 +36,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET', diff --git a/tests/email-routes.test.ts b/tests/email-routes.test.ts index c0a2117..e1f198e 100644 --- a/tests/email-routes.test.ts +++ b/tests/email-routes.test.ts @@ -1,5 +1,6 @@ import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * One row per Email endpoint that has a command: run the command the way a user @@ -33,6 +34,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET', diff --git a/tests/env-rule.test.ts b/tests/env-rule.test.ts new file mode 100644 index 0000000..8c31a48 --- /dev/null +++ b/tests/env-rule.test.ts @@ -0,0 +1,185 @@ +import {readFileSync, readdirSync} from 'node:fs' +import {join} from 'node:path' + +import {afterEach, describe, expect, it} from 'vitest' + +import {ENVS_PATH, cli, makeHome, parseSingleJson, startFakeGateway, type FakeGateway} from './_cli.js' + +/** + * The env rule (owner, 2026-10-14; src/lib/env-rule.ts), seen from outside the + * process as an agent sees it: a project with only PROD needs no env; a + * project with more than one needs --env / --prod / NORBIX_ENV / a saved env, + * and a saved env is printed on stderr. The list of environments is cached + * per project and dropped by `env use` / `env delete` and by an env error. + */ + +let gateway: FakeGateway | undefined + +afterEach(async () => { + await gateway?.close() + gateway = undefined +}) + +/** A fresh gateway whose project has these environments, and a fresh HOME (empty cache). */ +async function project(environments: string[] | null, answers: Record = {}) { + // The same object: a test may change an answer between two runs. + gateway = await startFakeGateway(Object.assign(answers, { + [ENVS_PATH]: environments ? {item: {environments}} : {ok: true}, + })) + const home = makeHome(gateway.port) + const run = (argv: string[], env: Record = {}) => cli(home, [...argv, '--profile', 'x'], env) + return {gateway, home, run} +} + +const READ = ['db', 'schemas'] +const WRITE = ['db', 'delete', 'orders', '--id', 'abc123', '--yes'] + +describe('a project with only PROD', () => { + it('runs without an env, sends no norbix-env header and prints nothing on stderr', async () => { + const {gateway, run} = await project(['PROD']) + + const r = await run(READ) + + expect(r.code).toBe(0) + expect(r.stderr).toBe('') + expect(gateway.hits).toHaveLength(1) + expect(gateway.hits[0].env).toBeUndefined() + }) + + it('runs the same with --env PROD and with --prod', async () => { + const {gateway, run} = await project(['PROD']) + + expect((await run([...WRITE, '--env', 'PROD'])).code).toBe(0) + expect((await run([...WRITE, '--prod'])).code).toBe(0) + expect(gateway.hits.map((h) => h.env)).toEqual([undefined, undefined]) + }) +}) + +describe('a project with PROD and TEST', () => { + it('refuses a command with no env: exit 2, the envs listed, nothing sent', async () => { + const {gateway, run} = await project(['TEST', 'PROD']) + + const r = await run([...WRITE, '--json']) + + expect(r.code).toBe(2) + const out = parseSingleJson(r.stdout) as {error: {code: string; message: string; hint: string}} + expect(out.error.code).toBe('ENV_REQUIRED') + expect(out.error.message).toBe('This project has environments PROD, TEST. Pass --env (or set NORBIX_ENV).') + expect(out.error.hint).toContain('--env PROD or --env TEST') + expect(gateway.hits).toEqual([]) + }) + + it('refuses a read too, with the text on stderr without --json', async () => { + const {gateway, run} = await project(['PROD', 'TEST']) + + const r = await run(READ) + + expect(r.code).toBe(2) + expect(r.stderr).toMatch(/^Error: This project has environments PROD, TEST\. Pass --env \(or set NORBIX_ENV\)\./) + expect(gateway.hits).toEqual([]) + }) + + it('sends norbix-env: TEST with --env TEST (any case) and no header with --env PROD / --prod', async () => { + const {gateway, run} = await project(['PROD', 'TEST']) + + expect((await run([...WRITE, '--env', 'test'])).code).toBe(0) + expect((await run([...WRITE, '--env', 'PROD'])).code).toBe(0) + expect((await run([...WRITE, '--prod'])).code).toBe(0) + + expect(gateway.hits.map((h) => h.env)).toEqual(['TEST', undefined, undefined]) + }) + + it('takes NORBIX_ENV as named for this run (no stderr line)', async () => { + const {gateway, run} = await project(['PROD', 'TEST']) + + const r = await run(READ, {NORBIX_ENV: 'TEST'}) + + expect(r.code).toBe(0) + expect(r.stderr).toBe('') + expect(gateway.hits[0].env).toBe('TEST') + }) + + it('refuses --prod together with a different --env', async () => { + const {gateway, run} = await project(['PROD', 'TEST']) + + const r = await run([...READ, '--prod', '--env', 'TEST']) + + expect(r.code).toBe(2) + expect(r.stderr).toContain('--prod and --env TEST name different environments.') + expect(gateway.hits).toEqual([]) + }) + + it('takes an env saved by `norbix env use` and prints it on stderr for every command', async () => { + const {gateway, run} = await project(['PROD', 'TEST']) + + expect((await run(['env', 'use', 'TEST'])).code).toBe(0) + const r = await run(READ) + + expect(r.code).toBe(0) + expect(r.stderr).toBe('env: TEST (profile [x])\n') + expect(gateway.hits.at(-1)?.env).toBe('TEST') + }) + + it('a dry run fails like the real call once the environments are known', async () => { + const {gateway, run} = await project(['PROD', 'TEST']) + + await run(['env', 'list']) + const r = await run(['db', 'delete', 'orders', '--id', 'abc123', '--dry-run', '--json']) + + expect(r.code).toBe(2) + expect((parseSingleJson(r.stdout) as {error: {code: string}}).error.code).toBe('ENV_REQUIRED') + expect(gateway.hits.filter((h) => h.method === 'DELETE')).toEqual([]) + }) +}) + +describe('the environments cache', () => { + it('reads the list once, then from the cache; `env use` and `env delete` drop it', async () => { + const {gateway, home, run} = await project(['PROD']) + + await run(READ) + await run(READ) + expect(gateway.envHits).toHaveLength(1) + + const hosts = join(home, '.norbix', 'hosts') + const cacheFile = join(hosts, readdirSync(hosts).find((f) => f.endsWith('.envs.json'))!) + expect(Object.values(JSON.parse(readFileSync(cacheFile, 'utf8')) as Record)[0].environments).toEqual(['PROD']) + + await run(['env', 'use', 'PROD']) + await run(READ) + expect(gateway.envHits).toHaveLength(2) + + await run(['env', 'delete', 'STAGING', '--yes']) + await run(READ) + expect(gateway.envHits).toHaveLength(3) + }) + + it('is dropped when the gateway says the environment does not exist', async () => { + const answers: Record = {} + const {gateway, run} = await project(['PROD', 'TEST'], answers) + + expect((await run([...READ, '--env', 'TEST'])).code).toBe(0) + expect(gateway.envHits).toHaveLength(1) + const path = gateway.hits[0].url.split('?')[0] + + // TEST was deleted in the dashboard meanwhile: the gateway refuses the env. + answers[path] = {__status: 404, responseStatus: {errorCode: 'CM-ERRORS-ENVIRONMENTS-001', message: "Environment 'TEST' does not exist on this project"}} + const r = await run([...READ, '--env', 'TEST', '--json']) + expect(r.code).toBe(5) + expect((parseSingleJson(r.stdout) as {error: {code: string}}).error.code).toBe('CM-ERRORS-ENVIRONMENTS-001') + + // The next command reads the list again instead of trusting the cache. + delete answers[path] + await run([...READ, '--env', 'TEST']) + expect(gateway.envHits).toHaveLength(2) + }) + + it('a project whose environments cannot be read runs as before, with one warning on stderr', async () => { + const {gateway, run} = await project(null) + + const r = await run(READ) + + expect(r.code).toBe(0) + expect(r.stderr).toContain("Warning: could not read the project's environments") + expect(gateway.hits).toHaveLength(1) + }) +}) diff --git a/tests/language.test.ts b/tests/language.test.ts index 1f2bbca..72e0964 100644 --- a/tests/language.test.ts +++ b/tests/language.test.ts @@ -1,5 +1,6 @@ import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * `--language` / NORBIX_LANGUAGE: the gateway's only language input is the @@ -14,7 +15,9 @@ let realFetch: typeof globalThis.fetch beforeEach(() => { headers = [] realFetch = globalThis.fetch - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList headers.push(new Headers(init?.headers ?? {})) return new Response('{}', {status: 200, headers: {'Content-Type': 'application/json'}}) }) as typeof globalThis.fetch diff --git a/tests/module-routes.test.ts b/tests/module-routes.test.ts index d437720..5f6c008 100644 --- a/tests/module-routes.test.ts +++ b/tests/module-routes.test.ts @@ -1,5 +1,6 @@ import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * `norbix module enable|disable ` for every module, run the way a user @@ -25,6 +26,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({method: init?.method ?? 'GET', path: url.pathname}) return new Response('{}', {status: 200, headers: {'Content-Type': 'application/json'}}) diff --git a/tests/project-routes.test.ts b/tests/project-routes.test.ts index cc7c916..bfa5c95 100644 --- a/tests/project-routes.test.ts +++ b/tests/project-routes.test.ts @@ -4,6 +4,7 @@ import {join} from 'node:path' import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * One row per Project / AI endpoint: run the command the way a user types it, @@ -70,6 +71,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) const method = init?.method ?? 'GET' calls.push({ diff --git a/tests/push-routes.test.ts b/tests/push-routes.test.ts index 0374cb7..32e3cee 100644 --- a/tests/push-routes.test.ts +++ b/tests/push-routes.test.ts @@ -2,6 +2,7 @@ import {Config} from '@oclif/core' import {runCommand} from '@oclif/test' import {fileURLToPath} from 'node:url' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * One row per push endpoint: run the command the way a user types it, through @@ -36,6 +37,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET', diff --git a/tests/scheduler-routes.test.ts b/tests/scheduler-routes.test.ts index 44c338d..4064ed8 100644 --- a/tests/scheduler-routes.test.ts +++ b/tests/scheduler-routes.test.ts @@ -1,5 +1,6 @@ import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * Every scheduler command, run the way a user types it, through oclif and the @@ -26,6 +27,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET', diff --git a/tests/sms-routes.test.ts b/tests/sms-routes.test.ts index a84348d..95ebae7 100644 --- a/tests/sms-routes.test.ts +++ b/tests/sms-routes.test.ts @@ -1,5 +1,6 @@ import {runCommand} from '@oclif/test' import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {envListAnswer} from './_env.js' /** * One row per SMS endpoint: run the command the way a user types it, through @@ -34,6 +35,8 @@ beforeEach(() => { calls = [] realFetch = globalThis.fetch globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init) + if (envList) return envList const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) calls.push({ method: init?.method ?? 'GET',