From 6ba5aebf61449ccbe61c12a3d4e60dae92c7e283 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 22 Oct 2026 17:10:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(env):=20norbix=20env=20admin-url=20set/cle?= =?UTF-8?q?ar/show=20=E2=80=94=20one=20admin=20portal=20per=20environment;?= =?UTF-8?q?=20project=20--env=20shows=20the=20effective=20admin=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway N49: PATCH/GET /v3/account/projects/environments/{environmentName}/admin-url, called directly (the SDK does not know the routes yet), env from --env / --prod / the saved default, else PROD. set/clear read the answer back and print where the env's reset / magic-link / invitation e-mails point and why (environment / project / hosted / cloud). `norbix project --env TEST` adds environmentAdminUrl; without an env it answers as before. A 404/405 names an old hub. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++ src/commands/env/admin-url/clear.ts | 23 +++ src/commands/env/admin-url/set.ts | 35 ++++ src/commands/env/admin-url/show.ts | 23 +++ src/commands/project.ts | 36 +++- src/lib/env-admin-url-command.ts | 62 +++++++ src/lib/env-admin-url.ts | 102 ++++++++++++ test/env-admin-url.test.ts | 213 ++++++++++++++++++++++++ tests/__snapshots__/schema.test.ts.snap | 27 +++ 9 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 src/commands/env/admin-url/clear.ts create mode 100644 src/commands/env/admin-url/set.ts create mode 100644 src/commands/env/admin-url/show.ts create mode 100644 src/lib/env-admin-url-command.ts create mode 100644 src/lib/env-admin-url.ts create mode 100644 test/env-admin-url.test.ts diff --git a/README.md b/README.md index b171db8..d723730 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ norbix scheduler list | `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) | | `norbix membership password reset request ` | Admin: send a test password-reset e-mail in `--env`; says whether a login was found, which integration sends it, and (from the hub's project-log entry) whether it went out, through which integration and template | | `norbix env list/use/delete/promote/rollback`, `norbix env built-ins repair` | Manage project environments and move content TEST → PROD (see *Environments* below) | +| `norbix env admin-url set/clear/show` | The admin portal address one environment's reset / magic-link / invitation e-mails open (gateway N49); `norbix project --env ` shows it too, with its source | | `norbix logs list/trail/get` | Read project logs, follow one correlation ID, show one entry | | `norbix logs write` | Write your app's own lines (`--title …` or `--file entries.json`); prints `accepted: n`, on a 429 says how many seconds to wait | | `norbix logs settings` / `settings set` / `clean` | Read and change log settings (only the flags you pass are sent); delete every stored entry (asks; `--yes` skips) | @@ -304,6 +305,26 @@ runs, and stderr says: Note: PROD is promoted from TEST — make the change in TEST and run `norbix env promote --from TEST --to PROD` ``` +#### One admin portal per environment + +When each environment runs its own admin portal (the portal image started with +`ENV=TEST` on `test-admin.…` and `ENV=PROD` on `admin.…`), give each environment +its address. Password-reset, magic-link and invitation e-mails sent in that +environment then open its own portal, which confirms in the same environment: + +```sh +norbix env admin-url set https://admin.example.com --prod +norbix env admin-url set https://test-admin.example.com --env TEST +norbix env admin-url show --env TEST # TEST: e-mail links open https://test-admin.example.com — from its own admin URL +norbix project --env TEST # the project, plus environmentAdminUrl {adminUrl, effectiveAdminUrl, source} +norbix env admin-url clear --env TEST # back to the project admin URL, then the Norbix-hosted portal +``` + +An environment without its own address uses the project admin URL +(`project admin-portal set-url`), then the Norbix-hosted portal. The address's +origin is allowed through CORS automatically while it is set; promotion never +copies it. + ## JSON output Every command supports `--json` and prints clean JSON on stdout, so you can diff --git a/src/commands/env/admin-url/clear.ts b/src/commands/env/admin-url/clear.ts new file mode 100644 index 0000000..e054edc --- /dev/null +++ b/src/commands/env/admin-url/clear.ts @@ -0,0 +1,23 @@ +import {BaseCommand} from '../../../base.js' +import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js' + +export default class EnvAdminUrlClear extends EnvAdminUrlCommand { + static description = `Remove the --env environment's own admin portal address + +Its e-mail links then use the project admin URL (\`project admin-portal set-url\`), +then the Norbix-hosted portal. Its origin leaves CORS again.` + + static examples = [ + '<%= config.bin %> env admin-url clear --env TEST', + '<%= config.bin %> env admin-url clear --env TEST --dry-run', + ] + + static flags = { + ...BaseCommand.dryRunFlags, + } + + async run(): Promise { + const {flags} = await this.parse(EnvAdminUrlClear) + return this.writeAdminUrl(flags, '') + } +} diff --git a/src/commands/env/admin-url/set.ts b/src/commands/env/admin-url/set.ts new file mode 100644 index 0000000..50748c2 --- /dev/null +++ b/src/commands/env/admin-url/set.ts @@ -0,0 +1,35 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js' + +export default class EnvAdminUrlSet extends EnvAdminUrlCommand { + static description = `Give the --env environment its own admin portal address + +Use it when each environment runs its own admin portal (the portal started with +ENV=TEST on test-admin.…, ENV=PROD on admin.…). Password-reset, magic-link and +invitation e-mails sent in that environment then open this address, so the +portal confirms in the environment the link was made in. The address must be +https with a host and an optional path (no query). Its origin is allowed +through CORS automatically — do not add it to the project origins. Promotion +never copies it. \`env admin-url clear\` goes back to the project admin URL.` + + static examples = [ + '<%= config.bin %> env admin-url set https://test-admin.example.com --env TEST', + '<%= config.bin %> env admin-url set https://admin.example.com --prod', + '<%= config.bin %> env admin-url set https://test-admin.example.com --env TEST --dry-run', + ] + + static args = { + url: Args.string({required: true, description: 'Admin portal address, e.g. https://test-admin.example.com'}), + } + + static flags = { + ...BaseCommand.dryRunFlags, + } + + async run(): Promise { + const {args, flags} = await this.parse(EnvAdminUrlSet) + return this.writeAdminUrl(flags, args.url) + } +} diff --git a/src/commands/env/admin-url/show.ts b/src/commands/env/admin-url/show.ts new file mode 100644 index 0000000..a726a96 --- /dev/null +++ b/src/commands/env/admin-url/show.ts @@ -0,0 +1,23 @@ +import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js' + +export default class EnvAdminUrlShow extends EnvAdminUrlCommand { + static description = `Show where the --env environment's e-mail links point + +Password-reset, magic-link and invitation e-mails open the environment's admin +portal. The answer has the environment's own URL (adminUrl), the address the +links really use (effectiveAdminUrl) and where it came from (source): +environment (its own URL), project (the project admin URL), hosted (the +Norbix-hosted portal) or cloud / none (the links cannot be opened).` + + static examples = [ + '<%= config.bin %> env admin-url show --env TEST', + '<%= config.bin %> env admin-url show --prod --json', + ] + + async run(): Promise { + const {flags} = await this.parse(EnvAdminUrlShow) + const item = await this.readAdminUrl(flags) + this.show(item) + return item + } +} diff --git a/src/commands/project.ts b/src/commands/project.ts index 49c140a..fac86a3 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,14 +1,21 @@ import {Args} from '@oclif/core' +import {describeEnvAdminUrl, type EnvironmentAdminUrl, envAdminUrlCall, sendEnvAdminUrlCall, targetEnv} from '../lib/env-admin-url.js' import {ProjectCommand} from '../lib/project.js' export default class Project extends ProjectCommand { static description = `Show a project: name, languages, regions, CORS origins, admin portal, modules Shows the configured project unless you pass another project ID. List every -project of the account with \`account projects\`.` +project of the account with \`account projects\`. With --env (or --prod) it also +shows the admin portal address that environment's e-mail links open and where +it came from (environmentAdminUrl: environment / project / hosted / cloud).` - static examples = ['<%= config.bin %> project', '<%= config.bin %> project 66b2f0a1c3d4e5f6a7b8c9d0 --json'] + static examples = [ + '<%= config.bin %> project', + '<%= config.bin %> project --env TEST', + '<%= config.bin %> project 66b2f0a1c3d4e5f6a7b8c9d0 --json', + ] static args = { id: Args.string({description: 'Project ID (defaults to the configured project)'}), @@ -20,6 +27,31 @@ project of the account with \`account projects\`.` const res = await client.hub.account.getProject({projectId}) + // N49 — the effective admin portal URL of the chosen environment. Only when + // an env was asked for, so a plain `project` answers exactly as before. + const ctx = await this.freshContext(flags) + if (ctx.env && !flags['dry-run']) { + const token = ctx.bearerToken ?? ctx.apiKey + let environmentAdminUrl: EnvironmentAdminUrl | {env: string; error: string} | undefined + if (token) { + try { + environmentAdminUrl = (await sendEnvAdminUrlCall( + envAdminUrlCall({...ctx, projectId}, token, targetEnv(ctx), 'GET'), + )).item + } catch (error) { + environmentAdminUrl = {env: targetEnv(ctx), error: (error as Error).message} + } + } + + const out = {...(res as object), environmentAdminUrl} + this.print(out) + if (environmentAdminUrl && !('error' in environmentAdminUrl) && !this.jsonEnabled()) { + process.stderr.write(`${describeEnvAdminUrl(environmentAdminUrl)}\n`) + } + + return out + } + this.print(res) return res } diff --git a/src/lib/env-admin-url-command.ts b/src/lib/env-admin-url-command.ts new file mode 100644 index 0000000..c8eeee8 --- /dev/null +++ b/src/lib/env-admin-url-command.ts @@ -0,0 +1,62 @@ +import {BaseCommand, type GlobalFlags} from '../base.js' +import { + describeEnvAdminUrl, + type EnvironmentAdminUrl, + envAdminUrlCall, + sendEnvAdminUrlCall, + targetEnv, +} from './env-admin-url.js' + +/** Base of `env admin-url set|clear|show`: the env is --env / --prod / the saved default, else PROD. */ +export abstract class EnvAdminUrlCommand extends BaseCommand { + /** Reads the env's admin URL (own / effective / source). */ + protected async readAdminUrl(flags: GlobalFlags): Promise { + const {ctx, token, env} = await this.target(flags) + try { + const item = (await sendEnvAdminUrlCall(envAdminUrlCall(ctx, token, env, 'GET'))).item + if (item) return item + } catch (error) { + this.error((error as Error).message) + } + + this.error(`The hub answered no admin URL for ${env}.`) + } + + /** Sets (url) or clears ('') the env's admin URL, then reads it back. */ + protected async writeAdminUrl(flags: GlobalFlags, url: string): Promise { + const {ctx, token, env} = await this.target(flags) + const call = envAdminUrlCall(ctx, token, env, 'PATCH', url) + + if (flags['dry-run']) { + return this.dryRun({ + method: url ? 'env admin-url set' : 'env admin-url clear', + request: call.body, + http: {method: call.method, url: call.url, headers: {...call.headers, Authorization: 'Bearer ***'}, body: call.body}, + }) + } + + try { + await sendEnvAdminUrlCall(call) + } catch (error) { + this.error((error as Error).message) + } + + // Read it back: the answer says where the links point now (and why). + const now = await this.readAdminUrl(flags) + this.show(now) + return now + } + + protected show(item: EnvironmentAdminUrl): void { + this.print(this.jsonEnabled() ? item : describeEnvAdminUrl(item)) + } + + private async target(flags: GlobalFlags) { + const ctx = await this.freshContext(flags) + this.assertEndpoints(ctx) + if (!ctx.projectId) this.error('No project ID configured. Run `norbix login` or pass --project.') + const token = ctx.bearerToken ?? ctx.apiKey + if (!token) this.error('Not authenticated. Run `norbix login` or pass --api-key.') + return {ctx, token, env: targetEnv(ctx)} + } +} diff --git a/src/lib/env-admin-url.ts b/src/lib/env-admin-url.ts new file mode 100644 index 0000000..b16132e --- /dev/null +++ b/src/lib/env-admin-url.ts @@ -0,0 +1,102 @@ +import type {ResolvedContext} from '../base.js' + +/** + * The admin portal URL of one environment (gateway N49): + * `GET|PATCH /v3/account/projects/environments/{environmentName}/admin-url`. + * The SDK does not know these routes yet, so the commands call them directly + * with the same headers the SDK transport sends. + */ + +/** One environment's admin portal URL as the hub answers it. */ +export interface EnvironmentAdminUrl { + env: string + /** The environment's own URL; absent = it uses the fallback. */ + adminUrl?: string + /** The base its password-reset / magic-link / invitation e-mails open. */ + effectiveAdminUrl?: string + /** environment / project / hosted / cloud / none */ + source: string + /** Why a set value was skipped, or why the links cannot be opened. */ + note?: string +} + +export interface HubCall { + method: 'GET' | 'PATCH' + url: string + headers: Record + body?: unknown +} + +interface HubAnswer { + item?: EnvironmentAdminUrl + responseStatus?: {isSuccess?: boolean; errors?: Array<{message?: string; errorCode?: string}>} +} + +/** The environment a command acts on: --env / --prod / the saved default, else PROD. */ +export function targetEnv(ctx: Pick): string { + return (ctx.env ?? 'PROD').trim().toUpperCase() +} + +/** Builds the request; the caller checked that a project and a token are there. */ +export function envAdminUrlCall( + ctx: ResolvedContext, + token: string, + env: string, + method: 'GET' | 'PATCH', + url?: string, +): HubCall { + const href = `${ctx.hubUrl.replace(/\/$/, '')}/v3/account/projects/environments/${encodeURIComponent(env)}/admin-url` + const target = new URL(href) + // The route has no {projectId} segment: the project rides in the query, as for the env list. + target.searchParams.set('projectId', ctx.projectId!) + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + 'norbix-project-id': ctx.projectId!, + } + if (method === 'PATCH') headers['Content-Type'] = 'application/json' + if (ctx.accountId) headers['norbix-account-id'] = ctx.accountId + if (ctx.region) headers['nb-region'] = ctx.region + return method === 'PATCH' + ? {method, url: target.href, headers, body: {environmentName: env, url: url ?? ''}} + : {method, url: target.href, headers} +} + +/** Sends the call; throws an Error with the hub's reason (and a hint on an old hub). */ +export async function sendEnvAdminUrlCall(call: HubCall): Promise { + const res = await fetch(call.url, { + method: call.method, + headers: call.headers, + body: call.body === undefined ? undefined : JSON.stringify(call.body), + }) + const text = await res.text() + let answer: HubAnswer = {} + try { + answer = text ? (JSON.parse(text) as HubAnswer) : {} + } catch { + throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`) + } + + if (!res.ok || answer.responseStatus?.isSuccess === false) { + const why = answer.responseStatus?.errors?.map((e) => [e.errorCode, e.message].filter(Boolean).join(' ')).join('; ') + const old = res.status === 404 || res.status === 405 + throw new Error( + `HTTP ${res.status}${why ? `: ${why}` : ''}${old && !why ? ' (this hub is older than the per-environment admin URL — upgrade it)' : ''}`, + ) + } + + return answer +} + +/** One line for people: where the env's e-mail links point and why. */ +export function describeEnvAdminUrl(a: EnvironmentAdminUrl): string { + const from: Record = { + environment: 'its own admin URL', + project: 'the project admin URL (no URL of its own)', + hosted: 'the Norbix-hosted portal', + cloud: 'the Cloud URL — the Cloud cannot open these links', + none: 'nothing — the links are relative', + } + const line = `${a.env}: e-mail links open ${a.effectiveAdminUrl ?? '(no address)'} — from ${from[a.source] ?? a.source}` + return a.note ? `${line}\n note: ${a.note}` : line +} diff --git a/test/env-admin-url.test.ts b/test/env-admin-url.test.ts new file mode 100644 index 0000000..096cad6 --- /dev/null +++ b/test/env-admin-url.test.ts @@ -0,0 +1,213 @@ +import {runCommand} from '@oclif/test' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {describeEnvAdminUrl} from '../src/lib/env-admin-url.js' +import {envListAnswer} from '../tests/_env.js' + +/** + * `norbix env admin-url set|clear|show` and `norbix project --env` (gateway N49): + * the Hub routes `PATCH|GET /v3/account/projects/environments/{environmentName}/admin-url`. + * Happy Tummy: PROD → https://admin.…, TEST → https://test-admin.…. The real commands + * run through oclif with `fetch` replaced. + */ + +const PROJECT = 'test-project' +const API_KEY = 'test-api-key' +const REGION = 'nb-eu-germany' +const globalArgs = ['--project', PROJECT, '--api-key', API_KEY, '--region', REGION] + +interface Call { + method: string + url: string + headers: Record + body?: unknown +} + +let calls: Call[] +let realFetch: typeof globalThis.fetch + +/** The hub: remembers the env URLs set by PATCH and answers GET like the gateway. */ +function fakeHub(options: {status?: number; error?: {errorCode: string; message: string}} = {}): void { + const urls: Record = {} + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init, ['PROD', 'TEST']) + if (envList) return envList + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const headers: Record = {} + new Headers(init?.headers).forEach((v, k) => (headers[k] = v)) + const body = init?.body ? JSON.parse(String(init.body)) : undefined + const method = init?.method ?? 'GET' + calls.push({method, url, headers, body}) + + const json = (status: number, payload: unknown) => + new Response(JSON.stringify(payload), {status, headers: {'Content-Type': 'application/json'}}) + + if (options.error) return json(options.status ?? 400, {responseStatus: {isSuccess: false, errors: [options.error]}}) + if (options.status) return json(options.status, {}) + + const match = /\/account\/projects\/environments\/([^/]+)\/admin-url$/.exec(new URL(url).pathname) + if (match) { + const env = decodeURIComponent(match[1]!) + if (method === 'PATCH') { + if (body?.url) urls[env] = body.url + else delete urls[env] + return json(200, {responseStatus: {isSuccess: true}}) + } + + const own = urls[env] + return json(200, { + item: own + ? {env, adminUrl: own, effectiveAdminUrl: own, source: 'environment'} + : {env, effectiveAdminUrl: 'https://admin.example.test', source: 'project'}, + responseStatus: {isSuccess: true}, + }) + } + + if (/\/account\/projects\/[^/]+$/.test(new URL(url).pathname)) { + return json(200, {item: {viewId: PROJECT, name: 'Happy Tummy'}, responseStatus: {isSuccess: true}}) + } + + return json(404, {}) + }) as typeof globalThis.fetch +} + +const adminUrlCalls = () => calls.filter((c) => c.url.includes('/admin-url')) + +beforeEach(() => { + calls = [] + realFetch = globalThis.fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch +}) + +describe('norbix env admin-url', () => { + it('set sends the URL for the --env environment and reads back where the links point', async () => { + fakeHub() + + const {result, error} = await runCommand([ + 'env', 'admin-url', 'set', 'https://test-admin.example.test', '--env', 'TEST', '--json', ...globalArgs, + ]) + + expect(error).toBeUndefined() + const [patch, get] = adminUrlCalls() + expect({method: patch!.method, path: new URL(patch!.url).pathname, project: new URL(patch!.url).searchParams.get('projectId'), body: patch!.body}) + .toEqual({ + method: 'PATCH', + path: '/v3/account/projects/environments/TEST/admin-url', + project: PROJECT, + body: {environmentName: 'TEST', url: 'https://test-admin.example.test'}, + }) + expect(get!.method).toBe('GET') + expect(result).toEqual({ + env: 'TEST', + adminUrl: 'https://test-admin.example.test', + effectiveAdminUrl: 'https://test-admin.example.test', + source: 'environment', + }) + }) + + it('--prod sets the PROD portal', async () => { + fakeHub() + + const {error} = await runCommand(['env', 'admin-url', 'set', 'https://admin.example.test', '--prod', '--json', ...globalArgs]) + + expect(error).toBeUndefined() + expect(new URL(adminUrlCalls()[0]!.url).pathname).toBe('/v3/account/projects/environments/PROD/admin-url') + }) + + it('clear sends an empty URL and shows the fallback', async () => { + fakeHub() + + const {result, error} = await runCommand(['env', 'admin-url', 'clear', '--env', 'TEST', '--json', ...globalArgs]) + + expect(error).toBeUndefined() + expect(adminUrlCalls()[0]!.body).toEqual({environmentName: 'TEST', url: ''}) + expect(result).toEqual({env: 'TEST', effectiveAdminUrl: 'https://admin.example.test', source: 'project'}) + }) + + it('show answers the env\'s own URL, the effective URL and the source', async () => { + fakeHub() + + const {result, error} = await runCommand(['env', 'admin-url', 'show', '--env', 'TEST', ...globalArgs]) + + expect(error).toBeUndefined() + expect(result).toEqual({env: 'TEST', effectiveAdminUrl: 'https://admin.example.test', source: 'project'}) + }) + + it('the line for people says where the links point and why', () => { + expect([ + describeEnvAdminUrl({env: 'TEST', adminUrl: 'https://test-admin.example.test', effectiveAdminUrl: 'https://test-admin.example.test', source: 'environment'}), + describeEnvAdminUrl({env: 'STAGING', effectiveAdminUrl: 'https://admin.example.test', source: 'project'}), + describeEnvAdminUrl({env: 'TEST', adminUrl: 'https://test-admin.example.test', effectiveAdminUrl: 'https://pr-1.admin.norbix.ai', source: 'hosted', note: 'the Admin Portal is disabled'}), + ]).toEqual([ + 'TEST: e-mail links open https://test-admin.example.test — from its own admin URL', + 'STAGING: e-mail links open https://admin.example.test — from the project admin URL (no URL of its own)', + 'TEST: e-mail links open https://pr-1.admin.norbix.ai — from the Norbix-hosted portal\n note: the Admin Portal is disabled', + ]) + }) + + it('says why the hub refused a URL', async () => { + fakeHub({status: 400, error: { + errorCode: 'CM-ERRORS-ENVIRONMENTS-015', + message: "'https://test-admin.example.test/?x=1' cannot be the admin portal URL of 'TEST': it must not have a query (?…).", + }}) + + const {error} = await runCommand(['env', 'admin-url', 'set', 'https://test-admin.example.test/?x=1', '--env', 'TEST', ...globalArgs]) + + expect(error?.message).toContain('HTTP 400: CM-ERRORS-ENVIRONMENTS-015') + }) + + it('names an old hub on 405', async () => { + fakeHub({status: 405}) + + const {error} = await runCommand(['env', 'admin-url', 'show', '--env', 'TEST', ...globalArgs]) + + expect(error?.message).toContain('older than the per-environment admin URL') + }) + + it('sends nothing on --dry-run', async () => { + fakeHub() + + const {result, error} = await runCommand<{dryRun: boolean; http: {method: string; url: string; headers: Record}}>([ + 'env', 'admin-url', 'set', 'https://test-admin.example.test', '--env', 'TEST', '--dry-run', ...globalArgs, + ]) + + expect(error).toBeUndefined() + expect(result?.dryRun).toBe(true) + expect(result?.http.method).toBe('PATCH') + expect(result?.http.url).toContain('/v3/account/projects/environments/TEST/admin-url') + expect(result?.http.headers.Authorization).toBe('Bearer ***') + expect(adminUrlCalls()).toHaveLength(0) + }) +}) + +describe('norbix project --env', () => { + it('adds the effective admin URL of that environment', async () => { + fakeHub() + + const {result, error} = await runCommand<{environmentAdminUrl?: unknown}>(['project', '--env', 'TEST', '--json', ...globalArgs]) + + expect(error).toBeUndefined() + expect(result?.environmentAdminUrl).toEqual({env: 'TEST', effectiveAdminUrl: 'https://admin.example.test', source: 'project'}) + }) + + it('without an env answers exactly as before', async () => { + fakeHub() + // A project with only PROD needs no env; nothing extra is fetched. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const envList = envListAnswer(input, init, ['PROD']) + if (envList) return envList + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + calls.push({method: init?.method ?? 'GET', url, headers: {}}) + return new Response(JSON.stringify({item: {viewId: PROJECT}, responseStatus: {isSuccess: true}}), {status: 200, headers: {'Content-Type': 'application/json'}}) + }) as typeof globalThis.fetch + + // Another project id: the env list of the project above is remembered (env rule). + const {result, error} = await runCommand>(['project', '--json', '--project', 'prod-only-project', '--api-key', API_KEY, '--region', REGION]) + + expect(error).toBeUndefined() + expect(result).not.toHaveProperty('environmentAdminUrl') + expect(adminUrlCalls()).toHaveLength(0) + }) +}) diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index ab76cc5..fc84b1d 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -1841,6 +1841,33 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "email validation test", "supportsDryRun": true, }, + { + "args": [], + "destructive": false, + "flags": [ + "dry-run:boolean", + ], + "id": "env admin-url clear", + "supportsDryRun": true, + }, + { + "args": [ + "url", + ], + "destructive": false, + "flags": [ + "dry-run:boolean", + ], + "id": "env admin-url set", + "supportsDryRun": true, + }, + { + "args": [], + "destructive": false, + "flags": [], + "id": "env admin-url show", + "supportsDryRun": false, + }, { "args": [], "destructive": true,