Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` | 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 |
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`
(`--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 <name>` (or `env =` in a profile)
also counts; each command then prints `env: <name> (<where it is saved>)`
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
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --help` / `norbix schema <command> --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 <command> --help` / `norbix schema <command> --json`; for `ENV_REQUIRED` add `--env <name>` (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` |
Expand Down
130 changes: 126 additions & 4 deletions src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -88,6 +92,7 @@ export interface GlobalFlags {
host?: string
project?: string
env?: string
prod?: boolean
region?: string
language?: string
'api-key'?: string
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>

/**
* 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<void> {
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) {
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand Down Expand Up @@ -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()) {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/commands/env/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 7 additions & 2 deletions src/commands/env/list.ts
Original file line number Diff line number Diff line change
@@ -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 <name>
(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<unknown> {
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
}
Expand Down
7 changes: 6 additions & 1 deletion src/commands/env/use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -27,6 +30,8 @@ profile (created when missing). Override per command with --env.`

async run(): Promise<unknown> {
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}})

Expand Down
14 changes: 12 additions & 2 deletions src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <name> (or --prod, NORBIX_ENV, or \`norbix env use\`)`
: 'only PROD — no --env needed'
}

interface SignIn {
method: 'browser' | 'password'
userId?: string
Expand Down
3 changes: 2 additions & 1 deletion src/lib/agent-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <their dashboard or Hub address>` (or `--profile <name>`) 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 <name>` (`--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 <command> --help` has a realistic example. No named command? `norbix hub <module> --json` (or `norbix api <module> --json`) lists the SDK methods with their fields; call one with plain words and `--body \'<json>\'`.',
'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.',
Expand All @@ -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')
}
Expand Down
Loading
Loading