From c7cf498e939444e18fcc5654e0b4e59e6064e4fd Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:12:29 +0300 Subject: [PATCH 01/19] =?UTF-8?q?feat(ai):=20norbix=20ai=20context=20?= =?UTF-8?q?=E2=80=94=20the=20agent's=20project=20context,=20kept=20in=20.n?= =?UTF-8?q?orbix-ai/=20after=20login=20and=20token=20renewal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `norbix ai context` prints the Hub's AI project-context summary (GET /{v}/account/ai/context): every project the identity may work on — environments, regions, modules, integrations with their default per env, CORS origins, languages — scoped to its roles, never a secret. Markdown by default, --json for the structured summary with its version, --project to narrow, --write to save .norbix-ai/context.md + context.json (+ a .gitignore). In a folder set up by `norbix ai init`, `norbix login` writes the copy and a token renewal refreshes an existing one when its version changed (one request with knownVersion). Both are silent and never fail the command; no request at all in any other folder. The ai init block and skill tell the agent to read the file and re-run the command after its context was compressed. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++ package.json | 2 +- src/base.ts | 49 +++++ src/commands/ai/context.ts | 103 ++++++++++ src/commands/login.ts | 24 +++ src/lib/agent-context.ts | 160 +++++++++++++++ src/lib/agent-files.ts | 2 + src/lib/session-auth.ts | 8 + tests/__snapshots__/schema.test.ts.snap | 10 + tests/ai-context.test.ts | 250 ++++++++++++++++++++++++ tests/ai-init.test.ts | 3 +- 11 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 src/commands/ai/context.ts create mode 100644 src/lib/agent-context.ts create mode 100644 tests/ai-context.test.ts diff --git a/README.md b/README.md index efe9274..56a45a3 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ norbix scheduler list | `norbix raw ` | Low-level HTTP escape hatch (hub by default, `--api` for API) | | `norbix schema [command]` | Describe every command as data (`--json`) — for scripts and agents | | `norbix ai init` | Set up a project for Claude Code / Cursor / AGENTS.md agents | +| `norbix ai context` | The project context for an AI agent: your projects, environments, modules, integrations — no secrets | ### Plain-word access to every endpoint @@ -323,6 +324,21 @@ norbix ai init # writes .claude/skills/norbix/SKILL.md + a CLAUD norbix ai init --target all # also AGENTS.md (Codex, OpenCode) and a Cursor rule ``` +The agent rebuilds what it knows about your projects from one summary — +every project you may work on, with its environments, regions, enabled +modules, integrations (id, name, kind, default per environment), CORS origins +and languages; scoped to your roles and never holding a secret: + +```sh +norbix ai context # Markdown, for a prompt +norbix ai context --json # the structured summary, with a "version" +norbix ai context --write # also saves .norbix-ai/context.md (+ context.json, git-ignored) +``` + +In a folder set up with `norbix ai init`, `norbix login` writes that file and a +token renewal refreshes it when the summary changed; the agent re-reads it +after its own context was compressed. + The agent signs in by itself, in two steps (its shell commands time out after ~2 minutes; the code lives 10 minutes): diff --git a/package.json b/package.json index ebff00d..cc8369f 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ } }, "ai": { - "description": "LLM and MCP server integrations, AI service users, and coding-agent setup (ai init)", + "description": "LLM and MCP server integrations, AI service users, coding-agent setup (ai init) and the agent project context (ai context)", "subtopics": { "llm": { "description": "Create, test, turn on/off and delete an LLM integration" diff --git a/src/base.ts b/src/base.ts index 5db6d59..c0556f5 100644 --- a/src/base.ts +++ b/src/base.ts @@ -1,3 +1,6 @@ +import {existsSync} from 'node:fs' +import {join} from 'node:path' + import {Norbix} from '@norbix.ai/ts' import {Command, Flags, ux} from '@oclif/core' @@ -29,6 +32,7 @@ import { type Profile, type Session, } from './lib/profiles.js' +import {CONTEXT_MD, syncContext, type ContextRequest} from './lib/agent-context.js' import {SessionRefresher} from './lib/session-auth.js' import {readStore, type StoredConfig} from './lib/store.js' @@ -602,6 +606,8 @@ export abstract class BaseCommand extends Command { private hubEndpoints = new Map>() private refresher?: SessionRefresher + /** The context the refresher was made for — the agent-context refresh after a renewal reuses it. */ + private refresherCtx?: ResolvedContext /** The Hub base + version for calls the CLI makes without the SDK (sign-in, refresh, revoke). */ protected hubEndpoint(ctx: ResolvedContext): Promise { @@ -619,6 +625,7 @@ export abstract class BaseCommand extends Command { protected sessionRefresher(ctx: ResolvedContext): SessionRefresher | undefined { if (ctx.authSource !== 'session' || !isSessionRefreshable(ctx.session)) return undefined const session = ctx.session + this.refresherCtx ??= ctx this.refresher ??= new SessionRefresher( session, () => (session.hubUrl ? resolveHubEndpoint(session.hubUrl, {stored: session.hubVersion}) : this.hubEndpoint(ctx)), @@ -627,6 +634,48 @@ export abstract class BaseCommand extends Command { return this.refresher } + /** + * The AI project-context request (Hub `account/ai/context`) for this + * context: an account-level call, so on norbix.ai without a region it goes + * to the account Hub. Undefined without credentials. + */ + protected async agentContextRequest( + ctx: ResolvedContext, + projectId?: string, + ): Promise | undefined> { + const token = ctx.bearerToken ?? ctx.apiKey + if (!token) return undefined + const hub = await this.hubEndpoint(ctx) + const accountHub = ctx.usesDefaultEndpoints && !ctx.region + return { + hubBase: accountHub ? ctx.authHubUrl : ctx.hubUrl, + hubVersion: hub.version, + token, + accountId: ctx.accountId, + projectId, + region: ctx.region, + } + } + + /** + * After a run that renewed the browser sign-in: bring the agent-context + * copy in this folder (`.norbix-ai/context.md`, written by `norbix ai + * context --write` or `norbix login`) up to date, when there is one and its + * version changed. Silent, and never changes the command's outcome. + */ + protected async finally(error: Error | undefined): Promise { + if (!error && this.refresher?.renewed && this.refresherCtx && existsSync(join(process.cwd(), CONTEXT_MD))) { + try { + const base = await this.agentContextRequest({...this.refresherCtx, bearerToken: this.refresher.token}) + if (base) await syncContext(process.cwd(), base, {onlyIfPresent: true}) + } catch { + // best effort by contract + } + } + + return super.finally(error) + } + /** * `resolveContext` for commands that call `fetch` themselves: the session * token is refreshed first when it is about to expire. A dry run sends diff --git a/src/commands/ai/context.ts b/src/commands/ai/context.ts new file mode 100644 index 0000000..94d2de7 --- /dev/null +++ b/src/commands/ai/context.ts @@ -0,0 +1,103 @@ +import {resolve} from 'node:path' + +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' +import {CliError} from '../../lib/cli-error.js' +import {CONTEXT_MD, fetchContext, writeContextFiles, type ContextResult} from '../../lib/agent-context.js' +import {EXIT} from '../../lib/exit-codes.js' + +export default class AiContext extends BaseCommand { + static description = `Print the project context an AI agent needs: every project you may work on. + +For each project (scoped to your roles): id and name, environments, +regions, enabled modules, integrations (id, name, kind, the default per +environment), CORS origins and languages. Without the right to read a +project's settings you get only its name, description and languages +("access": "minimal"). Never contains a secret: no keys, tokens, connection +strings or integration settings. + +Default output is Markdown for a prompt; --json prints the structured summary +(with "version"). --project narrows it to one project. + +--write also saves it into this folder as ${CONTEXT_MD} (+ context.json and a +.gitignore — the summary follows your roles, so it is never committed). +\`norbix ai init\` points the agent at that file. After \`norbix login\` and +after a token renewal the copy is refreshed by itself in a folder set up with +\`norbix ai init\`; run this command again after your context was compressed.` + + static examples = [ + '<%= config.bin %> ai context', + '<%= config.bin %> ai context --json', + '<%= config.bin %> ai context --project 66b2f0a1c3d4e5f6a7b8c9d0', + '<%= config.bin %> ai context --write', + ] + + static flags = { + write: Flags.boolean({description: `Also save it to ${CONTEXT_MD} (and context.json) in the project folder`, default: false}), + dir: Flags.string({description: 'Project folder for --write (default: current folder)'}), + } + + async run(): Promise { + const {flags} = await this.parse(AiContext) + const ctx = await this.freshContext(flags) + const base = await this.agentContextRequest(ctx, flags.project) + if (!base) { + throw new CliError({ + exit: EXIT.AUTH, + code: 'UNAUTHENTICATED', + message: 'Not authenticated.', + hint: 'Run `norbix login` (browser sign-in) or pass --api-key / set NORBIX_API_KEY.', + docs: 'norbix login --help', + }) + } + + const wantMarkdown = !this.jsonEnabled() || flags.write + const json = this.orFail(await this.call(() => fetchContext({...base, format: 'json'}))) + const md = wantMarkdown ? this.orFail(await this.call(() => fetchContext({...base, format: 'markdown'}))) : undefined + const summary = json.status === 'ok' ? json.context : undefined + const markdown = md?.status === 'ok' ? md.markdown : undefined + + let written: string[] = [] + if (flags.write && summary && markdown) { + written = writeContextFiles(resolve(flags.dir ?? process.cwd()), summary, markdown) + } + + if (!this.jsonEnabled()) { + this.log((markdown ?? '').trimEnd()) + if (written.length > 0) this.logToStderr(`wrote ${written.join(', ')}`) + } + + return written.length > 0 ? {...summary, written} : summary + } + + /** A network failure becomes the usual exit 7 envelope. */ + private async call(request: () => Promise): Promise { + try { + return await request() + } catch (error) { + throw new CliError({ + exit: EXIT.NETWORK, + code: 'NETWORK_ERROR', + message: `Could not reach the Hub: ${error instanceof Error ? error.message : String(error)}`, + hint: 'Check the network and `norbix whoami --json`.', + docs: 'norbix ai context --help', + }) + } + } + + private orFail(result: ContextResult): ContextResult { + if (result.status !== 'error') return result + const exit = result.httpStatus === 401 || result.httpStatus === 403 ? EXIT.AUTH : result.httpStatus === 404 ? EXIT.NOT_FOUND : result.httpStatus >= 500 ? EXIT.SERVER : EXIT.VALIDATION + throw new CliError({ + exit, + code: result.httpStatus === 404 ? 'NOT_FOUND' : 'AI_CONTEXT_FAILED', + message: `Could not read the project context: ${result.message}`, + hint: + result.httpStatus === 404 + ? 'This Hub is older than the project-context summary (GET /account/ai/context); update the gateway.' + : 'Check the sign-in with `norbix whoami --json`.', + docs: 'norbix ai context --help', + }) + } +} diff --git a/src/commands/login.ts b/src/commands/login.ts index eaba801..dc77607 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -13,6 +13,7 @@ import { startDeviceFlow, type DeviceTokenSuccess, } from '../lib/device-login.js' +import {isAgentFolder, syncContext} from '../lib/agent-context.js' import {EXIT} from '../lib/exit-codes.js' import {DEFAULT_HOST, cachedHost, hostKey, normalizeHost, storedHost} from '../lib/hosts.js' import {type HubEndpoint} from '../lib/hub-version.js' @@ -95,6 +96,29 @@ A shell with no terminal (scripts, CI, agents) without --api-key, static discoversHost = false async run(): Promise { + const result = await this.signIn() + await this.syncAgentContext(result) + return result + } + + /** + * After a finished sign-in, refresh the AI project context in this folder + * when `norbix ai init` set it up (src/lib/agent-context.ts). Silent; a + * failure never fails the login. + */ + private async syncAgentContext(result: unknown): Promise { + const status = (result as {status?: string} | undefined)?.status + if (status === 'pending' || !isAgentFolder(process.cwd())) return + try { + const {flags} = await this.parse(Login) + const base = await this.agentContextRequest(this.resolveContext(flags)) + if (base) await syncContext(process.cwd(), base, {onlyIfPresent: false}) + } catch { + // best effort by contract + } + } + + private async signIn(): Promise { const {flags} = await this.parse(Login) const noBrowser = flags['no-browser'] diff --git a/src/lib/agent-context.ts b/src/lib/agent-context.ts new file mode 100644 index 0000000..e54344d --- /dev/null +++ b/src/lib/agent-context.ts @@ -0,0 +1,160 @@ +/** + * The AI project-context summary (Hub `GET /{v}/account/ai/context`): the + * projects the signed-in identity may work on — environments, regions, + * modules, integrations (id, name, kind, default per env), CORS origins, + * languages — scoped to its roles and never holding a secret. + * + * `norbix ai context` prints it; `--write` (and, by themselves, `norbix login` + * and a token renewal) keep a copy in the project folder, next to what + * `norbix ai init` wrote, so a coding agent can rebuild its knowledge after + * its own context was compressed: + * + * .norbix-ai/context.md the Markdown summary (what the agent reads) + * .norbix-ai/context.json the structured summary with its `version` + * .norbix-ai/.gitignore `*` — the summary is per person (it follows the + * roles), so it is never committed + * + * Not `.norbix/`: run in the home folder that would be `~/.norbix`, the CLI's + * own config, which agents are told never to read. + * + * Writing on its own (login / renewal) happens only in a folder `norbix ai + * init` set up, and on renewal only when the copy already exists; it never + * fails the command. No oclif import. + */ +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' + +import {MARKER_START} from './agent-files.js' + +export const CONTEXT_DIR = '.norbix-ai' +export const CONTEXT_MD = `${CONTEXT_DIR}/context.md` +export const CONTEXT_JSON = `${CONTEXT_DIR}/context.json` + +/** What the Hub answers (GetAiContextResponse, camelCase). */ +export interface AiContextAnswer { + version?: string + unchanged?: boolean + context?: AiContextSummary + markdown?: string + responseStatus?: {isSuccess?: boolean; errors?: Array<{message?: string; errorCode?: string}>; message?: string} +} + +/** The summary (AiProjectContextWireDto). Only the fields the CLI itself reads are typed. */ +export interface AiContextSummary { + version: string + caller?: {kind?: string; name?: string; accountId?: string} + projects: Array<{id: string; name: string; access: string} & Record> + hints?: string[] +} + +export interface ContextRequest { + /** Hub base without the version, e.g. `https://hub.norbix.ai`. */ + hubBase: string + hubVersion: string + token: string + accountId?: string + projectId?: string + env?: string + region?: string + format: 'json' | 'markdown' + knownVersion?: string + fetchFn?: typeof fetch +} + +export type ContextResult = + | {status: 'ok'; version: string; context?: AiContextSummary; markdown?: string} + | {status: 'unchanged'; version: string} + | {status: 'error'; httpStatus: number; message: string} + +/** One call to `GET /{v}/account/ai/context`. Never throws for an HTTP error; a network error throws. */ +export async function fetchContext(req: ContextRequest): Promise { + const url = new URL(`${req.hubBase.replace(/\/$/, '')}/${req.hubVersion}/account/ai/context`) + url.searchParams.set('format', req.format) + if (req.projectId) url.searchParams.set('projectId', req.projectId) + if (req.knownVersion) url.searchParams.set('knownVersion', req.knownVersion) + + const headers: Record = {Accept: 'application/json', Authorization: `Bearer ${req.token}`} + if (req.accountId) headers['norbix-account-id'] = req.accountId + if (req.env && req.env !== 'PROD') headers['norbix-env'] = req.env + if (req.region) headers['nb-region'] = req.region + + const res = await (req.fetchFn ?? fetch)(url, {headers, signal: AbortSignal.timeout(20_000)}) + const body = (await res.json().catch(() => ({}))) as AiContextAnswer + if (!res.ok || body.responseStatus?.isSuccess === false) { + const message = + body.responseStatus?.errors?.[0]?.message ?? body.responseStatus?.message ?? `HTTP ${res.status} ${res.statusText}`.trim() + return {status: 'error', httpStatus: res.status, message} + } + + const version = body.version ?? body.context?.version ?? '' + if (body.unchanged) return {status: 'unchanged', version} + return {status: 'ok', version, context: body.context, markdown: body.markdown} +} + +/** True when `norbix ai init` set this folder up (any target), or a context copy is already here. */ +export function isAgentFolder(root: string): boolean { + if (existsSync(join(root, CONTEXT_MD))) return true + if (existsSync(join(root, '.claude/skills/norbix/SKILL.md'))) return true + if (existsSync(join(root, '.cursor/rules/norbix.mdc'))) return true + for (const file of ['CLAUDE.md', 'AGENTS.md']) { + try { + if (readFileSync(join(root, file), 'utf8').includes(MARKER_START)) return true + } catch { + // not there + } + } + + return false +} + +/** The version of the copy in this folder, if any. */ +export function storedVersion(root: string): string | undefined { + try { + const doc = JSON.parse(readFileSync(join(root, CONTEXT_JSON), 'utf8')) as {version?: unknown} + return typeof doc.version === 'string' ? doc.version : undefined + } catch { + return undefined + } +} + +/** Writes the three files; returns the paths written (relative to `root`). */ +export function writeContextFiles(root: string, summary: AiContextSummary, markdown: string): string[] { + mkdirSync(join(root, CONTEXT_DIR), {recursive: true}) + writeFileSync(join(root, CONTEXT_DIR, '.gitignore'), '# Per-person Norbix context (follows your roles) — never commit it.\n*\n') + writeFileSync(join(root, CONTEXT_JSON), JSON.stringify(summary, null, 2) + '\n') + writeFileSync(join(root, CONTEXT_MD), markdown.endsWith('\n') ? markdown : markdown + '\n') + return [CONTEXT_MD, CONTEXT_JSON] +} + +export type SyncOutcome = + | {written: string[]; version: string} + | {skipped: 'not an agent folder' | 'no copy yet' | 'unchanged' | 'no credentials'} + | {failed: string} + +/** + * Brings the copy in `root` up to date, quietly: only in an agent folder, + * only when `onlyIfPresent` is false or a copy exists, and only when the + * version changed (one request with `knownVersion`; a changed summary costs a + * second one for the Markdown). Never throws. + */ +export async function syncContext( + root: string, + base: Omit, + opts: {onlyIfPresent: boolean}, +): Promise { + try { + if (!base.token) return {skipped: 'no credentials'} + if (!isAgentFolder(root)) return {skipped: 'not an agent folder'} + const known = storedVersion(root) + if (opts.onlyIfPresent && !existsSync(join(root, CONTEXT_MD))) return {skipped: 'no copy yet'} + + const json = await fetchContext({...base, format: 'json', knownVersion: known}) + if (json.status === 'error') return {failed: json.message} + if (json.status === 'unchanged') return {skipped: 'unchanged'} + const md = await fetchContext({...base, format: 'markdown'}) + if (md.status !== 'ok' || !json.context || !md.markdown) return {failed: md.status === 'error' ? md.message : 'no summary'} + return {written: writeContextFiles(root, json.context, md.markdown), version: json.version} + } catch (error) { + return {failed: error instanceof Error ? error.message : String(error)} + } +} diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts index 5c78e4d..fe3f7af 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -28,6 +28,7 @@ export function recipeBlock(): string { '', '- Start with `norbix whoami --json`. `"auth": "none"` there (or exit code 4 from any other command) means not signed in. Sign in yourself in two steps: run `norbix login --no-browser --json`, show the user the `verificationUriComplete` link and the `userCode` (they open it, check the code and pick the roles you get — an AI service user, removable under Account → AI service users), then run `norbix login --wait --json` until it exits 0. Exit 4 with `AUTHORIZATION_PENDING` means not approved yet: run `--wait` again; `ACCESS_DENIED` / `EXPIRED_TOKEN`: start over. Their Norbix is not norbix.ai? Add `--host ` to both steps.', '- Pass `--json` to every command whose output you read: stdout is then exactly one JSON document (a result, or `{"error": {code, message, exit, fieldErrors, hint, docs}}`).', + '- Project context (your projects, environments, regions, modules, integrations, CORS, languages — never secrets): read `.norbix-ai/context.md` when it is there; run `norbix ai context --write` at the start of a task and again after your context was compressed or summarized (`norbix login` refreshes it too).', '- Discover, do not guess: `norbix schema --json` (every command, `destructive`, `supportsDryRun`), then `norbix --help`. No named command? `norbix hub --json` lists the SDK methods and their fields.', '- Before any change, run it with `--dry-run --json` (sends nothing) and show the user the request. Then re-run without `--dry-run`, adding `--yes` only for a destructive command (without it the command exits 3 and sends nothing).', '- Never print, paste or ask for API keys, passwords or tokens, and never read `~/.norbix`. Credentials come from `norbix login`, a `--profile`, or `NORBIX_*` variables the user set (CI: `NORBIX_HOST` + `NORBIX_API_KEY` + `NORBIX_PROJECT_ID`).', @@ -56,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.', + ' Then load the project context: read `.norbix-ai/context.md` if it is there, else run `norbix ai context --write` (it writes that file: every project you may work on — environments, regions, modules, integrations with their default per environment, CORS origins, languages; no secrets). Read it again whenever your context was compressed or summarized; `norbix ai context --json` has a `version` that changes only when the summary does.', '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.', '4. Change in two steps: `norbix ... --dry-run --json` prints the exact request and sends nothing — show it to the user. Then re-run without `--dry-run`, adding `--yes` only if the command is destructive. Never add `--yes` to a first attempt.', diff --git a/src/lib/session-auth.ts b/src/lib/session-auth.ts index dd64486..a468301 100644 --- a/src/lib/session-auth.ts +++ b/src/lib/session-auth.ts @@ -70,6 +70,8 @@ export function sessionExpiredError(reason?: string): CliError { export class SessionRefresher { private inFlight?: Promise private forced = false + /** True once this run renewed the access token (read by the agent-context refresh after the command). */ + private renewedThisRun = false /** A refresh that ended the sign-in: every later call fails the same way, with no new request. */ private ended?: CliError private readonly fetchFn: typeof fetch @@ -92,6 +94,11 @@ export class SessionRefresher { this.clear = deps.clear ?? (key ? () => clearSession(key) : () => {}) } + /** True when this run renewed the sign-in's access token. */ + get renewed(): boolean { + return this.renewedThisRun + } + /** The current access token (after any refresh this run made). */ get token(): string { return this.session.bearerToken @@ -168,6 +175,7 @@ export class SessionRefresher { } this.write(next) this.session = next + this.renewedThisRun = true return next } diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index f55b20e..420335d 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -79,6 +79,16 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "account usage", "supportsDryRun": false, }, + { + "args": [], + "destructive": false, + "flags": [ + "write:boolean", + "dir:string", + ], + "id": "ai context", + "supportsDryRun": false, + }, { "args": [], "destructive": false, diff --git a/tests/ai-context.test.ts b/tests/ai-context.test.ts new file mode 100644 index 0000000..428de63 --- /dev/null +++ b/tests/ai-context.test.ts @@ -0,0 +1,250 @@ +import {existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs' +import http from 'node:http' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' + +import {MARKER_START, recipeBlock} from '../src/lib/agent-files.js' +import {cli, parseSingleJson} from './_cli.js' + +/** + * `norbix ai context` and the agent-context copy (.norbix-ai/context.md), + * seen from outside: the BUILT CLI against a fake Hub that answers + * `GET /v3/account/ai/context` like the gateway (json / markdown, + * knownVersion → unchanged). Covers the command, --write, --project, the + * refresh after `norbix login` and after a token renewal, and that nothing is + * written in a folder `norbix ai init` did not set up. + */ + +interface Hit { + method: string + url: string + auth?: string +} + +let port = 0 +let server: http.Server +const hits: Hit[] = [] +let version = 'v1' +let contextStatus = 200 + +const summary = () => ({ + version, + caller: {kind: 'ai-service-user', name: 'Norbix CLI (test)', accountId: 'acc_1'}, + projects: [ + { + id: 'pr_alpha', + name: 'Happy Tummy', + access: 'full', + defaultLanguage: 'lt', + languages: ['lt', 'en'], + environments: ['PROD', 'TEST'], + corsOrigins: ['https://app.happytummy.lt'], + modules: [{name: 'Email', integrationsReadable: true, integrations: [{id: 'int_1', name: 'SendGrid main', kind: 'SendGrid', enabled: true, defaultIn: ['PROD']}]}], + }, + ], + hints: ['Re-read it after your context was compressed.'], +}) + +const markdown = () => `# Norbix project context (version ${version})\n\n## Happy Tummy (pr_alpha)\n\n- access: full\n` + +beforeAll(async () => { + server = http.createServer((req, res) => { + req.resume() + req.on('end', () => { + const url = new URL(req.url ?? '/', 'http://x') + res.setHeader('content-type', 'application/json') + if (url.pathname === '/.well-known/norbix.json') { + res.statusCode = 404 + return res.end('{}') + } + + if (url.pathname === '/v3/echo') { + const self = `http://127.0.0.1:${port}/v3` + return res.end(JSON.stringify({hubUrl: self, apiUrl: self, hubVersion: 'v3', apiVersion: 'v3'})) + } + + hits.push({method: req.method ?? '', url: req.url ?? '', auth: req.headers.authorization}) + if (url.pathname === '/v3/oauth/token') { + return res.end('{"access_token":"access-new","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-new","scope":""}') + } + + if (url.pathname === '/v3/account/ai/context') { + if (contextStatus !== 200) { + res.statusCode = contextStatus + return res.end('{"responseStatus":{"isSuccess":false,"message":"Not found"}}') + } + + if (url.searchParams.get('knownVersion') === version) return res.end(JSON.stringify({version, unchanged: true})) + return res.end( + JSON.stringify( + url.searchParams.get('format') === 'markdown' ? {version, markdown: markdown()} : {version, context: summary()}, + ), + ) + } + + res.end('{"item":{"environments":["PROD"]}}') + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + port = typeof address === 'object' && address ? address.port : 0 +}) + +afterAll(() => new Promise((resolve) => server.close(() => resolve()))) + +beforeEach(() => { + hits.length = 0 + version = 'v1' + contextStatus = 200 +}) + +/** A HOME with an api-key profile for the fake Hub; optionally a browser sign-in that expires in `secondsLeft`. */ +function makeHome(opts: {session?: number} = {}): string { + const dir = mkdtempSync(join(tmpdir(), 'norbix-ai-context-')) + mkdirSync(join(dir, '.norbix', 'sessions'), {recursive: true}) + const profile = opts.session === undefined ? ['api_key=k'] : [] + writeFileSync(join(dir, '.norbix', 'config'), ['[default]', `host=127.0.0.1:${port}`, ...profile, 'project_id=p1', ''].join('\n')) + if (opts.session !== undefined) { + writeFileSync( + join(dir, '.norbix', 'sessions', `127.0.0.1_${port}.json`), + JSON.stringify({ + hubUrl: `http://127.0.0.1:${port}`, + host: `127.0.0.1:${port}`, + bearerToken: 'access-old', + refreshToken: 'refresh-old', + clientId: 'norbix-cli', + method: 'browser', + hubVersion: 'v3', + userId: 'u1', + userName: 'norbix-cli-test', + displayName: 'Norbix CLI (test)', + expiresAt: new Date(Date.now() + opts.session * 1000).toISOString(), + }), + ) + } + + return dir +} + +/** Marks `dir` as set up by `norbix ai init` (the CLAUDE.md block). */ +const markAgentFolder = (dir: string) => writeFileSync(join(dir, 'CLAUDE.md'), `# App\n\n${recipeBlock()}`) + +const contextCalls = () => hits.filter((h) => h.url.startsWith('/v3/account/ai/context')).map((h) => h.url) + +describe('norbix ai context', () => { + it('--json prints the summary as one JSON document; one json call, Bearer the key', async () => { + const home = makeHome() + const r = await cli(home, ['ai', 'context', '--json']) + expect(r.code).toBe(0) + const doc = parseSingleJson(r.stdout) as {version: string; projects: Array<{name: string; access: string}>} + expect({version: doc.version, projects: doc.projects.map((p) => `${p.name}: ${p.access}`)}).toEqual({ + version: 'v1', + projects: ['Happy Tummy: full'], + }) + expect(contextCalls()).toEqual(['/v3/account/ai/context?format=json']) + expect(hits[0].auth).toBe('Bearer k') + // The saved project is a default, not a filter: no projectId without --project. + expect(existsSync(join(home, '.norbix-ai'))).toBe(false) + }) + + it('prints Markdown by default', async () => { + const r = await cli(makeHome(), ['ai', 'context']) + expect(r.code).toBe(0) + expect(r.stdout).toBe(markdown().trimEnd() + '\n') + expect(contextCalls()).toEqual(['/v3/account/ai/context?format=json', '/v3/account/ai/context?format=markdown']) + }) + + it('--project narrows the summary to one project', async () => { + const r = await cli(makeHome(), ['ai', 'context', '--project', 'pr_alpha', '--json']) + expect(r.code).toBe(0) + expect(contextCalls()).toEqual(['/v3/account/ai/context?format=json&projectId=pr_alpha']) + }) + + it('--write saves context.md, context.json and a .gitignore that keeps them out of git', async () => { + const home = makeHome() + const dir = mkdtempSync(join(tmpdir(), 'norbix-ai-context-dir-')) + const r = await cli(home, ['ai', 'context', '--write', '--dir', dir, '--json']) + expect(r.code).toBe(0) + expect((parseSingleJson(r.stdout) as {written: string[]}).written).toEqual(['.norbix-ai/context.md', '.norbix-ai/context.json']) + expect(readFileSync(join(dir, '.norbix-ai/context.md'), 'utf8')).toBe(markdown()) + expect(JSON.parse(readFileSync(join(dir, '.norbix-ai/context.json'), 'utf8'))).toEqual(summary()) + expect(readFileSync(join(dir, '.norbix-ai/.gitignore'), 'utf8')).toMatch(/^\*$/m) + }) + + it('an older Hub (404) exits 5 and says the gateway is older', async () => { + contextStatus = 404 + const r = await cli(makeHome(), ['ai', 'context', '--json']) + expect(r.code).toBe(5) + const err = (parseSingleJson(r.stdout) as {error: {code: string; hint: string}}).error + expect(err.code).toBe('NOT_FOUND') + expect(err.hint).toMatch(/older than the project-context summary/) + }) +}) + +describe('the agent-context copy refreshes by itself', () => { + it('norbix login --api-key in a folder set up by ai init writes it', async () => { + const home = mkdtempSync(join(tmpdir(), 'norbix-ai-context-login-')) + markAgentFolder(home) + const r = await cli(home, ['login', '--api-key', 'k', '--project', 'p1', '--host', `127.0.0.1:${port}`, '--profile', 't', '--json']) + expect(r.code).toBe(0) + parseSingleJson(r.stdout) + expect(contextCalls()).toEqual(['/v3/account/ai/context?format=json', '/v3/account/ai/context?format=markdown']) + expect(readFileSync(join(home, '.norbix-ai/context.md'), 'utf8')).toBe(markdown()) + }) + + it('norbix login in a folder ai init did not set up writes nothing and calls nothing', async () => { + const home = mkdtempSync(join(tmpdir(), 'norbix-ai-context-plain-')) + const r = await cli(home, ['login', '--api-key', 'k', '--project', 'p1', '--host', `127.0.0.1:${port}`, '--profile', 't', '--json']) + expect(r.code).toBe(0) + expect(contextCalls()).toEqual([]) + expect(existsSync(join(home, '.norbix-ai'))).toBe(false) + }) + + it('a token renewal refreshes an existing copy whose version changed; stdout stays one JSON document', async () => { + const home = makeHome({session: 30}) + markAgentFolder(home) + mkdirSync(join(home, '.norbix-ai')) + writeFileSync(join(home, '.norbix-ai/context.md'), 'old') + writeFileSync(join(home, '.norbix-ai/context.json'), JSON.stringify({version: 'v0', projects: []})) + + const r = await cli(home, ['env', 'list', '--json']) + expect(r.code).toBe(0) + parseSingleJson(r.stdout) + expect(contextCalls()).toEqual([ + '/v3/account/ai/context?format=json&knownVersion=v0', + '/v3/account/ai/context?format=markdown', + ]) + expect(hits.find((h) => h.url.startsWith('/v3/account/ai/context'))?.auth).toBe('Bearer access-new') + expect(readFileSync(join(home, '.norbix-ai/context.md'), 'utf8')).toBe(markdown()) + }) + + it('a renewal with an unchanged version rewrites nothing', async () => { + const home = makeHome({session: 30}) + markAgentFolder(home) + mkdirSync(join(home, '.norbix-ai')) + writeFileSync(join(home, '.norbix-ai/context.md'), 'kept') + writeFileSync(join(home, '.norbix-ai/context.json'), JSON.stringify({version: 'v1', projects: []})) + + const r = await cli(home, ['env', 'list', '--json']) + expect(r.code).toBe(0) + expect(contextCalls()).toEqual(['/v3/account/ai/context?format=json&knownVersion=v1']) + expect(readFileSync(join(home, '.norbix-ai/context.md'), 'utf8')).toBe('kept') + }) + + it('a renewal without a copy yet does not create one', async () => { + const home = makeHome({session: 30}) + markAgentFolder(home) + const r = await cli(home, ['env', 'list', '--json']) + expect(r.code).toBe(0) + expect(contextCalls()).toEqual([]) + expect(existsSync(join(home, '.norbix-ai'))).toBe(false) + }) + + it('ai init tells the agent where the context is and how to refresh it', () => { + expect(recipeBlock()).toContain(MARKER_START) + expect(recipeBlock()).toContain('.norbix-ai/context.md') + expect(recipeBlock()).toContain('norbix ai context --write') + }) +}) diff --git a/tests/ai-init.test.ts b/tests/ai-init.test.ts index fd66a4e..f90a0d4 100644 --- a/tests/ai-init.test.ts +++ b/tests/ai-init.test.ts @@ -100,6 +100,7 @@ describe('norbix ai init', () => { expect(bullets.map((l) => l.slice(0, 40))).toEqual([ '- Start with `norbix whoami --json`. `"a', '- Pass `--json` to every command whose o', + '- Project context (your projects, enviro', '- Discover, do not guess: `norbix schema', '- Before any change, run it with `--dry-', '- Never print, paste or ask for API keys', @@ -109,7 +110,7 @@ describe('norbix ai init', () => { expect(bullets[0]).toContain('run `norbix login --no-browser --json`, show the user the `verificationUriComplete` link and the `userCode`') expect(bullets[0]).toContain('pick the roles you get') expect(bullets[0]).toContain('Account → AI service users') - expect(bullets[3]).toContain('show the user the request') + expect(bullets[4]).toContain('show the user the request') expect(block).not.toMatch(/norbix login --api-key|nbk_/) expect(block.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(12) From 7bc583511e3f103b077b8f459b2f7b373792eb79 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:28:41 +0300 Subject: [PATCH 02/19] =?UTF-8?q?feat(db):=20db=20taxonomy=20create=20?= =?UTF-8?q?=E2=80=94=20create=20a=20taxonomy=20without=20the=20hub=20escap?= =?UTF-8?q?e=20hatch=20(N9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Happy Tummy needed taxonomies before their schemas and had to call `norbix hub database saveDatabaseTaxonomy`. The new command sends saveDatabaseTaxonomy without a viewId (create, never replace), with --description, --terms-meta-schema / --terms-ui-schema files, --parent and repeatable --dependency, and --dry-run. Co-Authored-By: Claude Opus 5.5 --- COVERAGE.md | 2 +- README.md | 2 +- docs/database.md | 3 +- src/commands/db/taxonomy/create.ts | 54 +++++++++++++++++++++++++ tests/__snapshots__/schema.test.ts.snap | 16 ++++++++ tests/db-routes.test.ts | 17 +++++++- 6 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/commands/db/taxonomy/create.ts diff --git a/COVERAGE.md b/COVERAGE.md index 859fd7b..bd7ff2b 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -23,7 +23,7 @@ out of reach. | --- | --- | --- | | database/collections | 13/15 | find, find-one, count, insert, insert-many, update(+many), replace, delete(+many), distinct, aggregate, execute-aggregate. Missing: find-own, change-responsibility | | database/schemas | 2/18 | read-only list/get. Schema editing is portal work | -| database/taxonomies | 2/15 | read-only list/get | +| database/taxonomies | 3/15 | list/get, create | | database/aggregates | 1/5 | list only | | files/* | 9/23 | upload, download, list, info, sign, delete. Missing: bulk delete, triggers, integration config | | logs/* | 6/14 | list, trail, settings, module toggle. Missing: clean, integration config | diff --git a/README.md b/README.md index 56a45a3..e7d6768 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ norbix scheduler list | --- | --- | | `norbix login` / `logout` / `whoami` | Authenticate and inspect the current context | | `norbix config list/get/set/unset` | Read and change one profile in `~/.norbix/config` (`--profile`, default `[default]`) | -| `norbix db ...` | Database records (find/get/count/insert/update/replace/delete/aggregate/change-owner), schemas (create/update/publish/delete/versions/diff), schema triggers, integrations, taxonomies and terms, indexes, test-data seed — see [docs/database.md](docs/database.md) | +| `norbix db ...` | Database records (find/get/count/insert/update/replace/delete/aggregate/change-owner), schemas (create/update/publish/delete/versions/diff), schema triggers, integrations, taxonomies (list/show/create) and terms, indexes, test-data seed — see [docs/database.md](docs/database.md) | | `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) | diff --git a/docs/database.md b/docs/database.md index 467fe3a..a88a864 100644 --- a/docs/database.md +++ b/docs/database.md @@ -240,6 +240,7 @@ module alike. |---|---|---| | `norbix db taxonomies` | list taxonomies | `GET /taxonomies` | | `norbix db taxonomy ` | show one taxonomy | `GET /taxonomies/{id}` | +| `norbix db taxonomy create [--description ] [--terms-meta-schema ] [--terms-ui-schema ] [--parent ] [--dependency …]` | create a taxonomy | `POST /taxonomies` | | `norbix db terms [--filter ] [--page-size ] [--after ] [--desc]` | list the terms of a taxonomy | `GET /taxonomies/{taxonomyName}/terms` | | `norbix db terms --parent ` | only the children of one term | `GET /taxonomies/{taxonomyName}/terms/{parentId}/children` | | `norbix db term ` | show one term | `GET /taxonomies/{TaxonomyId}/terms/{Id}` | @@ -295,7 +296,7 @@ state. `norbix hub database ` reaches every one of them. | endpoint | why there is no command | |---|---| -| `POST /taxonomies`, `DELETE /taxonomies/{Id}`, `GET /taxonomies/tree`, `DELETE /taxonomies/{TaxonomyId}/terms/many` | Taxonomy structure (dependencies, translatable names) is edited in the dashboard; terms — the data — have commands. | +| `POST /taxonomies` with a `viewId` (replace), `DELETE /taxonomies/{Id}`, `GET /taxonomies/tree`, `DELETE /taxonomies/{TaxonomyId}/terms/many` | Changing a taxonomy's structure (dependencies, translatable names) is done in the dashboard; creating one is `db taxonomy create`, and terms — the data — have commands. | | `POST /schemas/apply-bundle` | Applies a whole generated bundle of schemas; it is the AI schema builder's step, not a hand-run one. | | `PUT /schemas/{Id}/rename`, `/embed`, `/list-settings`, `/settings` | Dashboard settings of one schema (title, embedding, list columns, soft delete / owner). | | `POST /integrations`, `DELETE /integrations/{Id}`, `PUT /integrations/{Id}/enable` / `disable`, `GET /integrations/flex-tiers`, `GET /integrations/{Id}/connection-string` | Creating or removing a database, and revealing its connection string, are account-level decisions taken in the dashboard; a connection string should not land in a shell history. | diff --git a/src/commands/db/taxonomy/create.ts b/src/commands/db/taxonomy/create.ts new file mode 100644 index 0000000..9f2b2bf --- /dev/null +++ b/src/commands/db/taxonomy/create.ts @@ -0,0 +1,54 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {fileSource, readJsonText} from '../../../lib/database.js' + +export default class DbTaxonomyCreate extends BaseCommand { + static description = `Create a database taxonomy (a list of terms, e.g. countries or allergens) + + is the title ("Allergens"); the Hub derives the slug ("allergens") +that \`db terms \` and schema references use. Add terms afterwards with +\`db term create --doc \`. + +--terms-meta-schema is an optional data meta-schema (JSON) of the custom fields +every term of this taxonomy has; --terms-ui-schema the form that edits them. +--parent makes it a child taxonomy (its terms can have a parent term there).` + + static examples = [ + '<%= config.bin %> db taxonomy create Allergens', + '<%= config.bin %> db taxonomy create Allergens --description "Food allergens" --dry-run', + '<%= config.bin %> db taxonomy create Symptoms --terms-meta-schema symptom.meta.json', + '<%= config.bin %> db taxonomy create Cities --parent txn_5BBS25C3IKHRfzVABMjMiM', + ] + + static args = { + name: Args.string({required: true, description: 'Taxonomy title, e.g. "Allergens"'}), + } + + static flags = { + ...BaseCommand.dryRunFlags, + description: Flags.string({description: 'Free-text description'}), + 'terms-meta-schema': Flags.string({description: 'JSON file with the meta-schema of the terms\' custom fields (or `-` for stdin)'}), + 'terms-ui-schema': Flags.string({description: 'JSON file with the UI schema of the terms\' edit form'}), + parent: Flags.string({description: 'Parent taxonomy id (txn_…): makes this a child taxonomy'}), + dependency: Flags.string({description: 'Another taxonomy id this one depends on for multi-parent terms (repeatable)', multiple: true}), + } + + async run(): Promise { + const {args, flags} = await this.parse(DbTaxonomyCreate) + const client = this.client(flags) + + // No viewId: the Hub creates a new taxonomy (with one it would replace that taxonomy). + const res = await client.hub.database.saveDatabaseTaxonomy({ + taxonomyName: args.name, + description: flags.description, + termsMetaDataSchema: flags['terms-meta-schema'] ? await readJsonText(fileSource(flags['terms-meta-schema']), 'terms-meta-schema') : undefined, + termsMetaVisualSchema: flags['terms-ui-schema'] ? await readJsonText(fileSource(flags['terms-ui-schema']), 'terms-ui-schema') : undefined, + parentId: flags.parent, + dependencies: flags.dependency, + }) + + this.print(res && typeof res === 'object' && 'id' in res ? `Taxonomy "${args.name}" created: ${String((res as {id?: unknown}).id)}.` : res) + return res + } +} diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index 420335d..2047ab1 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -1112,6 +1112,22 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "db taxonomy", "supportsDryRun": false, }, + { + "args": [ + "name", + ], + "destructive": false, + "flags": [ + "dry-run:boolean", + "description:string", + "terms-meta-schema:string", + "terms-ui-schema:string", + "parent:string", + "dependency:string", + ], + "id": "db taxonomy create", + "supportsDryRun": true, + }, { "args": [ "taxonomyId", diff --git a/tests/db-routes.test.ts b/tests/db-routes.test.ts index f9e8bb1..c1a105b 100644 --- a/tests/db-routes.test.ts +++ b/tests/db-routes.test.ts @@ -130,6 +130,7 @@ const routes: Array<[string[], string, string]> = [ // Taxonomies and terms [['taxonomies'], 'GET', `${P}/taxonomies`], [['taxonomy', TAX], 'GET', `${P}/taxonomies/${TAX}`], + [['taxonomy', 'create', 'Allergens'], 'POST', `${P}/taxonomies`], [['terms', 'countries'], 'GET', `${P}/taxonomies/countries/terms`], [['terms', 'countries', '--parent', ID], 'GET', `${P}/taxonomies/countries/terms/${ID}/children`], [['term', TAX, ID], 'GET', `${P}/taxonomies/${TAX}/terms/${ID}`], @@ -153,10 +154,10 @@ describe('every db command reaches its route', () => { } }) -it('covers 48 distinct db routes', () => { +it('covers 49 distinct db routes', () => { // `db distinct`/`db delete`/`db update`/`db aggregate` each have two shapes; // count distinct verb + path pairs so a duplicated row is caught. - expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(48) + expect(new Set(routes.map(([, verb, path]) => `${verb} ${path}`)).size).toBe(49) }) /** Run a command and return the one request it sent. */ @@ -177,6 +178,18 @@ async function sentOf(argv: string[]): Promise> { return {...Object.fromEntries(call.query), ...call.body} } +describe('db taxonomy create', () => { + it('sends the title and the optional fields, never a viewId (that would replace a taxonomy)', async () => { + const body = await bodyOf(['taxonomy', 'create', 'Allergens', '--description', 'Food', '--parent', 'txn_P', '--dependency', 'txn_A', '--dependency', 'txn_B']) + expect(body).toEqual({taxonomyName: 'Allergens', description: 'Food', parentId: 'txn_P', dependencies: ['txn_A', 'txn_B']}) + }) + + it('--terms-meta-schema sends the file as a JSON string', async () => { + const body = await bodyOf(['taxonomy', 'create', 'Symptoms', '--terms-meta-schema', SCHEMA_FILE]) + expect(JSON.parse(body.termsMetaDataSchema as string)).toEqual(dataSchema) + }) +}) + describe('db update / delete of many records', () => { it('update --many sends the filter and the plain fields, without allRecords', async () => { expect(await bodyOf(['update', COL, '--many', '--filter', '{"status":"new"}', '--update', '{"status":"paid"}', '--yes'])).toEqual({ From 57dd87dc387a49f621beff3e56e3a4933db23f26 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:34:54 +0300 Subject: [PATCH 03/19] fix(db): every term command takes the taxonomy id or its name (N10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `db term create txn_…` worked, then `db terms txn_…` answered 404 — the gateway routes reads by taxonomy name and single-term writes by taxonomy id. The id (txn_…) is the gateway's canonical key; the CLI now accepts either in db taxonomy, terms, term, term tree/create/update/delete and looks up the other with one read (getDatabaseTaxonomies / getDatabaseTaxonomy). Only a txn_ value is an id (the gateway accepts no other form), so there is no ambiguity. An unknown name exits 5 TAXONOMY_NOT_FOUND listing the taxonomies; a dry run sends no lookup and shows a placeholder. New BaseCommand.lookup() for such pre-call reads. Co-Authored-By: Claude Opus 5.5 --- docs/database.md | 34 ++++++----- src/base.ts | 10 ++++ src/commands/db/taxonomy.ts | 8 ++- src/commands/db/term.ts | 8 ++- src/commands/db/term/create.ts | 16 ++++-- src/commands/db/term/delete.ts | 10 ++-- src/commands/db/term/tree.ts | 8 ++- src/commands/db/term/update.ts | 10 ++-- src/commands/db/terms.ts | 9 ++- src/lib/taxonomy.ts | 100 +++++++++++++++++++++++++++++++++ tests/db-routes.test.ts | 76 ++++++++++++++++++++++++- 11 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 src/lib/taxonomy.ts diff --git a/docs/database.md b/docs/database.md index a88a864..9ce012d 100644 --- a/docs/database.md +++ b/docs/database.md @@ -239,20 +239,24 @@ module alike. | command | what it does | endpoint | |---|---|---| | `norbix db taxonomies` | list taxonomies | `GET /taxonomies` | -| `norbix db taxonomy ` | show one taxonomy | `GET /taxonomies/{id}` | +| `norbix db taxonomy ` | show one taxonomy | `GET /taxonomies/{id}` | | `norbix db taxonomy create [--description ] [--terms-meta-schema ] [--terms-ui-schema ] [--parent ] [--dependency …]` | create a taxonomy | `POST /taxonomies` | -| `norbix db terms [--filter ] [--page-size ] [--after ] [--desc]` | list the terms of a taxonomy | `GET /taxonomies/{taxonomyName}/terms` | -| `norbix db terms --parent ` | only the children of one term | `GET /taxonomies/{taxonomyName}/terms/{parentId}/children` | -| `norbix db term ` | show one term | `GET /taxonomies/{TaxonomyId}/terms/{Id}` | -| `norbix db term tree [--root ] [--depth ]` | the terms as a tree | `GET /taxonomies/{TaxonomyName}/terms/tree` | -| `norbix db term tree --merged` | one tree across the parent taxonomies | `GET /taxonomies/{TaxonomyName}/merged-tree` | -| `norbix db term create --doc ` | add a term | `POST /taxonomies/{TaxonomyId}/terms` | -| `norbix db term update --set ` | change some fields of a term | `PUT /taxonomies/{TaxonomyId}/terms/{Id}` | -| `norbix db term delete [--yes]` | delete a term | `DELETE /taxonomies/{TaxonomyId}/terms/{Id}` | - -Note the two kinds of key: listing and trees take the taxonomy **name**; a single -term is addressed by the taxonomy **id** and the term id (that is how the -gateway routes them). `--set` is applied with `$set`: only the fields you give +| `norbix db terms [--filter ] [--page-size ] [--after ] [--desc]` | list the terms of a taxonomy | `GET /taxonomies/{taxonomyName}/terms` | +| `norbix db terms --parent ` | only the children of one term | `GET /taxonomies/{taxonomyName}/terms/{parentId}/children` | +| `norbix db term ` | show one term | `GET /taxonomies/{TaxonomyId}/terms/{Id}` | +| `norbix db term tree [--root ] [--depth ]` | the terms as a tree | `GET /taxonomies/{TaxonomyName}/terms/tree` | +| `norbix db term tree --merged` | one tree across the parent taxonomies | `GET /taxonomies/{TaxonomyName}/merged-tree` | +| `norbix db term create --doc ` | add a term | `POST /taxonomies/{TaxonomyId}/terms` | +| `norbix db term update --set ` | change some fields of a term | `PUT /taxonomies/{TaxonomyId}/terms/{Id}` | +| `norbix db term delete [--yes]` | delete a term | `DELETE /taxonomies/{TaxonomyId}/terms/{Id}` | + +`` is the taxonomy **id** (`txn_…`) or its **name** (the title or +its slug, any case) in every command. The gateway routes them with two kinds +of key — listing and trees by name, a single term by the taxonomy id — so the +CLI looks up the other one when needed (one extra read; a `--dry-run` sends +nothing and shows `` instead). The id is the stable key: a +title can be renamed. An unknown name exits 5 (`TAXONOMY_NOT_FOUND`) with the +taxonomies there are. `--set` is applied with `$set`: only the fields you give change — `name` (a string or a `{lang: value}` map), `description`, `order` (lower shows first), `parentId`, `multiParents`. @@ -269,8 +273,8 @@ merged tree checks every parent taxonomy too. Errors you may meet: (a name longer than 40 characters). ```bash -norbix db term create 66b2f0a1c3d4e5f6a7b8c9d0 --doc '{"name":"Lithuania","order":1}' -norbix db term update 66b2f0a1c3d4e5f6a7b8c9d0 66b2f0a1c3d4e5f6a7b8c9d1 --set '{"order":2}' +norbix db term create countries --doc '{"name":"Lithuania","order":1}' +norbix db term update txn_5BBS25C3IKHRfzVABMjMiM 66b2f0a1c3d4e5f6a7b8c9d1 --set '{"order":2}' norbix db term tree countries --depth 2 ``` diff --git a/src/base.ts b/src/base.ts index c0556f5..f28c6f9 100644 --- a/src/base.ts +++ b/src/base.ts @@ -818,6 +818,16 @@ export abstract class BaseCommand extends Command { }) } + /** + * A read a command needs before its real call — the id behind a name, the + * default integration. A dry run sends nothing, so it gets `placeholder` + * (a readable stand-in that shows up in the printed request) instead. + */ + protected async lookup(flags: GlobalFlags, placeholder: T, read: (client: Norbix) => Promise): Promise { + if (flags['dry-run']) return placeholder + return read(this.client(flags)) + } + /** * The account's projects (Hub `account/projects`) with their primary * region, or undefined when they cannot be read. An account-level call: on diff --git a/src/commands/db/taxonomy.ts b/src/commands/db/taxonomy.ts index 2fa6008..dea52a2 100644 --- a/src/commands/db/taxonomy.ts +++ b/src/commands/db/taxonomy.ts @@ -1,21 +1,23 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' +import {TAXONOMY_KEY_DESCRIPTION, dryRunTaxonomyId, taxonomyIdOf} from '../../lib/taxonomy.js' export default class DbTaxonomy extends BaseCommand { static description = 'Show one database taxonomy' - static examples = ['<%= config.bin %> db taxonomy 66b2f0a1c3d4e5f6a7b8c9d0'] + static examples = ['<%= config.bin %> db taxonomy txn_5BBS25C3IKHRfzVABMjMiM', '<%= config.bin %> db taxonomy allergens'] static args = { - id: Args.string({required: true, description: 'Taxonomy ID'}), + id: Args.string({required: true, description: TAXONOMY_KEY_DESCRIPTION}), } async run(): Promise { const {args, flags} = await this.parse(DbTaxonomy) const client = this.client(flags) - const res = await client.hub.database.getDatabaseTaxonomy({id: args.id}) + const id = await this.lookup(flags, dryRunTaxonomyId(args.id), (c) => taxonomyIdOf(c.hub, args.id)) + const res = await client.hub.database.getDatabaseTaxonomy({id}) this.print(res) return res diff --git a/src/commands/db/term.ts b/src/commands/db/term.ts index 5821c90..deae603 100644 --- a/src/commands/db/term.ts +++ b/src/commands/db/term.ts @@ -2,14 +2,15 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' import {databaseIntegrationFlag} from '../../lib/database.js' +import {TAXONOMY_KEY_DESCRIPTION, dryRunTaxonomyId, taxonomyIdOf} from '../../lib/taxonomy.js' export default class DbTerm extends BaseCommand { static description = 'Show one taxonomy term' - static examples = ['<%= config.bin %> db term 66b2f0a1c3d4e5f6a7b8c9d0 66b2f0a1c3d4e5f6a7b8c9d1'] + static examples = ['<%= config.bin %> db term countries 66b2f0a1c3d4e5f6a7b8c9d1'] static args = { - taxonomyId: Args.string({required: true, description: 'Taxonomy ID'}), + taxonomyId: Args.string({required: true, description: TAXONOMY_KEY_DESCRIPTION}), id: Args.string({required: true, description: 'Term ID'}), } @@ -20,9 +21,10 @@ export default class DbTerm extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(DbTerm) const client = this.client(flags) + const taxonomyId = await this.lookup(flags, dryRunTaxonomyId(args.taxonomyId), (c) => taxonomyIdOf(c.hub, args.taxonomyId)) const res = await client.hub.database.getDatabaseTaxonomyTerm({ - taxonomyId: args.taxonomyId, + taxonomyId, id: args.id, databaseIntegrationId: flags.integration, }) diff --git a/src/commands/db/term/create.ts b/src/commands/db/term/create.ts index 21a36fc..afa1109 100644 --- a/src/commands/db/term/create.ts +++ b/src/commands/db/term/create.ts @@ -2,18 +2,21 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' import {databaseIntegrationFlag, readJsonText} from '../../../lib/database.js' +import {TAXONOMY_KEY_DESCRIPTION, dryRunTaxonomyId, taxonomyIdOf} from '../../../lib/taxonomy.js' export default class DbTermCreate extends BaseCommand { - static description = 'Add one term to a taxonomy' + static description = `Add one term to a taxonomy + +The taxonomy is its id (txn_…) or its name — the same key every term command takes.` static examples = [ - `<%= config.bin %> db term create 66b2f0a1c3d4e5f6a7b8c9d0 --doc '{"name":"Lithuania","order":1}'`, - '<%= config.bin %> db term create 66b2f0a1c3d4e5f6a7b8c9d0 --doc @term.json', - `<%= config.bin %> db term create 66b2f0a1c3d4e5f6a7b8c9d0 --doc '{"name":"Lithuania"}' --dry-run`, + `<%= config.bin %> db term create countries --doc '{"name":"Lithuania","order":1}'`, + '<%= config.bin %> db term create txn_5BBS25C3IKHRfzVABMjMiM --doc @term.json', + `<%= config.bin %> db term create countries --doc '{"name":"Lithuania"}' --dry-run`, ] static args = { - taxonomyId: Args.string({required: true, description: 'Taxonomy ID'}), + taxonomyId: Args.string({required: true, description: TAXONOMY_KEY_DESCRIPTION}), } static flags = { @@ -25,9 +28,10 @@ export default class DbTermCreate extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(DbTermCreate) const client = this.client(flags) + const taxonomyId = await this.lookup(flags, dryRunTaxonomyId(args.taxonomyId), (c) => taxonomyIdOf(c.hub, args.taxonomyId)) const res = await client.hub.database.saveDatabaseTaxonomyTerm({ - taxonomyId: args.taxonomyId, + taxonomyId, databaseIntegrationId: flags.integration, document: await readJsonText(flags.doc, 'doc'), }) diff --git a/src/commands/db/term/delete.ts b/src/commands/db/term/delete.ts index 31b874e..052853f 100644 --- a/src/commands/db/term/delete.ts +++ b/src/commands/db/term/delete.ts @@ -2,17 +2,18 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../../base.js' import {databaseIntegrationFlag} from '../../../lib/database.js' +import {TAXONOMY_KEY_DESCRIPTION, dryRunTaxonomyId, taxonomyIdOf} from '../../../lib/taxonomy.js' export default class DbTermDelete extends BaseCommand { static description = 'Delete one taxonomy term' static examples = [ - '<%= config.bin %> db term delete 66b2f0a1c3d4e5f6a7b8c9d0 66b2f0a1c3d4e5f6a7b8c9d1 --yes', - '<%= config.bin %> db term delete 66b2f0a1c3d4e5f6a7b8c9d0 66b2f0a1c3d4e5f6a7b8c9d1 --dry-run', + '<%= config.bin %> db term delete txn_5BBS25C3IKHRfzVABMjMiM 66b2f0a1c3d4e5f6a7b8c9d1 --yes', + '<%= config.bin %> db term delete txn_5BBS25C3IKHRfzVABMjMiM 66b2f0a1c3d4e5f6a7b8c9d1 --dry-run', ] static args = { - taxonomyId: Args.string({required: true, description: 'Taxonomy ID'}), + taxonomyId: Args.string({required: true, description: TAXONOMY_KEY_DESCRIPTION}), id: Args.string({required: true, description: 'Term ID'}), } @@ -24,11 +25,12 @@ export default class DbTermDelete extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(DbTermDelete) const client = this.client(flags) + const taxonomyId = await this.lookup(flags, dryRunTaxonomyId(args.taxonomyId), (c) => taxonomyIdOf(c.hub, args.taxonomyId)) await this.confirmOrFail(`Delete term ${args.id} from taxonomy ${args.taxonomyId}?`, flags) const res = await client.hub.database.deleteDatabaseTaxonomyTerm({ - taxonomyId: args.taxonomyId, + taxonomyId, id: args.id, databaseIntegrationId: flags.integration, }) diff --git a/src/commands/db/term/tree.ts b/src/commands/db/term/tree.ts index a336477..244883e 100644 --- a/src/commands/db/term/tree.ts +++ b/src/commands/db/term/tree.ts @@ -2,6 +2,7 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' import {databaseIntegrationFlag} from '../../../lib/database.js' +import {TAXONOMY_KEY_DESCRIPTION, dryRunTaxonomyName, taxonomyNameOf} from '../../../lib/taxonomy.js' export default class DbTermTree extends BaseCommand { static description = `Show the terms of a taxonomy as a tree @@ -16,7 +17,7 @@ the taxonomy chain).` ] static args = { - taxonomy: Args.string({required: true, description: 'Taxonomy name'}), + taxonomy: Args.string({required: true, description: TAXONOMY_KEY_DESCRIPTION}), } static flags = { @@ -29,14 +30,15 @@ the taxonomy chain).` async run(): Promise { const {args, flags} = await this.parse(DbTermTree) const client = this.client(flags) + const taxonomyName = await this.lookup(flags, dryRunTaxonomyName(args.taxonomy), (c) => taxonomyNameOf(c.hub, args.taxonomy)) const res = flags.merged ? await client.hub.database.getDatabaseMergedTermTree({ - taxonomyName: args.taxonomy, + taxonomyName, databaseIntegrationId: flags.integration, }) : await client.hub.database.getDatabaseTaxonomyTermTree({ - taxonomyName: args.taxonomy, + taxonomyName, rootTermId: flags.root, depth: flags.depth, databaseIntegrationId: flags.integration, diff --git a/src/commands/db/term/update.ts b/src/commands/db/term/update.ts index 8a6a592..531b7cc 100644 --- a/src/commands/db/term/update.ts +++ b/src/commands/db/term/update.ts @@ -2,6 +2,7 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' import {databaseIntegrationFlag, readJsonText} from '../../../lib/database.js' +import {TAXONOMY_KEY_DESCRIPTION, dryRunTaxonomyId, taxonomyIdOf} from '../../../lib/taxonomy.js' export default class DbTermUpdate extends BaseCommand { static description = `Change some fields of one taxonomy term @@ -10,12 +11,12 @@ export default class DbTermUpdate extends BaseCommand { description, order, parentId, multiParents. Fields you leave out keep their value.` static examples = [ - `<%= config.bin %> db term update 66b2f0a1c3d4e5f6a7b8c9d0 66b2f0a1c3d4e5f6a7b8c9d1 --set '{"order":1}'`, - '<%= config.bin %> db term update 66b2f0a1c3d4e5f6a7b8c9d0 66b2f0a1c3d4e5f6a7b8c9d1 --set @patch.json --dry-run', + `<%= config.bin %> db term update countries 66b2f0a1c3d4e5f6a7b8c9d1 --set '{"order":1}'`, + '<%= config.bin %> db term update countries 66b2f0a1c3d4e5f6a7b8c9d1 --set @patch.json --dry-run', ] static args = { - taxonomyId: Args.string({required: true, description: 'Taxonomy ID'}), + taxonomyId: Args.string({required: true, description: TAXONOMY_KEY_DESCRIPTION}), id: Args.string({required: true, description: 'Term ID'}), } @@ -28,9 +29,10 @@ description, order, parentId, multiParents. Fields you leave out keep their valu async run(): Promise { const {args, flags} = await this.parse(DbTermUpdate) const client = this.client(flags) + const taxonomyId = await this.lookup(flags, dryRunTaxonomyId(args.taxonomyId), (c) => taxonomyIdOf(c.hub, args.taxonomyId)) const res = await client.hub.database.updateDatabaseTaxonomyTerm({ - taxonomyId: args.taxonomyId, + taxonomyId, id: args.id, databaseIntegrationId: flags.integration, update: await readJsonText(flags.set, 'set'), diff --git a/src/commands/db/terms.ts b/src/commands/db/terms.ts index 316cf4c..b3c5510 100644 --- a/src/commands/db/terms.ts +++ b/src/commands/db/terms.ts @@ -3,11 +3,13 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' import {databaseIntegrationFlag} from '../../lib/database.js' import {readJsonInput} from '../../lib/json.js' +import {TAXONOMY_KEY_DESCRIPTION, dryRunTaxonomyName, taxonomyNameOf} from '../../lib/taxonomy.js' export default class DbTerms extends BaseCommand { static description = `List the terms of a taxonomy -Pass --parent to list only the children of one term.` +Pass --parent to list only the children of one term. The taxonomy is its id +(txn_…) or its name — the same key every term command takes.` static examples = [ '<%= config.bin %> db terms countries', @@ -16,7 +18,7 @@ Pass --parent to list only the children of one term.` ] static args = { - taxonomy: Args.string({required: true, description: 'Taxonomy name'}), + taxonomy: Args.string({required: true, description: TAXONOMY_KEY_DESCRIPTION}), } static flags = { @@ -32,9 +34,10 @@ Pass --parent to list only the children of one term.` async run(): Promise { const {args, flags} = await this.parse(DbTerms) const client = this.client(flags) + const taxonomyName = await this.lookup(flags, dryRunTaxonomyName(args.taxonomy), (c) => taxonomyNameOf(c.hub, args.taxonomy)) const request = { - taxonomyName: args.taxonomy, + taxonomyName, databaseIntegrationId: flags.integration, filter: flags.filter ? await readJsonInput(flags.filter, 'filter') : undefined, pageSize: flags['page-size'], diff --git a/src/lib/taxonomy.ts b/src/lib/taxonomy.ts new file mode 100644 index 0000000..39dd8ab --- /dev/null +++ b/src/lib/taxonomy.ts @@ -0,0 +1,100 @@ +import type {Norbix} from '@norbix.ai/ts' + +import {CliError, usageError} from './cli-error.js' +import {EXIT} from './exit-codes.js' + +/** + * The gateway addresses a taxonomy two ways (N10 of the Happy Tummy findings): + * + * by id (`txn_…`) — get one taxonomy, and every single-term route: + * create, show, update, delete a term (`/taxonomies/{TaxonomyId}/…`) + * by name (the title or its slug) — listing terms, their children and the + * trees (`/taxonomies/{TaxonomyName}/…`) + * + * The id is the canonical key — it never changes, a title can be renamed. The + * CLI takes either in every term command and turns it into what the route + * needs. Which is which is unambiguous: the gateway only accepts an id with + * the `txn_` prefix (IdUtility.ParseIdToResult), and a name is never an id. + */ + +/** True for a taxonomy id (`txn_5BBS25C3IKHRfzVABMjMiM`). */ +export function isTaxonomyId(key: string): boolean { + return /^txn_[A-Za-z0-9]+$/.test(key.trim()) +} + +type Hub = Pick + +interface TaxonomyRow { + viewId: string + taxonomyName?: string + taxonomySlug?: string +} + +/** Every taxonomy of the project (env), following the pages. */ +async function listTaxonomies(hub: Hub): Promise { + const rows: TaxonomyRow[] = [] + let after: string | undefined + for (let page = 0; page < 50; page++) { + const res = await hub.database.getDatabaseTaxonomies({pageSize: 100, startingAfter: after}) + const items = (res.list?.items ?? []) as TaxonomyRow[] + rows.push(...items) + if (!res.list?.hasMore || items.length === 0) break + after = items.at(-1)?.viewId + } + + return rows +} + +/** The id of a taxonomy given by id or by name (title or slug, any case). */ +export async function taxonomyIdOf(hub: Hub, key: string): Promise { + if (isTaxonomyId(key)) return key.trim() + const wanted = key.trim().toLowerCase() + const rows = await listTaxonomies(hub) + const matches = rows.filter((t) => t.taxonomyName?.toLowerCase() === wanted || t.taxonomySlug?.toLowerCase() === wanted) + if (matches.length === 1) return matches[0].viewId + if (matches.length === 0) { + throw new CliError({ + exit: EXIT.NOT_FOUND, + code: 'TAXONOMY_NOT_FOUND', + message: `No taxonomy named "${key}" in this project and environment.`, + hint: `Pass the taxonomy id (txn_…) or its name; \`norbix db taxonomies\` lists them${rows.length ? `: ${rows.map((t) => t.taxonomySlug ?? t.taxonomyName).join(', ')}` : ' (there are none yet: norbix db taxonomy create )'}.`, + docs: 'norbix db taxonomies --help', + }) + } + + throw usageError( + `${matches.length} taxonomies are named "${key}".`, + `Pass the id instead: ${matches.map((t) => `${t.viewId} (${t.taxonomyName ?? t.taxonomySlug})`).join(', ')}.`, + 'norbix db taxonomies --help', + ) +} + +/** The name the name-keyed routes take, for a taxonomy given by id or by name. */ +export async function taxonomyNameOf(hub: Hub, key: string): Promise { + if (!isTaxonomyId(key)) return key + const res = await hub.database.getDatabaseTaxonomy({id: key.trim()}) + const name = res.item?.slug || res.item?.name + if (!name) { + throw new CliError({ + exit: EXIT.NOT_FOUND, + code: 'TAXONOMY_NOT_FOUND', + message: `Taxonomy ${key} was not found.`, + hint: '`norbix db taxonomies` lists the taxonomies of this project and environment.', + docs: 'norbix db taxonomies --help', + }) + } + + return name +} + +/** What a dry run shows instead of looking the key up (a dry run sends nothing). */ +export function dryRunTaxonomyId(key: string): string { + return isTaxonomyId(key) ? key.trim() : `` +} + +export function dryRunTaxonomyName(key: string): string { + return isTaxonomyId(key) ? `` : key +} + +/** The argument description every term command uses. */ +export const TAXONOMY_KEY_DESCRIPTION = 'Taxonomy id (txn_…) or name (title or slug, e.g. allergens)' diff --git a/tests/db-routes.test.ts b/tests/db-routes.test.ts index c1a105b..26ccf64 100644 --- a/tests/db-routes.test.ts +++ b/tests/db-routes.test.ts @@ -3,9 +3,10 @@ import {tmpdir} from 'node:os' import {join} from 'node:path' import {runCommand} from '@oclif/test' -import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import DbSchemaDelete from '../src/commands/db/schema/delete.js' +import {dryRunTaxonomyId, dryRunTaxonomyName, isTaxonomyId} from '../src/lib/taxonomy.js' /** * One row per `db` command: run it the way a user types it, through oclif and @@ -17,7 +18,7 @@ import DbSchemaDelete from '../src/commands/db/schema/delete.js' */ const ID = '66b2f0a1' -const TAX = 't4x0n0my' +const TAX = 'txn_T4x0n0my' const COL = 'orders' const USER = 'u5e7' @@ -190,6 +191,77 @@ describe('db taxonomy create', () => { }) }) +describe('the taxonomy key: id or name in every term command (N10)', () => { + /** The Hub's taxonomy list and one taxonomy, for the lookups. */ + function answerTaxonomies(): void { + const fake = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const res = await fake(input, init) + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + const json = (body: unknown) => new Response(JSON.stringify(body), {status: 200, headers: {'Content-Type': 'application/json'}}) + if (url.pathname === `${P}/taxonomies`) { + return json({list: {items: [{viewId: 'txn_Allerg', taxonomyName: 'Allergens', taxonomySlug: 'allergens'}, {viewId: 'txn_Sympt', taxonomyName: 'Symptoms', taxonomySlug: 'symptoms'}], hasMore: false}}) + } + + if (url.pathname === `${P}/taxonomies/txn_Allerg`) return json({item: {viewId: 'txn_Allerg', name: 'Allergens', slug: 'allergens'}}) + return res + }) as typeof globalThis.fetch + } + + const paths = () => calls.map((c) => `${c.method} ${c.path}`) + + it('term create by name: looks the id up, then posts to /taxonomies/{id}/terms', async () => { + answerTaxonomies() + const {error} = await runCommand(['db', 'term', 'create', 'allergens', '--doc', '{"name":"Milk"}', ...globalArgs]) + expect(error).toBeUndefined() + expect(paths()).toEqual([`GET ${P}/taxonomies`, `POST ${P}/taxonomies/txn_Allerg/terms`]) + }) + + it('the title in any case matches too', async () => { + answerTaxonomies() + await runCommand(['db', 'term', 'delete', 'ALLERGENS', ID, '--yes', ...globalArgs]) + expect(paths().at(-1)).toBe(`DELETE ${P}/taxonomies/txn_Allerg/terms/${ID}`) + }) + + it('terms by id: looks the name up, then lists by name (the id used to answer 404)', async () => { + answerTaxonomies() + const {error} = await runCommand(['db', 'terms', 'txn_Allerg', ...globalArgs]) + expect(error).toBeUndefined() + expect(paths()).toEqual([`GET ${P}/taxonomies/txn_Allerg`, `GET ${P}/taxonomies/allergens/terms`]) + }) + + it('term tree by id: the same', async () => { + answerTaxonomies() + await runCommand(['db', 'term', 'tree', 'txn_Allerg', ...globalArgs]) + expect(paths().at(-1)).toBe(`GET ${P}/taxonomies/allergens/terms/tree`) + }) + + it('an id where the route takes an id, a name where it takes a name: no lookup', async () => { + expect((await callOf(['term', 'create', 'txn_Allerg', '--doc', '{"name":"Milk"}'])).path).toBe(`${P}/taxonomies/txn_Allerg/terms`) + calls.length = 0 + expect((await callOf(['terms', 'allergens'])).path).toBe(`${P}/taxonomies/allergens/terms`) + }) + + it('an unknown name: exit 5 TAXONOMY_NOT_FOUND naming the taxonomies there are, nothing written', async () => { + answerTaxonomies() + const {error} = await runCommand(['db', 'term', 'create', 'spices', '--doc', '{"name":"Salt"}', ...globalArgs]) + expect(error?.message).toContain('No taxonomy named "spices"') + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(5) + expect(paths()).toEqual([`GET ${P}/taxonomies`]) + }) + + it('--dry-run by name sends nothing, not even the lookup; the request shows a placeholder for the id', async () => { + const {error} = await runCommand(['db', 'term', 'create', 'allergens', '--doc', '{"name":"Milk"}', '--dry-run', ...globalArgs]) + expect(error).toBeUndefined() + expect(calls).toHaveLength(0) + expect(dryRunTaxonomyId('allergens')).toBe('') + expect(dryRunTaxonomyId('txn_Allerg')).toBe('txn_Allerg') + expect(dryRunTaxonomyName('txn_Allerg')).toBe('') + expect(isTaxonomyId('txn_5BBS25C3IKHRfzVABMjMiM')).toBe(true) + expect(isTaxonomyId('66b2f0a1c3d4e5f6a7b8c9d0')).toBe(false) + }) +}) + describe('db update / delete of many records', () => { it('update --many sends the filter and the plain fields, without allRecords', async () => { expect(await bodyOf(['update', COL, '--many', '--filter', '{"status":"new"}', '--update', '{"status":"paid"}', '--yes'])).toEqual({ From 94e4d761b519c73508ddf32c358a65e8d18770bb Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:36:49 +0300 Subject: [PATCH 04/19] fix(files): files commands use the env's default files integration (N6) `files list` without --integration failed with "No files integration ID" even where the project has a default one. Every files command now resolves --integration / NORBIX_FILES_INTEGRATION_ID / the profile, then the Hub's defaultIntegrationId for the environment (else the only one). None is its own error (exit 5 NO_FILES_INTEGRATION, naming the env); several without a default exit 2 and list them. A dry run sends no lookup. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++-- src/base.ts | 12 ++++++ src/commands/files/delete.ts | 7 +--- src/commands/files/download.ts | 7 +--- src/commands/files/get-by-id.ts | 7 +--- src/commands/files/info.ts | 7 +--- src/commands/files/list.ts | 7 +--- src/commands/files/publish.ts | 7 +--- src/commands/files/sign.ts | 7 +--- src/commands/files/unpublish.ts | 7 +--- src/commands/files/upload.ts | 7 +--- src/lib/files.ts | 53 +++++++++++++++++++++++- test/files.test.ts | 72 ++++++++++++++++++++++++--------- 13 files changed, 142 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index e7d6768..20769df 100644 --- a/README.md +++ b/README.md @@ -381,9 +381,15 @@ Commands live in `src/commands//.ts` — one file per command (oclif convention). Shared logic (auth resolution, config store, output) is in `src/base.ts` and `src/lib/`. -## Files: pick your integration once +## Files: which integration -File commands need a files integration ID. Set it once: +File commands use the project's **default files integration** of the current +environment (`--env`), like the database commands use the default database +integration — one read of the Hub's integration list. When the environment has +none, they exit 5 (`NO_FILES_INTEGRATION`, "This project has no files +integration in environment TEST"); several with no default exit 2 and list +them. To pick another one, pass `--integration ` (env var: +`NORBIX_FILES_INTEGRATION_ID`) or save it: ```sh norbix config set files_integration_id @@ -391,8 +397,6 @@ norbix files upload ./invoice.pdf invoices/2026/invoice.pdf norbix files download invoices/2026/invoice.pdf ``` -Or pass `--integration ` (env var: `NORBIX_FILES_INTEGRATION_ID`). - ### The file commands | Command | What it does | diff --git a/src/base.ts b/src/base.ts index f28c6f9..6d4d685 100644 --- a/src/base.ts +++ b/src/base.ts @@ -828,6 +828,18 @@ export abstract class BaseCommand extends Command { return read(this.client(flags)) } + /** + * The files integration a `files` command uses: --integration / + * NORBIX_FILES_INTEGRATION_ID → the profile's files_integration_id → the + * project's default files integration in this environment (one Hub read). + */ + protected async filesIntegration(flags: GlobalFlags & {integration?: string}): Promise { + const known = flags.integration ?? this.resolveContext(flags).filesIntegrationId + if (known) return known + const {DRY_RUN_FILES_INTEGRATION, defaultFilesIntegration} = await import('./lib/files.js') + return this.lookup(flags, DRY_RUN_FILES_INTEGRATION, (c) => defaultFilesIntegration(c.hub, this.resolveContext(flags).env)) + } + /** * The account's projects (Hub `account/projects`) with their primary * region, or undefined when they cannot be read. An account-level call: on diff --git a/src/commands/files/delete.ts b/src/commands/files/delete.ts index 2bdf884..3443b34 100644 --- a/src/commands/files/delete.ts +++ b/src/commands/files/delete.ts @@ -1,7 +1,7 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' export default class FilesDelete extends BaseCommand { static description = 'Delete one file' @@ -23,10 +23,7 @@ export default class FilesDelete extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(FilesDelete) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, this.resolveContext(flags).filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) await this.confirmOrFail(`Delete file "${args.remote}"?`, flags) diff --git a/src/commands/files/download.ts b/src/commands/files/download.ts index 233b3ae..604ed55 100644 --- a/src/commands/files/download.ts +++ b/src/commands/files/download.ts @@ -3,7 +3,7 @@ import {writeFile} from 'node:fs/promises' import {basename} from 'node:path' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' export default class FilesDownload extends BaseCommand { static description = 'Download a file to disk (via a signed URL)' @@ -28,10 +28,7 @@ export default class FilesDownload extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(FilesDownload) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, this.resolveContext(flags).filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const {url} = await client.api.files.getSignedUrl({ filesIntegrationId: integration, diff --git a/src/commands/files/get-by-id.ts b/src/commands/files/get-by-id.ts index f4602cf..fa6826a 100644 --- a/src/commands/files/get-by-id.ts +++ b/src/commands/files/get-by-id.ts @@ -1,7 +1,7 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' export default class FilesGetById extends BaseCommand { static description = `Show one file by its stable id @@ -24,10 +24,7 @@ path, isPublic and publicUrl. An id no storage of the integration holds is async run(): Promise { const {args, flags} = await this.parse(FilesGetById) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, this.resolveContext(flags).filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const res = await client.api.files.getFileById({ filesIntegrationId: integration, diff --git a/src/commands/files/info.ts b/src/commands/files/info.ts index 4bfd05c..f423778 100644 --- a/src/commands/files/info.ts +++ b/src/commands/files/info.ts @@ -1,7 +1,7 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' export default class FilesInfo extends BaseCommand { static description = 'Show metadata for one file' @@ -19,10 +19,7 @@ export default class FilesInfo extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(FilesInfo) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, this.resolveContext(flags).filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const res = await client.api.files.getFileInfo({ filesIntegrationId: integration, diff --git a/src/commands/files/list.ts b/src/commands/files/list.ts index 121a7f7..82b2dbb 100644 --- a/src/commands/files/list.ts +++ b/src/commands/files/list.ts @@ -1,7 +1,7 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' export default class FilesList extends BaseCommand { static description = 'List files in a folder of a files integration' @@ -22,10 +22,7 @@ export default class FilesList extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(FilesList) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, this.resolveContext(flags).filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const res = await client.api.files.listFiles({ filesIntegrationId: integration, diff --git a/src/commands/files/publish.ts b/src/commands/files/publish.ts index 91308a3..59a0583 100644 --- a/src/commands/files/publish.ts +++ b/src/commands/files/publish.ts @@ -1,7 +1,7 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' import {callPublicFiles, publicUrlFor} from '../../lib/publicFiles.js' export default class FilesPublish extends BaseCommand { @@ -33,10 +33,7 @@ export default class FilesPublish extends BaseCommand { // credentials; with --dry-run it stops the call just before it is sent. const client = this.client(flags) - const integration = resolveIntegration(flags.integration, ctx.filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const operation = flags.folder ? 'makeFolderPublic' : 'makeFilePublic' const body = {filesIntegrationId: integration, path: args.remote} diff --git a/src/commands/files/sign.ts b/src/commands/files/sign.ts index 081a7e0..9579b90 100644 --- a/src/commands/files/sign.ts +++ b/src/commands/files/sign.ts @@ -1,7 +1,7 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' export default class FilesSign extends BaseCommand { static description = 'Get a temporary signed download URL for a file' @@ -20,10 +20,7 @@ export default class FilesSign extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(FilesSign) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, this.resolveContext(flags).filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const res = await client.api.files.getSignedUrl({ filesIntegrationId: integration, diff --git a/src/commands/files/unpublish.ts b/src/commands/files/unpublish.ts index a2db05c..4f2a78c 100644 --- a/src/commands/files/unpublish.ts +++ b/src/commands/files/unpublish.ts @@ -1,7 +1,7 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {integrationFlag, resolveIntegration} from '../../lib/files.js' +import {integrationFlag} from '../../lib/files.js' import {callPublicFiles} from '../../lib/publicFiles.js' export default class FilesUnpublish extends BaseCommand { @@ -30,10 +30,7 @@ export default class FilesUnpublish extends BaseCommand { const ctx = await this.freshContext(flags) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, ctx.filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const operation = flags.folder ? 'makeFolderPrivate' : 'makeFilePrivate' const body = {filesIntegrationId: integration, path: args.remote} diff --git a/src/commands/files/upload.ts b/src/commands/files/upload.ts index 9a8429c..f306fbb 100644 --- a/src/commands/files/upload.ts +++ b/src/commands/files/upload.ts @@ -3,7 +3,7 @@ import {readFile} from 'node:fs/promises' import {basename} from 'node:path' import {BaseCommand} from '../../base.js' -import {guessContentType, integrationFlag, resolveIntegration} from '../../lib/files.js' +import {guessContentType, integrationFlag} from '../../lib/files.js' export default class FilesUpload extends BaseCommand { static description = `Upload a local file (three steps: get signed URL, PUT bytes, commit).` @@ -31,10 +31,7 @@ export default class FilesUpload extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(FilesUpload) const client = this.client(flags) - const integration = resolveIntegration(flags.integration, this.resolveContext(flags).filesIntegrationId) - if (!integration) { - this.error('No files integration ID. Pass --integration or run `norbix config set filesIntegrationId `.') - } + const integration = await this.filesIntegration(flags) const data = await readFile(args.file) const fileName = basename(args.file) diff --git a/src/lib/files.ts b/src/lib/files.ts index 6778065..0d7583c 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -1,13 +1,64 @@ +import type {Norbix} from '@norbix.ai/ts' import {Flags} from '@oclif/core' import {extname} from 'node:path' +import {CliError, toEnvelope, usageError} from './cli-error.js' +import {EXIT} from './exit-codes.js' + /** Shared flag: which files integration to talk to. */ export const integrationFlag = Flags.string({ char: 'i', - description: 'Files integration ID', + description: 'Files integration ID (default: the project default files integration of the environment)', env: 'NORBIX_FILES_INTEGRATION_ID', }) +/** What a dry run sends instead of looking the default integration up. */ +export const DRY_RUN_FILES_INTEGRATION = '' + +/** + * The project's default files integration in the current environment — + * like the database commands, which use the default database integration + * when no --integration is given (N6). One read of the Hub's integration list: + * its `defaultIntegrationId`, else the only integration there is. None, or + * several without a default, are two different errors. + */ +export async function defaultFilesIntegration(hub: Pick, env: string | undefined): Promise { + const where = `environment ${env ?? 'PROD'}` + let res: Awaited> + try { + res = await hub.files.getFilesIntegrations({}) + } catch (error) { + const envelope = toEnvelope(error) + throw new CliError({ + ...envelope, + message: `No files integration ID given, and the project's files integrations could not be read: ${envelope.message}`, + hint: 'Pass --integration , set NORBIX_FILES_INTEGRATION_ID, or save one: norbix config set files_integration_id .', + docs: 'norbix files list --help', + }) + } + + if (res.defaultIntegrationId) return res.defaultIntegrationId + const items = (res.list?.items ?? []) as Array<{viewId: string; integrationName?: string}> + if (items.length === 1) return items[0].viewId + if (items.length === 0) { + throw new CliError({ + exit: EXIT.NOT_FOUND, + code: 'NO_FILES_INTEGRATION', + message: `This project has no files integration in ${where}.`, + hint: + `Add one in the dashboard (Files → Integrations) or with \`norbix hub files saveFilesIntegration --body ''\`` + + `${env && env !== 'PROD' ? `, or pass --env for the environment that has one` : ''}; then run the command again.`, + docs: 'norbix integrations files --help', + }) + } + + throw usageError( + `This project has ${items.length} files integrations in ${where} and none is the default.`, + `Pass --integration (or save one: norbix config set files_integration_id ):\n${items.map((i) => ` ${i.viewId}${i.integrationName ? ` ${i.integrationName}` : ''}`).join('\n')}`, + 'norbix integrations files --help', + ) +} + /** Resolve the files integration ID: flag > env (via oclif) > config file. */ export function resolveIntegration( flagValue: string | undefined, diff --git a/test/files.test.ts b/test/files.test.ts index 62e68e0..e3a37c5 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -99,22 +99,52 @@ describe('norbix files list', () => { expect(calls[0].url).toContain('path=invoices') }) - it('fails with a clear message when no integration is given', async () => { - fakeFetch() + describe('no --integration: the default files integration of the environment (N6)', () => { + const noIntegration = ['--project', PROJECT, '--api-key', API_KEY, '--region', REGION] + const paths = () => calls.map((c) => `${c.method} ${new URL(c.url).pathname}`) - const {error} = await runCommand([ - 'files', - 'list', - '--project', - PROJECT, - '--api-key', - API_KEY, - '--region', - REGION, - ]) + it('uses defaultIntegrationId from the Hub', async () => { + fakeFetch([['/files/integrations', {defaultIntegrationId: 'fi_Default', list: {items: [{viewId: 'fi_Default'}, {viewId: 'fi_Other'}]}}]]) + const {error} = await runCommand(['files', 'list', ...noIntegration]) + expect(error).toBeUndefined() + expect(paths()).toEqual(['GET /v3/files/integrations', 'GET /v3/files/fi_Default']) + }) - expect(error?.message).toContain('No files integration ID') - expect(calls).toHaveLength(0) + it('no default but exactly one integration: uses it', async () => { + fakeFetch([['/files/integrations', {list: {items: [{viewId: 'fi_Only'}]}}]]) + const {error} = await runCommand(['files', 'delete', 'a.txt', '--yes', ...noIntegration]) + expect(error).toBeUndefined() + expect(paths().at(-1)).toContain('/files/fi_Only') + }) + + it('none in the environment: exit 5 NO_FILES_INTEGRATION that names the environment', async () => { + fakeFetch([['/files/integrations', {list: {items: []}}]]) + const {error} = await runCommand(['files', 'list', '--env', 'TEST', ...noIntegration]) + expect(error?.message).toContain('This project has no files integration in environment TEST.') + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(5) + expect(paths()).toEqual(['GET /v3/files/integrations']) + }) + + it('several and no default: exit 2 listing them, so the caller picks one', async () => { + fakeFetch([['/files/integrations', {list: {items: [{viewId: 'fi_A', integrationName: 'S3'}, {viewId: 'fi_B'}]}}]]) + const {error} = await runCommand(['files', 'list', ...noIntegration]) + expect(error?.message).toContain('2 files integrations in environment PROD and none is the default') + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(2) + expect(paths()).toHaveLength(1) + }) + + it('--integration, NORBIX_FILES_INTEGRATION_ID or the profile value: no lookup', async () => { + fakeFetch() + await runCommand(['files', 'list', ...globalArgs]) + expect(paths()).toEqual([`GET /v3/files/${INTEGRATION}`]) + }) + + it('--dry-run sends nothing, not even the lookup', async () => { + fakeFetch() + const {error} = await runCommand(['files', 'delete', 'a.txt', '--dry-run', ...noIntegration]) + expect(error).toBeUndefined() + expect(calls).toHaveLength(0) + }) }) }) @@ -142,13 +172,14 @@ describe('norbix files get-by-id', () => { expect(new URL(calls[0].url).pathname).toBe(`/v3/files/${INTEGRATION}/by-id/nbfl_7hK2abc`) }) - it('fails with a clear message when no integration is given', async () => { + it('no integration given and none in the project: a clear message after one lookup', async () => { fakeFetch() const {error} = await runCommand(['files', 'get-by-id', 'nbfl_7hK2abc', '--project', PROJECT, '--api-key', API_KEY, '--region', REGION]) - expect(error?.message).toContain('No files integration ID') - expect(calls).toHaveLength(0) + // The Hub (fake) has no files integration: only the lookup was sent. + expect(error?.message).toContain('This project has no files integration in environment PROD.') + expect(calls.map((c) => new URL(c.url).pathname)).toEqual(['/v3/files/integrations']) }) }) @@ -380,7 +411,7 @@ describe('norbix files publish / unpublish', () => { expect(calls).toHaveLength(0) }) - it('fails with a clear message when no integration is given', async () => { + it('no integration given and none in the project: a clear message after one lookup', async () => { fakeFetch() const {error} = await runCommand([ @@ -395,8 +426,9 @@ describe('norbix files publish / unpublish', () => { REGION, ]) - expect(error?.message).toContain('No files integration ID') - expect(calls).toHaveLength(0) + // The Hub (fake) has no files integration: only the lookup was sent. + expect(error?.message).toContain('This project has no files integration in environment PROD.') + expect(calls.map((c) => new URL(c.url).pathname)).toEqual(['/v3/files/integrations']) }) it('reports what the gateway refused, instead of a blank success', async () => { From 796267f2e0a30f65ef2e9c60747fedebdc954608 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:37:58 +0300 Subject: [PATCH 05/19] fix(db): db delete --filter alone deletes the one matching record (N15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `db delete --filter '{"_id":…}'` without --many was a usage error. The safe default: --filter alone first reads at most two matches; exactly one is deleted by its id (after the usual confirmation / --yes), none exits 5, and more than one is refused with exit 2 and a hint to add --many — so a filter meant for one record can never delete several. --many and --all are unchanged; an empty filter is still refused; a dry run sends nothing. Co-Authored-By: Claude Opus 5.5 --- docs/database.md | 1 + src/commands/db/delete.ts | 54 ++++++++++++++++++++++++++++++++-- tests/db-routes.test.ts | 61 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/docs/database.md b/docs/database.md index 9ce012d..ee59b59 100644 --- a/docs/database.md +++ b/docs/database.md @@ -43,6 +43,7 @@ refactoringV2 (2026-10) has only the GET routes, so this CLI needs a current gat | `norbix db update --all --update [--array-filters ] [--yes]` | update every record | `PUT /collections/{collectionName}/many` (`allRecords: true`) | | `norbix db replace --id --doc ` | replace one record fully | `PUT /collections/{collectionName}/{id}/replace` | | `norbix db delete --id [--yes]` | delete one | `DELETE /collections/{collectionName}/{id}` | +| `norbix db delete --filter [--yes]` | delete the ONE record the filter matches: reads up to 2 matches first; 0 → exit 5, 2+ → exit 2 (add `--many`) | `GET /collections/{collectionName}`, then `DELETE /collections/{collectionName}/{id}` | | `norbix db delete --many --filter [--yes]` | delete many | `DELETE /collections/{collectionName}/many` | | `norbix db delete --all [--yes]` | delete every record | `DELETE /collections/{collectionName}/many` (`allRecords: true`) | | `norbix db aggregate --pipeline ` | run an inline aggregation | `POST /collections/{collectionName}/aggregate` | diff --git a/src/commands/db/delete.ts b/src/commands/db/delete.ts index fd53170..c8d0875 100644 --- a/src/commands/db/delete.ts +++ b/src/commands/db/delete.ts @@ -1,11 +1,20 @@ +import type {Norbix} from '@norbix.ai/ts' import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' +import {CliError, usageError} from '../../lib/cli-error.js' +import {EXIT} from '../../lib/exit-codes.js' import {readJsonInput} from '../../lib/json.js' import {refuseEmptyFilter} from '../../lib/records.js' export default class DbDelete extends BaseCommand { - static description = `Delete records: one by --id, many by --filter with --many, or every record with --all + static description = `Delete records: one by --id, the one record a --filter matches, many by --filter with --many, or every record with --all + +--filter alone deletes exactly one record: the CLI first reads which records +match (one read; a --dry-run sends nothing); one match is +deleted by its id, none exits 5, and more than one is refused (exit 2) — add +--many to delete them all, or narrow the filter. So a filter that was meant +for one record can never delete several. An empty filter ('{}') is refused: it would delete the whole collection. Use --all to do that on purpose. A caller with only "delete own" rights deletes @@ -14,6 +23,7 @@ only the records it owns.` static examples = [ '<%= config.bin %> db delete orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', '<%= config.bin %> db delete orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --yes', + `<%= config.bin %> db delete orders --filter '{"orderNo":"A-1001"}' --yes`, `<%= config.bin %> db delete orders --filter '{"status":"cancelled"}' --many --yes`, '<%= config.bin %> db delete orders --all --dry-run', ] @@ -24,7 +34,7 @@ only the records it owns.` static flags = { id: Flags.string({description: 'Record ID (single delete)', exclusive: ['many', 'filter', 'all']}), - filter: Flags.string({char: 'f', description: 'JSON filter (with --many); must not be empty', dependsOn: ['many'], exclusive: ['all']}), + filter: Flags.string({char: 'f', description: 'JSON filter; alone it deletes the ONE matching record (refused when several match), with --many every match; must not be empty', exclusive: ['all']}), many: Flags.boolean({description: 'Delete every record matching --filter', default: false}), all: Flags.boolean({description: 'Delete EVERY record of the collection (sends allRecords: true)', default: false}), ...BaseCommand.mutatingFlags, @@ -52,10 +62,48 @@ only the records it owns.` return res } - if (!flags.id) this.error('Pass --id for a single delete, --filter with --many, or --all.') + if (flags.filter) { + const filter = await readJsonInput(flags.filter, 'filter') + refuseEmptyFilter(filter, 'delete') + const id = await this.lookup(flags, '', (c) => onlyMatch(c, args.collection, filter)) + await this.confirmOrFail(`Delete the one record ${id} in "${args.collection}" matching ${filter}?`, flags) + const res = await client.api.database.deleteOne({collectionName: args.collection, id}) + this.print(res) + return res + } + + if (!flags.id) this.error('Pass --id or --filter for a single delete, --filter with --many, or --all.') await this.confirmOrFail(`Delete record ${flags.id} from "${args.collection}"?`, flags) const res = await client.api.database.deleteOne({collectionName: args.collection, id: flags.id}) this.print(res) return res } } + +/** The id of the only record `filter` matches; none → exit 5, several → exit 2. */ +async function onlyMatch(client: Norbix, collection: string, filter: string): Promise { + const res = await client.api.database.find({collectionName: collection, filter, pageSize: 2}) + const items = (res.list?.items ?? []) as Array<{_id?: unknown}> + if (items.length === 0) { + throw new CliError({ + exit: EXIT.NOT_FOUND, + code: 'NOT_FOUND', + message: `No record in "${collection}" matches ${filter}.`, + hint: `Check the filter with \`norbix db find ${collection} --filter '${filter}'\` (and --env).`, + docs: 'norbix db delete --help', + }) + } + + if (items.length > 1 || res.list?.hasMore) { + throw usageError( + `More than one record in "${collection}" matches ${filter}; --filter alone deletes exactly one.`, + `Add --many to delete every match (preview with --dry-run, count with \`norbix db count ${collection} --filter '${filter}'\`), or narrow the filter.`, + 'norbix db delete --help', + ) + } + + const raw = items[0]._id + const id = typeof raw === 'object' && raw !== null && '$oid' in raw ? String((raw as {$oid: unknown}).$oid) : raw === undefined ? undefined : String(raw) + if (!id) throw usageError(`The record matching ${filter} has no _id.`, 'Delete it with --many --filter instead.', 'norbix db delete --help') + return id +} diff --git a/tests/db-routes.test.ts b/tests/db-routes.test.ts index 26ccf64..6bff905 100644 --- a/tests/db-routes.test.ts +++ b/tests/db-routes.test.ts @@ -262,6 +262,67 @@ describe('the taxonomy key: id or name in every term command (N10)', () => { }) }) +describe('db delete --filter without --many: exactly one record (N15)', () => { + /** Answer `find` on the collection with these records. */ + function answerFind(items: unknown[], hasMore = false): void { + const fake = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const res = await fake(input, init) + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + if ((init?.method ?? 'GET') === 'GET' && url.pathname === `${P}/collections/${COL}`) { + return new Response(JSON.stringify({list: {items, hasMore}}), {status: 200, headers: {'Content-Type': 'application/json'}}) + } + + return res + }) as typeof globalThis.fetch + } + + const paths = () => calls.map((c) => `${c.method} ${c.path}`) + + it('one match: reads at most 2, then deletes that record by its id', async () => { + answerFind([{_id: ID}]) + const {error} = await runCommand(['db', 'delete', COL, '--filter', '{"_id":"66b2f0a1"}', '--yes', ...globalArgs]) + expect(error).toBeUndefined() + expect(paths()).toEqual([`GET ${P}/collections/${COL}`, `DELETE ${P}/collections/${COL}/${ID}`]) + expect(calls[0].query.get('filter')).toBe('{"_id":"66b2f0a1"}') + }) + + it('an extended-JSON {$oid} id works too', async () => { + answerFind([{_id: {$oid: ID}}]) + await runCommand(['db', 'delete', COL, '--filter', '{"n":1}', '--yes', ...globalArgs]) + expect(paths().at(-1)).toBe(`DELETE ${P}/collections/${COL}/${ID}`) + }) + + it('several matches: exit 2, the hint says --many, nothing deleted', async () => { + answerFind([{_id: 'a'}, {_id: 'b'}]) + const {error} = await runCommand(['db', 'delete', COL, '--filter', '{"status":"new"}', '--yes', ...globalArgs]) + expect(error?.message).toContain('More than one record') + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(2) + expect(paths()).toEqual([`GET ${P}/collections/${COL}`]) + }) + + it('no match: exit 5, nothing deleted', async () => { + answerFind([]) + const {error} = await runCommand(['db', 'delete', COL, '--filter', '{"status":"gone"}', '--yes', ...globalArgs]) + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(5) + expect(paths()).toHaveLength(1) + }) + + it('without --yes in a script: the read happens, the delete is refused with exit 3', async () => { + answerFind([{_id: ID}]) + const {error} = await runCommand(['db', 'delete', COL, '--filter', '{"n":1}', ...globalArgs]) + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(3) + expect(paths()).toEqual([`GET ${P}/collections/${COL}`]) + }) + + it('--dry-run sends nothing; an empty filter is still refused', async () => { + expect((await runCommand(['db', 'delete', COL, '--filter', '{"n":1}', '--dry-run', ...globalArgs])).error).toBeUndefined() + const empty = await runCommand(['db', 'delete', COL, '--filter', '{}', '--yes', ...globalArgs]) + expect(empty.error?.message).toContain("matches every record") + expect(calls).toHaveLength(0) + }) +}) + describe('db update / delete of many records', () => { it('update --many sends the filter and the plain fields, without allRecords', async () => { expect(await bodyOf(['update', COL, '--many', '--filter', '{"status":"new"}', '--update', '{"status":"paid"}', '--yes'])).toEqual({ From 28c503a639ce886722202c46c6a636bf561cca0e Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:40:11 +0300 Subject: [PATCH 06/19] fix(db): schema publish asks only when the Hub reports a breaking change (N11) `db schema publish` on a brand-new collection needed --yes like a destructive command (exit 3 in scripts). The Hub already has the rule: publish with confirmed: false goes through unless the new version removes fields, then it answers CM-ERRORS-SCHEMA-020 with the removed fields. The CLI now sends confirmed: false first; only on 020 does it name the fields and confirm (prompt, or exit 3 without --yes), then resends with confirmed: true. --yes up front still publishes in one call. The command keeps --yes, so `norbix schema` still marks it destructive (it can be). Co-Authored-By: Claude Opus 5.5 --- docs/database.md | 22 +++++++---- src/commands/db/schema/publish.ts | 50 +++++++++++++++++++---- tests/db-routes.test.ts | 66 +++++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/docs/database.md b/docs/database.md index ee59b59..0cf8c0a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -166,16 +166,20 @@ counts, the new ids, and any errors. `--file` is the data schema (the JSON Schema of one record); `--ui-file` is the UI schema the dashboard form uses. A change is a draft first: records keep the -published version until `schema publish`. `publish` asks first and sends the -server's `confirmed: true`, so it also publishes a change the server calls -breaking — read `schema draft` (or `schema diff` after a publish) before you -answer yes. +published version until `schema publish`. `publish` asks only when data is at +risk, and lets the Hub decide that: it first sends `confirmed: false`, so a +first publish or one that only adds or changes fields goes through without +`--yes`. When the new version removes fields the published one has, the Hub +answers `CM-ERRORS-SCHEMA-020`; the CLI names the fields and asks (without a +terminal: exit 3, nothing published), and on yes — or with `--yes` up front — +publishes with `confirmed: true`. Read `schema draft` (or `schema diff` after a +publish) before you answer yes. ```bash norbix db schema create --name orders --file orders.schema.json --ui-file orders.ui.json norbix db schema update 66b2f0a1c3d4e5f6a7b8c9d0 --file orders.schema.json norbix db schema draft 66b2f0a1c3d4e5f6a7b8c9d0 -norbix db schema publish 66b2f0a1c3d4e5f6a7b8c9d0 --yes +norbix db schema publish 66b2f0a1c3d4e5f6a7b8c9d0 norbix db schema diff 66b2f0a1c3d4e5f6a7b8c9d0 --from 1 --to 2 ``` @@ -288,9 +292,11 @@ norbix db term tree countries --depth 2 checked: `norbix hub database schema bundle apply --bundleJson '' --dry-run` asks the Hub's read-only `account.validateSchema` and exits 6 with the issues when the bundle is invalid. -- `delete`, `discard`, `publish`, `seed`, `trigger disable`, `trigger delete`, - `term delete` and the `--many` / `--all` record writes ask first; `--yes` skips the - question, and without a terminal they exit 3 unless `--yes` is given. +- `delete`, `discard`, `seed`, `trigger disable`, `trigger delete`, + `term delete` and the `--many` / `--all` record writes (and `delete --filter`) + ask first; `--yes` skips the question, and without a terminal they exit 3 + unless `--yes` is given. `publish` asks only for a breaking change (see + above); `norbix schema` still lists it as destructive because it can be. - Exit codes are in the README ("Errors and exit codes"). ## No command on purpose diff --git a/src/commands/db/schema/publish.ts b/src/commands/db/schema/publish.ts index 38ef95c..c655509 100644 --- a/src/commands/db/schema/publish.ts +++ b/src/commands/db/schema/publish.ts @@ -1,16 +1,29 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../../base.js' +import {toEnvelope} from '../../../lib/cli-error.js' + +/** The gateway's "this publish removes fields" refusal (SchemaBreakingChangeRequiresConfirmationError). */ +export const BREAKING_CHANGE = 'CM-ERRORS-SCHEMA-020' export default class DbSchemaPublish extends BaseCommand { static description = `Publish the draft of a database schema Records are validated against the published version from now on. Check the -change first with \`db schema draft \` or \`db schema diff\`.` +change first with \`db schema draft \` or \`db schema diff\`. + +Confirmation is needed only when the publish would put data at risk. The +first attempt asks the Hub to publish WITHOUT the breaking-change switch: a +first publish, or one that only adds or changes fields, goes through at once +— no --yes needed. When the new version removes fields that the published +one has (the Hub answers ${BREAKING_CHANGE}), the CLI names them and asks; +in a script it exits 3 and publishes nothing — re-run with --yes to publish +anyway. --yes up front publishes even a breaking change in one call.` static examples = [ - '<%= config.bin %> db schema publish 66b2f0a1c3d4e5f6a7b8c9d0 --yes', - '<%= config.bin %> db schema publish 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run', + '<%= config.bin %> db schema publish sch_6zbH6AJwyGylTFfs4B9bpC', + '<%= config.bin %> db schema publish sch_6zbH6AJwyGylTFfs4B9bpC --dry-run', + '<%= config.bin %> db schema publish sch_6zbH6AJwyGylTFfs4B9bpC --yes', ] static args = { @@ -24,14 +37,35 @@ change first with \`db schema draft \` or \`db schema diff\`.` async run(): Promise { const {args, flags} = await this.parse(DbSchemaPublish) const client = this.client(flags) + const publish = (confirmed: boolean) => client.hub.database.publishDatabaseSchema({id: args.id, confirmed}) - await this.confirmOrFail(`Publish the draft of schema ${args.id}?`, flags) - - // `confirmed` is the server's own "yes, publish even a breaking change" - // switch; the CLI asks first (or takes --yes), so it always sends true. - const res = await client.hub.database.publishDatabaseSchema({id: args.id, confirmed: true}) + // `confirmed` is the Hub's own "yes, publish even a breaking change" + // switch. Without --yes it is false, so the Hub itself decides whether + // anything is at risk. + let res: unknown + try { + res = await publish(flags.yes) + } catch (error) { + const envelope = toEnvelope(error) + if (flags.yes || envelope.code !== BREAKING_CHANGE) throw error + const removed = removedFieldsOf(envelope.context) + await this.confirmOrFail( + `Publishing schema ${args.id} removes ${removed ? `the field(s) ${removed}` : 'fields'} that the published version has (a breaking change). Publish anyway?`, + flags, + ) + res = await publish(true) + } this.print(`Schema ${args.id} published.`) return res } } + +/** `RemovedFieldsText` from the error's context, whatever its casing. */ +function removedFieldsOf(context: Record | undefined): string | undefined { + for (const [key, value] of Object.entries(context ?? {})) { + if (key.toLowerCase() === 'removedfieldstext' && typeof value === 'string' && value) return value + } + + return undefined +} diff --git a/tests/db-routes.test.ts b/tests/db-routes.test.ts index 6bff905..a5b6ca8 100644 --- a/tests/db-routes.test.ts +++ b/tests/db-routes.test.ts @@ -323,6 +323,66 @@ describe('db delete --filter without --many: exactly one record (N15)', () => { }) }) +describe('db schema publish: confirmation only for a breaking change (N11)', () => { + const breaking = { + responseStatus: { + isSuccess: false, + errorCode: 'CM-ERRORS-SCHEMA-020', + message: 'Publishing this schema version removes 2 field(s) (color, size) that existed in the previous version.', + errors: [ + { + errorCode: 'CM-ERRORS-SCHEMA-020', + message: 'Publishing this schema version removes 2 field(s) (color, size) that existed in the previous version.', + context: {SchemaId: ID, RemovedFieldsText: 'color, size'}, + }, + ], + }, + } + + /** The Hub answers the publishes in order: `true` = 200, `false` = the breaking-change refusal. */ + function answerPublish(...ok: boolean[]): void { + const fake = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + await fake(input, init) + const pass = ok.shift() ?? true + return new Response(JSON.stringify(pass ? {} : breaking), {status: pass ? 200 : 400, headers: {'Content-Type': 'application/json'}}) + }) as typeof globalThis.fetch + } + + const confirmedSent = () => calls.map((c) => c.body?.confirmed) + + it('no --yes, no terminal: a first or additive publish goes through with confirmed: false', async () => { + answerPublish(true) + const {error} = await runCommand(['db', 'schema', 'publish', ID, ...globalArgs]) + expect(error).toBeUndefined() + expect(confirmedSent()).toEqual([false]) + }) + + it('no --yes and the Hub says fields are removed: exit 3 naming them, the second publish is not sent', async () => { + answerPublish(false) + const {error} = await runCommand(['db', 'schema', 'publish', ID, ...globalArgs]) + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(3) + expect(error?.message).toContain('removes the field(s) color, size') + expect(confirmedSent()).toEqual([false]) + }) + + it('--yes: one call with confirmed: true (publishes even a breaking change)', async () => { + answerPublish(true) + await runCommand(['db', 'schema', 'publish', ID, '--yes', ...globalArgs]) + expect(confirmedSent()).toEqual([true]) + }) + + it('another refusal is reported as it is, not turned into a confirmation', async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({responseStatus: {errorCode: 'CM-ERRORS-SCHEMA-010', message: 'bad schema'}}), { + status: 400, + headers: {'Content-Type': 'application/json'}, + })) as typeof globalThis.fetch + const {error} = await runCommand(['db', 'schema', 'publish', ID, ...globalArgs]) + expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(6) + }) +}) + describe('db update / delete of many records', () => { it('update --many sends the filter and the plain fields, without allRecords', async () => { expect(await bodyOf(['update', COL, '--many', '--filter', '{"status":"new"}', '--update', '{"status":"paid"}', '--yes'])).toEqual({ @@ -585,10 +645,10 @@ describe('db schema writes', () => { expect(await bodyOf(['schema', 'publish', ID, '--yes'])).toEqual({confirmed: true}) }) - it('publish without --yes in a non-interactive shell sends nothing', async () => { + it('publish without --yes in a non-interactive shell asks the Hub with confirmed: false (N11; the Hub refuses a breaking change)', async () => { const {error} = await runCommand(['db', 'schema', 'publish', ID, ...globalArgs]) - expect(error?.message).toMatch(/Confirmation required/) - expect(calls).toHaveLength(0) + expect(error).toBeUndefined() + expect(calls.map((c) => c.body)).toEqual([{confirmed: false}]) }) it('delete without --yes in a non-interactive shell warns that the records go too, and sends nothing', async () => { From 4997d124a23de99e4f9499869c914cdda8a43a80 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:43:07 +0300 Subject: [PATCH 07/19] =?UTF-8?q?feat(env):=20env=20create=20=E2=80=94=20a?= =?UTF-8?q?n=20environment=20and=20its=20database=20in=20one=20command=20(?= =?UTF-8?q?N5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating TEST needed `norbix hub environments create` and knowing that the Hub requires a database integration. `env create ` sends environments.create with the integration the gateway's converter expects: --connection-string-env VAR (the MongoDB connection string is read from that variable, never argv; a dry run shows $VAR, never the secret) with optional --database-name, or --managed [--db-region] for a Norbix-managed database. The name rule (A-Z, 0-9, space, <= 15, not PROD) is checked before sending. Help and the env topic say what a new environment needs. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- package.json | 2 +- src/commands/env/create.ts | 102 ++++++++++++++++++++++++ tests/__snapshots__/schema.test.ts.snap | 16 ++++ tests/env-create.test.ts | 89 +++++++++++++++++++++ 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 src/commands/env/create.ts create mode 100644 tests/env-create.test.ts diff --git a/README.md b/README.md index 20769df..a4e0ce0 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/create/use/delete` | Manage project environments; `env create TEST --connection-string-env VAR` (or `--managed`) creates one with its database | | `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 | diff --git a/package.json b/package.json index cc8369f..bea58fc 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ } }, "env": { - "description": "Manage project environments (PROD, TEST, ...)" + "description": "Manage project environments (PROD, TEST, ...): list, create (a new env needs its own database: --connection-string-env or --managed), use, delete" }, "files": { "description": "Upload, download and manage files", diff --git a/src/commands/env/create.ts b/src/commands/env/create.ts new file mode 100644 index 0000000..98457c1 --- /dev/null +++ b/src/commands/env/create.ts @@ -0,0 +1,102 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' +import {usageError} from '../../lib/cli-error.js' + +/** The gateway's rule for an environment name (Env.Create): A–Z, 0–9, space, ≤ 15, not PROD. */ +export function checkEnvName(raw: string): string { + const name = raw.trim().toUpperCase() + if (name === 'PROD') throw usageError('PROD always exists and cannot be created.', 'Pick another name, e.g. TEST or STAGING.', 'norbix env create --help') + if (!/^[A-Z0-9 ]{1,15}$/.test(name)) { + throw usageError(`"${raw}" is not an environment name.`, 'Use A–Z, 0–9 and spaces, at most 15 characters (e.g. TEST, STAGING).', 'norbix env create --help') + } + + return name +} + +export default class EnvCreate extends BaseCommand { + static description = `Create a project environment (TEST, STAGING, …) together with its database + +A new environment starts with exactly one integration: its own database, which +becomes its default. Every other integration (files, email, …) is added inside +the environment afterwards (--env ). Give the database one of two ways: + + --connection-string-env VAR your own MongoDB: the NAME of an environment + variable that holds the connection string (the + string itself never goes on the command line or + into a dry-run report); --database-name picks + the database on that server + --managed a Norbix-managed database (norbix.ai), in + --db-region or the project's region + +There is no "same server as PROD, new database" option: the Hub does not hand +out PROD's connection string, so pass it yourself. Creating an environment is +not available while the account is on a trial (each one is an extra database). + +Then work in it: \`norbix env use \` or --env per command.` + + static examples = [ + 'NORBIX_TEST_DB="mongodb+srv://…" <%= config.bin %> env create TEST --connection-string-env NORBIX_TEST_DB --database-name happy-tummy-test --dry-run', + '<%= config.bin %> env create TEST --connection-string-env NORBIX_TEST_DB --database-name happy-tummy-test', + '<%= config.bin %> env create STAGING --managed --db-region nb-eu-germany', + ] + + static args = { + name: Args.string({required: true, description: 'Environment name: A–Z, 0–9, space, up to 15 characters (not PROD)'}), + } + + static flags = { + ...BaseCommand.dryRunFlags, + 'connection-string-env': Flags.string({ + description: 'Name of the environment variable holding the MongoDB connection string of the new database', + exactlyOne: ['connection-string-env', 'managed'], + }), + 'database-name': Flags.string({description: 'Database on that server (with --connection-string-env)', dependsOn: ['connection-string-env']}), + managed: Flags.boolean({description: 'A Norbix-managed database (norbix.ai)', exactlyOne: ['connection-string-env', 'managed']}), + 'db-region': Flags.string({description: 'Region of the managed database (default: --region / the project region)', dependsOn: ['managed']}), + 'integration-name': Flags.string({description: 'Name of the database integration (default " database")'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(EnvCreate) + const name = checkEnvName(args.name) + const integrationName = flags['integration-name'] ?? `${name} database` + const client = this.client(flags) + + let integration: Record + if (flags.managed) { + const region = flags['db-region'] ?? this.resolveContext(flags).region + if (!region) throw usageError('--managed needs the region of the database.', 'Pass --db-region (e.g. nb-eu-germany).', 'norbix env create --help') + integration = {provider: 'CodeMashMongoDbAtlasFlexManaged', integrationName, isEnabled: true, norbixRegionCode: region} + } else { + const variable = flags['connection-string-env']! + const value = process.env[variable]?.trim() + if (!value && !flags['dry-run']) { + throw usageError( + `The environment variable ${variable} is empty or not set.`, + `Put the new database's connection string there first, e.g. export ${variable}='mongodb+srv://user:pass@cluster/'.`, + 'norbix env create --help', + ) + } + + integration = { + provider: 'MongoDbConnectionString', + integrationName, + isEnabled: true, + // A dry run prints the body: it shows the variable, never the secret. + connectionString: flags['dry-run'] ? `$${variable}` : value, + ...(flags['database-name'] ? {databaseName: flags['database-name']} : {}), + } + } + + // `integration` is the gateway's polymorphic DatabaseIntegrationRequest; its + // JSON converter picks the subtype from `provider`. + const res = await client.hub.environments.create({ + environmentName: name, + integration: integration as never, + }) + + this.print(`Environment ${name} created with its database integration "${integrationName}". Use it with --env ${name} or \`norbix env use ${name}\`.`) + return res ?? {environment: name} + } +} diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index 2047ab1..eb2da20 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -1860,6 +1860,22 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "email validation test", "supportsDryRun": true, }, + { + "args": [ + "name", + ], + "destructive": false, + "flags": [ + "dry-run:boolean", + "connection-string-env:string", + "database-name:string", + "managed:boolean", + "db-region:string", + "integration-name:string", + ], + "id": "env create", + "supportsDryRun": true, + }, { "args": [ "name", diff --git a/tests/env-create.test.ts b/tests/env-create.test.ts new file mode 100644 index 0000000..3e38bb1 --- /dev/null +++ b/tests/env-create.test.ts @@ -0,0 +1,89 @@ +import {runCommand} from '@oclif/test' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' + +/** + * `norbix env create` (N5): one call to POST /account/projects/environments + * with the new environment's database integration. `fetch` is replaced; the + * connection string comes from an environment variable and must never show + * up in a dry run. + */ + +interface Call { + method: string + path: string + body?: Record +} + +let calls: Call[] +let realFetch: typeof globalThis.fetch +const SECRET = 'mongodb+srv://user:s3cret@cluster.example/' + +beforeEach(() => { + calls = [] + realFetch = globalThis.fetch + process.env.NORBIX_TEST_DB = SECRET + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) + calls.push({ + method: init?.method ?? 'GET', + path: url.pathname, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as Record) : undefined, + }) + return new Response('{}', {status: 200, headers: {'Content-Type': 'application/json'}}) + }) as typeof globalThis.fetch +}) + +afterEach(() => { + globalThis.fetch = realFetch + delete process.env.NORBIX_TEST_DB +}) + +const globalArgs = ['--project', 'test-project', '--api-key', 'test-api-key', '--region', 'nb-eu-germany'] +const exitOf = (error: unknown) => (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit + +describe('norbix env create', () => { + it('own MongoDB: the connection string is read from the named variable and sent with provider MongoDbConnectionString', async () => { + const {error} = await runCommand(['env', 'create', 'test', '--connection-string-env', 'NORBIX_TEST_DB', '--database-name', 'ht-test', ...globalArgs]) + expect(error).toBeUndefined() + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({method: 'POST', path: '/v3/account/projects/environments'}) + expect(calls[0].body).toEqual({ + environmentName: 'TEST', + integration: {provider: 'MongoDbConnectionString', integrationName: 'TEST database', isEnabled: true, connectionString: SECRET, databaseName: 'ht-test'}, + }) + }) + + it('managed: provider CodeMashMongoDbAtlasFlexManaged in the region', async () => { + await runCommand(['env', 'create', 'STAGING', '--managed', '--integration-name', 'StagingDB', ...globalArgs]) + expect(calls[0].body).toEqual({ + environmentName: 'STAGING', + integration: {provider: 'CodeMashMongoDbAtlasFlexManaged', integrationName: 'StagingDB', isEnabled: true, norbixRegionCode: 'nb-eu-germany'}, + }) + }) + + it('--dry-run sends nothing and never prints the connection string', async () => { + const written: string[] = [] + const out = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => (written.push(String(chunk)), true)) + const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => void written.push(a.join(' '))) + const {error, stdout} = await runCommand(['env', 'create', 'TEST', '--connection-string-env', 'NORBIX_TEST_DB', '--dry-run', ...globalArgs]) + out.mockRestore() + log.mockRestore() + expect(error).toBeUndefined() + expect(calls).toHaveLength(0) + expect(written.join('') + stdout).not.toContain('s3cret') + }) + + it('an unset variable: exit 2 before anything is sent', async () => { + const {error} = await runCommand(['env', 'create', 'TEST', '--connection-string-env', 'NOT_SET_ANYWHERE', ...globalArgs]) + expect(exitOf(error)).toBe(2) + expect(error?.message).toContain('NOT_SET_ANYWHERE is empty or not set') + expect(calls).toHaveLength(0) + }) + + it('PROD, a bad name, or no database at all: usage errors, nothing sent', async () => { + expect((await runCommand(['env', 'create', 'PROD', '--managed', ...globalArgs])).error?.message).toContain('PROD always exists') + expect((await runCommand(['env', 'create', 'test-env', '--managed', ...globalArgs])).error?.message).toContain('is not an environment name') + expect((await runCommand(['env', 'create', 'TEST', ...globalArgs])).error).toBeDefined() + expect(calls).toHaveLength(0) + }) +}) From 36c4b6edd6c1241b86b75252c62ee732f0238056 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:45:32 +0300 Subject: [PATCH 08/19] feat(cli): notice when a newer CLI is on npm (N4) Happy Tummy ran 1.23.1 while 1.27.0 existed and nothing said so. The init hook now prints one stderr line (never --json stdout) when the cached latest version is newer: at most once a day, always on login, ai init and whoami. It never waits: the version comes from ~/.norbix/update-check.json, refreshed at most daily by a detached, unref'd child that asks the npm registry with a 3 s timeout. Off with NORBIX_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER or CI, and for a 0.x development build. The test setup switches it off. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++ src/hooks/init.ts | 22 +++++- src/lib/update-check.ts | 145 ++++++++++++++++++++++++++++++++++ test/setup.ts | 4 + tests/update-check.test.ts | 154 +++++++++++++++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 src/lib/update-check.ts create mode 100644 tests/update-check.test.ts diff --git a/README.md b/README.md index a4e0ce0..b29ba6e 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,15 @@ or ask for keys or tokens. In CI, give the agent `NORBIX_HOST` + The recipe the agent follows is in [docs/AGENTS.md](docs/AGENTS.md); the guarantees it relies on are in [docs/agent-contract.md](docs/agent-contract.md). +## Updates + +When a newer `@norbix.ai/cli` is on npm, the CLI says so in one line on +**stderr** (never in `--json` stdout): at most once a day, and on every +`login`, `ai init` and `whoami`. It never waits for the network: the latest +version is read from `~/.norbix/update-check.json`, which a background process +refreshes at most once a day (3 s timeout, failures ignored). Turn it off with +`NORBIX_NO_UPDATE_CHECK=1` (`NO_UPDATE_NOTIFIER` and `CI` turn it off too). + ## Versioning The major version is frozen at **v1** until the public launch. diff --git a/src/hooks/init.ts b/src/hooks/init.ts index 6ec807d..af0511b 100644 --- a/src/hooks/init.ts +++ b/src/hooks/init.ts @@ -1,10 +1,30 @@ import type {Hook} from '@oclif/core' import {disableColorIfNeeded} from '../lib/color.js' +import {cachePath, planUpdateCheck, readCache, spawnRefresh, writeCache} from '../lib/update-check.js' -/** Runs before every command: no ANSI codes with --json, NO_COLOR or a pipe. */ +/** + * Runs before every command: no ANSI codes with --json, NO_COLOR or a pipe; + * and the "a newer CLI exists" notice (lib/update-check.ts) — one stderr + * line from a cache, never a network wait. + */ const hook: Hook<'init'> = async function (opts) { disableColorIfNeeded(opts.argv) + try { + const path = cachePath() + const now = Date.now() + const plan = planUpdateCheck({version: opts.config.version, commandId: opts.id, cache: readCache(path), now}) + if (plan.notice) process.stderr.write(plan.notice + '\n') + if (plan.refresh) { + // Claim the check first, so parallel commands start one child, not many. + writeCache({...plan.cache, checkedAt: new Date(now).toISOString()}, path) + spawnRefresh() + } else if (plan.notice) { + writeCache(plan.cache, path) + } + } catch { + /* the notice is never worth a failed command */ + } } export default hook diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 0000000..d024068 --- /dev/null +++ b/src/lib/update-check.ts @@ -0,0 +1,145 @@ +import {spawn} from 'node:child_process' +import {mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {fileURLToPath} from 'node:url' + +/** + * "A newer CLI exists" (N4 of the Happy Tummy findings). + * + * Never blocks and never fails a command: + * - the notice is read from a cache (~/.norbix/update-check.json) only; + * - when the cache is older than a day, a detached child process + * (`node update-check.js`, `unref`ed) asks the npm registry, with a 3 s + * timeout, and writes the cache — the notice shows from the next run on; + * - every error is swallowed. + * The notice is one line on stderr, so `--json` stdout stays one document. + * It shows at most once a day, and always on `login`, `ai init` and + * `whoami` (where people look at their setup). + * + * Off with NORBIX_NO_UPDATE_CHECK=1 (or NO_UPDATE_NOTIFIER, or in CI), and for + * a development build (version 0.x: the release stamps the real version). + */ + +export const PACKAGE = '@norbix.ai/cli' +export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE.replace('/', '%2f')}/latest` +export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000 +const COMMANDS_THAT_ALWAYS_SHOW = new Set(['login', 'ai:init', 'whoami']) + +export interface UpdateCache { + /** When the registry was last asked (ISO). */ + checkedAt?: string + /** The latest version it named. */ + latest?: string + /** When the notice was last shown (ISO). */ + notifiedAt?: string +} + +export function cachePath(home: string = homedir()): string { + return join(home, '.norbix', 'update-check.json') +} + +export function readCache(path: string = cachePath()): UpdateCache { + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) + return typeof parsed === 'object' && parsed !== null ? (parsed as UpdateCache) : {} + } catch { + return {} + } +} + +export function writeCache(cache: UpdateCache, path: string = cachePath()): void { + try { + mkdirSync(join(path, '..'), {recursive: true}) + writeFileSync(path, JSON.stringify(cache, null, 2) + '\n') + } catch { + /* a read-only HOME must not break a command */ + } +} + +function truthy(value: string | undefined): boolean { + return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false' +} + +/** True when no check may run: the kill switches, CI, or a development build. */ +export function updateCheckDisabled(version: string, env: NodeJS.ProcessEnv = process.env): boolean { + if (truthy(env.NORBIX_NO_UPDATE_CHECK) || truthy(env.NO_UPDATE_NOTIFIER) || truthy(env.CI)) return true + return !/^[1-9]\d*\.\d+\.\d+/.test(version) +} + +/** `a` is a newer release than `b` (x.y.z; a pre-release tag never counts as newer). */ +export function isNewer(a: string | undefined, b: string): boolean { + if (!a || a.includes('-')) return false + const parse = (v: string) => v.replace(/^v/, '').split(/[-+]/)[0].split('.').map((n) => Number.parseInt(n, 10) || 0) + const [x, y] = [parse(a), parse(b)] + for (let i = 0; i < 3; i++) { + if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0) + } + + return false +} + +export function noticeText(current: string, latest: string): string { + return `A newer Norbix CLI is available: ${current} → ${latest}. Update: npm install -g ${PACKAGE}@latest (turn this notice off: NORBIX_NO_UPDATE_CHECK=1)` +} + +/** + * Decide what this run does: the notice to print (or none) and whether the + * cache is stale. Pure — the hook does the printing and the spawning. + */ +export function planUpdateCheck(opts: { + version: string + commandId?: string + cache: UpdateCache + now: number + env?: NodeJS.ProcessEnv +}): {notice?: string; refresh: boolean; cache: UpdateCache} { + const {version, cache, now} = opts + if (updateCheckDisabled(version, opts.env)) return {refresh: false, cache} + const checked = Date.parse(cache.checkedAt ?? '') + const refresh = !(now - checked < CHECK_EVERY_MS) + if (!isNewer(cache.latest, version)) return {refresh, cache} + const notified = Date.parse(cache.notifiedAt ?? '') + const always = opts.commandId !== undefined && COMMANDS_THAT_ALWAYS_SHOW.has(opts.commandId) + if (!always && now - notified < CHECK_EVERY_MS) return {refresh, cache} + return {notice: noticeText(version, cache.latest!), refresh, cache: {...cache, notifiedAt: new Date(now).toISOString()}} +} + +/** Ask the registry for the latest version; undefined on any failure. */ +export async function fetchLatest(fetchFn: typeof fetch = fetch, timeoutMs = 3000): Promise { + try { + const res = await fetchFn(REGISTRY_URL, {signal: AbortSignal.timeout(timeoutMs), headers: {accept: 'application/json'}}) + if (!res.ok) return undefined + const body = (await res.json()) as {version?: unknown} + return typeof body.version === 'string' && /^\d+\.\d+\.\d+/.test(body.version) ? body.version : undefined + } catch { + return undefined + } +} + +/** The child process's job: ask the registry, write the cache. */ +export async function refreshCache(path: string = cachePath(), fetchFn: typeof fetch = fetch, now = Date.now()): Promise { + const latest = await fetchLatest(fetchFn) + const cache = readCache(path) + writeCache({...cache, checkedAt: new Date(now).toISOString(), ...(latest ? {latest} : {})}, path) +} + +/** Start the refresh in a detached child that outlives nothing: the command never waits for it. */ +export function spawnRefresh(): void { + try { + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--refresh'], { + detached: true, + stdio: 'ignore', + env: {...process.env, NORBIX_UPDATE_CHECK_CHILD: '1'}, + }) + child.on('error', () => {}) + child.unref() + } catch { + /* no child, no notice — never an error */ + } +} + +// `node update-check.js --refresh`: the detached child. +if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === '--refresh') { + await refreshCache() +} diff --git a/test/setup.ts b/test/setup.ts index 785a243..4f27629 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -22,6 +22,10 @@ for (const name of ['NORBIX_PROJECT_ID', 'NORBIX_API_KEY', 'NORBIX_REGION', 'NOR delete process.env[name] } +// No "newer CLI" check from the tests (it would ask the npm registry in a +// child process); tests/update-check.test.ts turns it on where it is tested. +process.env.NORBIX_NO_UPDATE_CHECK = '1' + // hub.norbix.ai is "already discovered": the default host's built-in // addresses are in the host cache, so no test asks the real Hub. A test of // discovery itself uses its own host and its own fake fetch. diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 0000000..e2f551a --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,154 @@ +import {existsSync, mkdtempSync, readFileSync, rmSync} from 'node:fs' +import {homedir, tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' + +import hook from '../src/hooks/init.js' +import { + CHECK_EVERY_MS, + REGISTRY_URL, + cachePath, + fetchLatest, + isNewer, + planUpdateCheck, + refreshCache, + updateCheckDisabled, + writeCache, +} from '../src/lib/update-check.js' + +/** N4: a non-blocking, cached "newer CLI" notice that can be turned off. */ + +const NOW = Date.parse('2026-10-14T12:00:00Z') +const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString() +const on = {} as NodeJS.ProcessEnv + +describe('isNewer', () => { + it('compares x.y.z numerically, never counts a pre-release as newer', () => { + expect(isNewer('1.32.0', '1.31.0')).toBe(true) + expect(isNewer('1.10.0', '1.9.9')).toBe(true) + expect(isNewer('1.31.0', '1.31.0')).toBe(false) + expect(isNewer('1.30.9', '1.31.0')).toBe(false) + expect(isNewer('2.0.0-beta.1', '1.31.0')).toBe(false) + expect(isNewer(undefined, '1.31.0')).toBe(false) + }) +}) + +describe('updateCheckDisabled', () => { + it('NORBIX_NO_UPDATE_CHECK, NO_UPDATE_NOTIFIER and CI turn it off; "0" / "false" do not', () => { + expect(updateCheckDisabled('1.31.0', {NORBIX_NO_UPDATE_CHECK: '1'})).toBe(true) + expect(updateCheckDisabled('1.31.0', {NO_UPDATE_NOTIFIER: '1'})).toBe(true) + expect(updateCheckDisabled('1.31.0', {CI: 'true'})).toBe(true) + expect(updateCheckDisabled('1.31.0', {NORBIX_NO_UPDATE_CHECK: '0'})).toBe(false) + expect(updateCheckDisabled('1.31.0', {NORBIX_NO_UPDATE_CHECK: 'false'})).toBe(false) + expect(updateCheckDisabled('1.31.0', on)).toBe(false) + }) + + it('a development build (0.x, the placeholder before the release stamps a version) never checks', () => { + expect(updateCheckDisabled('0.1.0', on)).toBe(true) + }) +}) + +describe('planUpdateCheck', () => { + it('an empty cache: no notice, refresh', () => { + const plan = planUpdateCheck({version: '1.31.0', cache: {}, now: NOW, env: on}) + expect(plan.notice).toBeUndefined() + expect(plan.refresh).toBe(true) + }) + + it('a fresh cache with a newer version: the notice, no refresh; it names the update command and the switch', () => { + const plan = planUpdateCheck({version: '1.27.0', cache: {checkedAt: hoursAgo(1), latest: '1.31.0'}, now: NOW, env: on}) + expect(plan.refresh).toBe(false) + expect(plan.notice).toBe( + 'A newer Norbix CLI is available: 1.27.0 → 1.31.0. Update: npm install -g @norbix.ai/cli@latest (turn this notice off: NORBIX_NO_UPDATE_CHECK=1)', + ) + expect(plan.cache.notifiedAt).toBe(new Date(NOW).toISOString()) + }) + + it('at most once a day — except on login, ai init and whoami', () => { + const cache = {checkedAt: hoursAgo(1), latest: '1.31.0', notifiedAt: hoursAgo(2)} + expect(planUpdateCheck({version: '1.27.0', commandId: 'db:find', cache, now: NOW, env: on}).notice).toBeUndefined() + expect(planUpdateCheck({version: '1.27.0', commandId: 'login', cache, now: NOW, env: on}).notice).toBeDefined() + expect(planUpdateCheck({version: '1.27.0', commandId: 'ai:init', cache, now: NOW, env: on}).notice).toBeDefined() + expect(planUpdateCheck({version: '1.27.0', commandId: 'db:find', cache: {...cache, notifiedAt: hoursAgo(25)}, now: NOW, env: on}).notice).toBeDefined() + }) + + it('a stale cache refreshes (and still shows what it knows); up to date shows nothing', () => { + const stale = planUpdateCheck({version: '1.27.0', cache: {checkedAt: new Date(NOW - CHECK_EVERY_MS - 1).toISOString(), latest: '1.31.0'}, now: NOW, env: on}) + expect(stale).toMatchObject({refresh: true}) + expect(stale.notice).toBeDefined() + expect(planUpdateCheck({version: '1.31.0', cache: {checkedAt: hoursAgo(1), latest: '1.31.0'}, now: NOW, env: on}).notice).toBeUndefined() + }) + + it('switched off: nothing at all', () => { + const plan = planUpdateCheck({version: '1.27.0', cache: {checkedAt: hoursAgo(25), latest: '1.31.0'}, now: NOW, env: {NORBIX_NO_UPDATE_CHECK: '1'}}) + expect(plan.notice).toBeUndefined() + expect(plan.refresh).toBe(false) + }) +}) + +describe('fetchLatest / refreshCache', () => { + const answer = (body: unknown, status = 200) => vi.fn(async () => new Response(JSON.stringify(body), {status})) as unknown as typeof fetch + + it('reads `version` from the registry document of @norbix.ai/cli', async () => { + const f = answer({version: '1.32.0'}) + expect(await fetchLatest(f)).toBe('1.32.0') + expect((f as unknown as {mock: {calls: unknown[][]}}).mock.calls[0][0]).toBe(REGISTRY_URL) + expect(REGISTRY_URL).toBe('https://registry.npmjs.org/@norbix.ai%2fcli/latest') + }) + + it('a failure, a non-200 or a timeout is undefined, never a throw', async () => { + expect(await fetchLatest(vi.fn(async () => Promise.reject(new Error('offline'))) as unknown as typeof fetch)).toBeUndefined() + expect(await fetchLatest(answer({}, 500))).toBeUndefined() + const slow = ((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))))) as unknown as typeof fetch + expect(await fetchLatest(slow, 20)).toBeUndefined() + }) + + it('refreshCache writes checkedAt and latest; on a failure only checkedAt (asks again tomorrow)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'norbix-update-')) + const path = join(dir, 'update-check.json') + await refreshCache(path, answer({version: '1.32.0'}), NOW) + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({checkedAt: new Date(NOW).toISOString(), latest: '1.32.0'}) + await refreshCache(path, answer({}, 500), NOW + 1000) + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({checkedAt: new Date(NOW + 1000).toISOString(), latest: '1.32.0'}) + rmSync(dir, {recursive: true, force: true}) + }) +}) + +describe('the init hook', () => { + let stderr: string[] + beforeEach(() => { + stderr = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => (stderr.push(String(chunk)), true)) + delete process.env.NORBIX_NO_UPDATE_CHECK + delete process.env.CI + }) + afterEach(() => { + vi.restoreAllMocks() + process.env.NORBIX_NO_UPDATE_CHECK = '1' + rmSync(cachePath(), {force: true}) + }) + + const run = (version: string, id = 'whoami') => + (hook as unknown as (o: unknown) => Promise).call({}, {argv: [], id, config: {version}}) + + it('prints the notice on stderr from a fresh cache, without starting a refresh', async () => { + expect(cachePath().startsWith(homedir())).toBe(true) + writeCache({checkedAt: new Date().toISOString(), latest: '9.9.9'}) + await run('1.27.0') + expect(stderr.join('')).toContain('A newer Norbix CLI is available: 1.27.0 → 9.9.9.') + }) + + it('NORBIX_NO_UPDATE_CHECK=1: no notice, no cache written', async () => { + process.env.NORBIX_NO_UPDATE_CHECK = '1' + await run('1.27.0') + expect(stderr.join('')).toBe('') + expect(existsSync(cachePath())).toBe(false) + }) + + it('a development build: no notice, no cache written', async () => { + await run('0.1.0') + expect(existsSync(cachePath())).toBe(false) + }) +}) From d7cd65f9d4cafb865f153a56ad89f614d1b1aef1 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:52:01 +0300 Subject: [PATCH 09/19] feat(ai): ai init pins the repo to its Norbix in .norbix/project.json (N3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After `ai init` neither the skill nor CLAUDE.md named the host or the project, and commands without --host used the computer's [default] profile of another installation; the suggested `config set host` would break that one. ai init now writes .norbix/project.json (host, projectId, env — no secrets) from what the CLI resolves, and the agent files name them. Every command in the repo (any sub-folder) reads it after flags/NORBIX_* and before the profile; its host acts like --host, so a [default] profile for another host (and its key) is not used. An explicit --profile for another host wins. env use inside the repo writes the file; whoami shows hostSource/envSource 'project file'. The home folder is never searched; NORBIX_PROJECT_CONFIG=0 turns it off; --no-project-file skips writing it. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++++ src/base.ts | 60 +++++++++++++---- src/commands/ai/init.ts | 42 ++++++++++-- src/commands/env/use.ts | 18 +++-- src/commands/login.ts | 3 +- src/commands/whoami.ts | 7 +- src/lib/agent-files.ts | 50 +++++++++++--- src/lib/project-config.ts | 90 +++++++++++++++++++++++++ test/login.test.ts | 3 +- test/setup.ts | 4 ++ tests/__snapshots__/schema.test.ts.snap | 1 + tests/_cli.ts | 4 +- tests/ai-init.test.ts | 65 ++++++++++++++++-- tests/project-config.test.ts | 62 +++++++++++++++++ 14 files changed, 384 insertions(+), 43 deletions(-) create mode 100644 src/lib/project-config.ts create mode 100644 tests/project-config.test.ts diff --git a/README.md b/README.md index b29ba6e..e4c276d 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,24 @@ create content in a language (`email|sms|push campaign create`, `scheduler save`, `email template attach`) keep their own `--language` for that content. +**The repo's Norbix — `.norbix/project.json`.** `norbix ai init` writes the +host, project and (when set) environment this repo belongs to into +`.norbix/project.json` (no secrets — commit it): + +```json +{"host": "cloud.example.com", "projectId": "pr_6zbH6AJwyGylTFfs4B9bpC", "env": "TEST"} +``` + +Every command run in that folder or below uses it **before** the profiles: +`--host` / `--project` / `--env` (and `NORBIX_*`) → `.norbix/project.json` → +the profile → the sign-in. Its host acts like `--host`: a `[default]` profile +for another installation is not used there (its API key is never sent), so a +second installation on the same computer cannot be reached by accident. An +explicit `--profile` for another host wins, and the file is ignored. +`norbix env use ` inside the repo changes the file's `env`. `whoami` +shows `hostSource: project file`. The home folder is never searched; +`NORBIX_PROJECT_CONFIG=0` turns the lookup off. + **Profiles (AWS-style)** — one INI file at `~/.norbix/config`: ```ini diff --git a/src/base.ts b/src/base.ts index 6d4d685..832203e 100644 --- a/src/base.ts +++ b/src/base.ts @@ -33,6 +33,7 @@ import { type Session, } from './lib/profiles.js' import {CONTEXT_MD, syncContext, type ContextRequest} from './lib/agent-context.js' +import {findProjectConfig, type FoundProjectConfig} from './lib/project-config.js' import {SessionRefresher} from './lib/session-auth.js' import {readStore, type StoredConfig} from './lib/store.js' @@ -114,7 +115,11 @@ export interface ResolvedContext { /** The host the CLI was pointed at, as an origin: `https://cloud.finlo.space`. */ host: string /** Where the host came from. */ - hostSource: 'flag/env' | `profile [${string}]` | 'deprecated url' | 'default' + hostSource: 'flag/env' | 'project file' | `profile [${string}]` | 'deprecated url' | 'default' + /** The repo's `.norbix/project.json` in use (its host is this one), when there is one. */ + projectFile?: FoundProjectConfig + /** Where the environment came from; 'none' means no norbix-env header — the server uses PROD. */ + envSource: 'flag/env' | 'project file' | `profile [${string}]` | 'session' | 'legacy config' | 'none' /** The Hub's key — the name of its session file: `hub.finlo.space`. */ hubKey: string /** What discovery learned about the Hub; undefined when nothing is known yet. */ @@ -431,22 +436,31 @@ export abstract class BaseCommand extends Command { hubOverride?: string apiOverride?: string deprecations: string[] + projectFile?: FoundProjectConfig } { let {prof, profileName, explicit} = this.selectProfile(flags) - const flagHost = flags.host?.trim() || undefined + const found = this.projectConfig() + const profOrigin = () => + prof.host ? normalizeHost(prof.host) : prof.hub_url ? originOf(prof.hub_url) : normalizeHost(DEFAULT_HOST) + // The repo's file applies when nothing explicit names another host. + const fileOrigin = found?.config.host ? normalizeHost(found.config.host) : undefined + let projectFile: FoundProjectConfig | undefined + if (found) { + const flagOrigin = flags.host?.trim() ? normalizeHost(flags.host.trim()) : undefined + const against = flagOrigin ?? (explicit ? profOrigin() : undefined) + projectFile = !fileOrigin || !against || sameHub(fileOrigin, against) ? found : undefined + } + + const flagHost = flags.host?.trim() || (projectFile && fileOrigin && !explicit ? projectFile.config.host : undefined) + const hostFromFile = !flags.host?.trim() && flagHost !== undefined if (flagHost) { const origin = normalizeHost(flagHost) if (profileName) { - const profOrigin = prof.host - ? normalizeHost(prof.host) - : prof.hub_url - ? originOf(prof.hub_url) - : normalizeHost(DEFAULT_HOST) - if (!sameHub(origin, profOrigin)) { + if (!sameHub(origin, profOrigin())) { if (explicit) { throw usageError( - `Profile "${profileName}" is for ${new URL(profOrigin).host}, but --host / NORBIX_HOST names ${new URL(origin).host}.`, + `Profile "${profileName}" is for ${new URL(profOrigin()).host}, but --host / NORBIX_HOST names ${new URL(origin).host}.`, `Drop one of them, or change the profile: norbix config set host ${new URL(origin).host} --profile ${profileName}.`, 'norbix config --help', ) @@ -457,11 +471,11 @@ export abstract class BaseCommand extends Command { } } - return {origin, hostSource: 'flag/env', prof, profileName, explicit, deprecations: []} + return {origin, hostSource: hostFromFile ? 'project file' : 'flag/env', prof, profileName, explicit, deprecations: [], projectFile} } if (prof.host) { - return {origin: normalizeHost(prof.host), hostSource: `profile [${profileName}]`, prof, profileName, explicit, deprecations: []} + return {origin: normalizeHost(prof.host), hostSource: `profile [${profileName}]`, prof, profileName, explicit, deprecations: [], projectFile} } // Deprecated for one release: full Api / Hub URLs instead of a host. @@ -493,9 +507,18 @@ export abstract class BaseCommand extends Command { hubOverride, apiOverride, deprecations, + projectFile, } } + private foundProjectConfig?: {value?: FoundProjectConfig} + + /** The repo's `.norbix/project.json` (lib/project-config.ts), read once per run. */ + protected projectConfig(): FoundProjectConfig | undefined { + this.foundProjectConfig ??= {value: findProjectConfig()} + return this.foundProjectConfig.value + } + protected resolveContext(flags: GlobalFlags): ResolvedContext { const target = this.pickTarget(flags) const {prof, profileName, explicit} = target @@ -536,7 +559,16 @@ export abstract class BaseCommand extends Command { const {base: apiUrlBase, version: apiUrlVersion} = splitVersionedUrl(apiFull) const {base: hubUrlBase, version: hubUrlVersion} = splitVersionedUrl(hubFull) - const projectId = flags.project ?? prof.project_id ?? session?.projectId ?? fromLegacy(legacy.projectId) + const file = target.projectFile?.config + const projectId = flags.project ?? file?.projectId ?? prof.project_id ?? session?.projectId ?? fromLegacy(legacy.projectId) + const envPick: Array<[string | undefined, ResolvedContext['envSource']]> = [ + [flags.env, 'flag/env'], + [file?.env, 'project file'], + [prof.env, `profile [${profileName ?? 'default'}]`], + [session?.env, 'session'], + [fromLegacy(legacy.env), 'legacy config'], + ] + const [env, envSource] = envPick.find(([value]) => value) ?? [undefined, 'none' as const] const region = flags.region ?? prof.region ?? @@ -562,7 +594,9 @@ export abstract class BaseCommand extends Command { 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, + projectFile: target.projectFile, region, apiKey, bearerToken, diff --git a/src/commands/ai/init.ts b/src/commands/ai/init.ts index a8decd4..ac6b725 100644 --- a/src/commands/ai/init.ts +++ b/src/commands/ai/init.ts @@ -4,8 +4,10 @@ import {dirname, join, resolve} from 'node:path' import {Flags} from '@oclif/core' import {BaseCommand} from '../../base.js' -import {hasBlock, planTarget, type PlannedFile, type Target} from '../../lib/agent-files.js' +import {hasBlock, planTarget, type PlannedFile, type RepoTarget, type Target} from '../../lib/agent-files.js' import {usageError} from '../../lib/cli-error.js' +import {storedHost} from '../../lib/hosts.js' +import {PROJECT_DIR, PROJECT_FILE, readProjectConfig} from '../../lib/project-config.js' const TARGETS: Target[] = ['claude', 'agents', 'cursor'] @@ -22,6 +24,14 @@ Writes, into the current folder: cursor .cursor/rules/norbix.mdc all all of the above +and .norbix/project.json — the Norbix this repo belongs to: the host, the +project and (when set) the environment the CLI resolves now (--host, +--project, --env, or the profile / sign-in). No secrets; commit it. Every +\`norbix\` command run anywhere in this repo then uses that host and project +before the computer's [default] profile, so an agent can never reach another +installation by accident. The agent files name the host and project too. +--no-project-file skips it; an existing one is kept unless --force. + The files are short on purpose: \`norbix schema --json\` and \`--help\` are the source of truth. An existing file is never overwritten without --force; a block that is already present is left alone. --dry-run lists what would be @@ -44,6 +54,11 @@ written.` }), force: Flags.boolean({description: 'Overwrite an existing skill / rule file and rewrite an existing block', default: false}), dir: Flags.string({description: 'Project folder (default: current folder)'}), + 'project-file': Flags.boolean({ + description: 'Write .norbix/project.json with the host, project and environment (--no-project-file to skip)', + default: true, + allowNo: true, + }), } async run(): Promise { @@ -53,7 +68,12 @@ written.` const exists = (p: string) => existsSync(join(root, p)) const read = (p: string) => readFileSync(join(root, p), 'utf8') - const planned = targets.flatMap((t) => planTarget(t, exists, read, flags.force)) + // What the CLI resolves right now (cache only, no network): the repo's target. + const ctx = this.resolveContext(flags) + const filePath = join(PROJECT_DIR, PROJECT_FILE) + const kept = exists(filePath) && !flags.force ? readProjectConfig(join(root, filePath)) : undefined + const repo: RepoTarget = kept ?? {host: storedHost(ctx.host), projectId: ctx.projectId, env: ctx.env?.toUpperCase()} + const planned = targets.flatMap((t) => planTarget(t, exists, read, flags.force, flags['project-file'] ? repo : undefined)) const refused = planned.filter((f) => { if (f.action === 'create' && exists(f.path) && !flags.force) return true @@ -68,9 +88,14 @@ written.` ) } + if (flags['project-file'] && !kept) { + const content = JSON.stringify(Object.fromEntries(Object.entries(repo).filter(([, v]) => v)), null, 2) + '\n' + planned.push({path: filePath, action: exists(filePath) ? 'replace' : 'create', content}) + } + const files = planned.map((f) => ({path: f.path, action: f.action, bytes: Buffer.byteLength(f.content)})) if (flags['dry-run']) { - return this.dryRun({method: 'ai.init', request: {dir: root, targets, files}}) + return this.dryRun({method: 'ai.init', request: {dir: root, targets, files, ...(flags['project-file'] ? {repo} : {})}}) } for (const f of planned) this.write(root, f) @@ -78,13 +103,22 @@ written.` this.print( [ ...files.map((f) => `${f.action === 'append' ? 'updated' : f.action === 'replace' ? 'replaced' : 'wrote'} ${f.path}`), + ...(flags['project-file'] + ? [ + kept + ? `kept ${filePath} (pass --force to write it again)` + : '', + `This repo now targets ${repo.host}${repo.projectId ? `, project ${repo.projectId}` : ''}${repo.env ? `, environment ${repo.env}` : ''}.` + + (repo.projectId ? '' : ' No project is known yet: run `norbix ai init --force --project ` (ids: `norbix account projects`).'), + ].filter(Boolean) + : []), '', 'Done. The agent now knows to use `norbix` with --json, --dry-run first, and --yes only for destructive changes.', 'Signing in: the agent runs `norbix login --no-browser --json` itself and shows you a link — open it, check the code, pick the roles it gets, press Allow; it finishes with `norbix login --wait`. Or sign in yourself with `norbix login`.', 'CI and scripts: NORBIX_HOST + NORBIX_API_KEY + NORBIX_PROJECT_ID, or `norbix login --api-key ... [--host ] --profile `.', ].join('\n'), ) - return {dir: root, targets, files} + return {dir: root, targets, files, ...(flags['project-file'] ? {repo} : {})} } private write(root: string, f: PlannedFile): void { diff --git a/src/commands/env/use.ts b/src/commands/env/use.ts index dabfcac..17fc24c 100644 --- a/src/commands/env/use.ts +++ b/src/commands/env/use.ts @@ -1,14 +1,17 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../base.js' +import {writeProjectConfig} from '../../lib/project-config.js' import {PROFILES_PATH, isSessionUsable, readProfiles, readSession, sessionPath, writeProfile, writeSession} from '../../lib/profiles.js' export default class EnvUse extends BaseCommand { static description = `Set the default environment for future commands. -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.` +Written, in this order of preference: with --profile into that profile in +~/.norbix/config; inside a repo set up by \`norbix ai init\` into its +.norbix/project.json (so the repo remembers it — commit it); else into the +browser session of the host when you are signed in there; otherwise into the +[default] profile (created when missing). Override per command with --env.` static examples = [ '<%= config.bin %> env use TEST', @@ -39,7 +42,14 @@ profile (created when missing). Override per command with --env.` return {env: args.name, profile: flags.profile} } - const {hubKey} = this.resolveContext(flags) + const {hubKey, projectFile} = this.resolveContext(flags) + if (projectFile) { + if (flags['dry-run']) return dry('project file', projectFile.path) + writeProjectConfig(projectFile.path, {...projectFile.config, env: args.name.toUpperCase()}) + this.print(`This repo (${projectFile.path}) now targets environment ${args.name.toUpperCase()}.`) + return {env: args.name.toUpperCase(), target: 'project file', file: projectFile.path} + } + const session = readSession(hubKey) if (isSessionUsable(session)) { if (flags['dry-run']) return dry('session', sessionPath(hubKey)) diff --git a/src/commands/login.ts b/src/commands/login.ts index dc77607..6e6d67d 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -372,7 +372,8 @@ A shell with no terminal (scripts, CI, agents) without --api-key, state: 'other host', note: `Commands without --host still use profile [default] (${shown}). ` + - `Use this sign-in with --host ${host} or NORBIX_HOST=${host}, or make it the default: norbix config set host ${host}`, + `Use this sign-in with --host ${host} or NORBIX_HOST=${host}; pin a repo to it with \`norbix ai init --host ${host} --project \` (writes .norbix/project.json there); ` + + `or make it the default for every folder: norbix config set host ${host}`, } } diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index b11c95f..1776dd3 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -36,7 +36,9 @@ export default class Whoami extends BaseCommand { apiKey: ctx.apiKey ? redact(ctx.apiKey) : undefined, projectId: ctx.projectId, accountId: ctx.accountId, - env: ctx.env ?? 'PROD', + env: ctx.env ?? '(not set — the server uses PROD)', + envSource: ctx.envSource, + projectFile: ctx.projectFile?.path, region: ctx.region ?? (ctx.usesDefaultEndpoints @@ -81,7 +83,8 @@ export default class Whoami extends BaseCommand { ...(signIn ? signInLines(signIn) : []), `Project: ${summary.projectId ?? '(not set)'}`, `Account: ${summary.accountId ?? '(not set)'}`, - `Env: ${summary.env}`, + `Env: ${summary.env}${ctx.env ? ` (${ctx.envSource})` : ''}`, + ...(summary.projectFile ? [`Repo: ${summary.projectFile}`] : []), `Region: ${summary.region}`, verified ? `Server: OK${environments?.length ? ` — environments: ${environments.join(', ')}` : ''}` diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts index fe3f7af..87020a4 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -18,14 +18,35 @@ export interface PlannedFile { content: string } +/** The Norbix installation, project and environment this repo belongs to (`.norbix/project.json`). */ +export interface RepoTarget { + host?: string + projectId?: string + env?: string +} + +/** One line naming the repo's target, or none when nothing is known. */ +export function targetLine(target?: RepoTarget): string[] { + if (!target?.host && !target?.projectId) return [] + const parts = [ + target.host ? `host \`${target.host}\`` : undefined, + target.projectId ? `project \`${target.projectId}\`` : undefined, + target.env ? `environment \`${target.env}\`` : undefined, + ].filter(Boolean) + return [ + `- This repo's Norbix: ${parts.join(', ')} — saved in \`.norbix/project.json\`, which \`norbix\` reads by itself anywhere in this repo (\`whoami\` shows "project file"). Do not pass another \`--host\`, \`--project\` or \`--profile\` unless the user asks.`, + ] +} + /** The recipe every target shares (Markdown, ~10 lines). */ -export function recipeBlock(): string { +export function recipeBlock(target?: RepoTarget): string { return [ MARKER_START, '## Norbix backend — use the `norbix` CLI', '', 'This project uses Norbix (database, users, files, templates, campaigns, scheduler, logs). Talk to it with the `norbix` CLI from the shell; never edit the backend by hand.', '', + ...targetLine(target), '- Start with `norbix whoami --json`. `"auth": "none"` there (or exit code 4 from any other command) means not signed in. Sign in yourself in two steps: run `norbix login --no-browser --json`, show the user the `verificationUriComplete` link and the `userCode` (they open it, check the code and pick the roles you get — an AI service user, removable under Account → AI service users), then run `norbix login --wait --json` until it exits 0. Exit 4 with `AUTHORIZATION_PENDING` means not approved yet: run `--wait` again; `ACCESS_DENIED` / `EXPIRED_TOKEN`: start over. Their Norbix is not norbix.ai? Add `--host ` to both steps.', '- Pass `--json` to every command whose output you read: stdout is then exactly one JSON document (a result, or `{"error": {code, message, exit, fieldErrors, hint, docs}}`).', '- Project context (your projects, environments, regions, modules, integrations, CORS, languages — never secrets): read `.norbix-ai/context.md` when it is there; run `norbix ai context --write` at the start of a task and again after your context was compressed or summarized (`norbix login` refreshes it too).', @@ -40,7 +61,7 @@ export function recipeBlock(): string { } /** Claude Code skill: frontmatter + the same rules as steps. */ -export function claudeSkill(): string { +export function claudeSkill(target?: RepoTarget): string { return [ '---', 'name: norbix', @@ -51,6 +72,7 @@ export function claudeSkill(): string { '', 'Use the `norbix` command for everything on the Norbix backend. It is built for agents: one JSON document per call with `--json`, documented exit codes, `--dry-run` on every change, nothing destructive without `--yes`.', '', + ...(targetLine(target).length ? [...targetLine(target).map((l) => l.slice(2)), ''] : []), '## Steps', '', '1. `norbix whoami --json` — the host and Hub, who you are (with a browser sign-in: an AI service user and its expiry), the project, environment and region. `"auth": "none"` (or exit 4 from any other command) means not signed in: sign in in two steps (your shell commands time out; the code lives 10 minutes):', @@ -82,22 +104,28 @@ export function claudeSkill(): string { } /** Cursor rule file (.mdc): frontmatter + the recipe. */ -export function cursorRule(): string { - return ['---', 'description: Use the norbix CLI for anything on the Norbix backend', 'alwaysApply: false', 'globs:', '---', '', recipeBlock()].join('\n') +export function cursorRule(target?: RepoTarget): string { + return ['---', 'description: Use the norbix CLI for anything on the Norbix backend', 'alwaysApply: false', 'globs:', '---', '', recipeBlock(target)].join('\n') } /** Files one target needs, given what is already on disk. */ -export function planTarget(target: Target, exists: (path: string) => boolean, read: (path: string) => string, force: boolean): PlannedFile[] { +export function planTarget( + target: Target, + exists: (path: string) => boolean, + read: (path: string) => string, + force: boolean, + repo?: RepoTarget, +): PlannedFile[] { switch (target) { case 'claude': return [ - fileOrReplace('.claude/skills/norbix/SKILL.md', claudeSkill(), exists, force), - blockIn('CLAUDE.md', exists, read, force), + fileOrReplace('.claude/skills/norbix/SKILL.md', claudeSkill(repo), exists, force), + blockIn('CLAUDE.md', exists, read, force, repo), ] case 'agents': - return [blockIn('AGENTS.md', exists, read, force)] + return [blockIn('AGENTS.md', exists, read, force, repo)] default: - return [fileOrReplace('.cursor/rules/norbix.mdc', cursorRule(), exists, force)] + return [fileOrReplace('.cursor/rules/norbix.mdc', cursorRule(repo), exists, force)] } } @@ -106,8 +134,8 @@ function fileOrReplace(path: string, content: string, exists: (p: string) => boo } /** Append the block to a Markdown file, or replace the block between the markers with --force. */ -function blockIn(path: string, exists: (p: string) => boolean, read: (p: string) => string, force: boolean): PlannedFile { - const block = recipeBlock() +function blockIn(path: string, exists: (p: string) => boolean, read: (p: string) => string, force: boolean, repo?: RepoTarget): PlannedFile { + const block = recipeBlock(repo) if (!exists(path)) return {path, action: 'create', content: block} const current = read(path) const start = current.indexOf(MARKER_START) diff --git a/src/lib/project-config.ts b/src/lib/project-config.ts new file mode 100644 index 0000000..d197670 --- /dev/null +++ b/src/lib/project-config.ts @@ -0,0 +1,90 @@ +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {homedir} from 'node:os' +import {dirname, join, parse, resolve} from 'node:path' + +import {usageError} from './cli-error.js' + +/** + * The repo's own Norbix target (N3 of the Happy Tummy findings): + * `.norbix/project.json` in the project folder (or any folder above the + * current one), written by `norbix ai init` and `norbix env use`: + * + * {"host": "cloud.example.com", "projectId": "pr_…", "env": "TEST"} + * + * No secrets: the sign-in stays in ~/.norbix. Commit it, so every clone and + * every coding agent in this repo talks to the same installation and + * project, whatever the computer's [default] profile says. + * + * Precedence: --host / --project / --env (and NORBIX_*) → this file → the + * profile → the sign-in. The file's host behaves like --host: a [default] + * profile for another host is not used (its API key is never sent there). + * An explicit --profile for another host wins, and the file is ignored. + */ + +export const PROJECT_DIR = '.norbix' +export const PROJECT_FILE = 'project.json' + +export interface ProjectConfig { + host?: string + projectId?: string + env?: string +} + +export interface FoundProjectConfig { + path: string + config: ProjectConfig +} + +/** `/.norbix/project.json`. */ +export function projectConfigPath(dir: string): string { + return join(dir, PROJECT_DIR, PROJECT_FILE) +} + +/** + * The nearest `.norbix/project.json` from `start` upwards. The home folder + * itself is never searched (~/.norbix is the CLI's own folder), nor anything + * above it. NORBIX_PROJECT_CONFIG=0 turns the lookup off. + */ +export function findProjectConfig(start: string = process.cwd(), home: string = homedir()): FoundProjectConfig | undefined { + if (process.env.NORBIX_PROJECT_CONFIG === '0') return undefined + let dir = resolve(start) + const stop = resolve(home) + const root = parse(dir).root + for (;;) { + if (dir === stop) return undefined + const path = projectConfigPath(dir) + if (existsSync(path)) return {path, config: readProjectConfig(path)} + if (dir === root) return undefined + dir = dirname(dir) + } +} + +export function readProjectConfig(path: string): ProjectConfig { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw usageError( + `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}.`, + 'Fix it, or write it again with `norbix ai init --force`.', + 'norbix ai init --help', + ) + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw usageError(`${path} must hold a JSON object like {"host": "…", "projectId": "…", "env": "TEST"}.`, undefined, 'norbix ai init --help') + } + + const o = parsed as Record + const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined) + return {host: text(o.host), projectId: text(o.projectId), env: text(o.env)?.toUpperCase()} +} + +export function writeProjectConfig(path: string, config: ProjectConfig): void { + mkdirSync(dirname(path), {recursive: true}) + const clean: ProjectConfig = {} + if (config.host) clean.host = config.host + if (config.projectId) clean.projectId = config.projectId + if (config.env) clean.env = config.env + writeFileSync(path, JSON.stringify(clean, null, 2) + '\n') +} diff --git a/test/login.test.ts b/test/login.test.ts index 91726df..336178f 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -297,7 +297,8 @@ describe('norbix login (browser sign-in)', () => { expect(readFileSync(PROFILES_PATH, 'utf8')).toBe('[default]\nproject_id = p-norbix\n') expect(output.at(-1)!.split('\n').at(-1)).toBe( 'Commands without --host still use profile [default] (hub.norbix.ai). ' + - 'Use this sign-in with --host cloud.example.com or NORBIX_HOST=cloud.example.com, or make it the default: norbix config set host cloud.example.com', + 'Use this sign-in with --host cloud.example.com or NORBIX_HOST=cloud.example.com; pin a repo to it with `norbix ai init --host cloud.example.com --project ` (writes .norbix/project.json there); ' + + 'or make it the default for every folder: norbix config set host cloud.example.com', ) }) diff --git a/test/setup.ts b/test/setup.ts index 4f27629..b2cf4a5 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -26,6 +26,10 @@ for (const name of ['NORBIX_PROJECT_ID', 'NORBIX_API_KEY', 'NORBIX_REGION', 'NOR // child process); tests/update-check.test.ts turns it on where it is tested. process.env.NORBIX_NO_UPDATE_CHECK = '1' +// No repo `.norbix/project.json` from the folder the tests run in; the +// tests of that file turn the lookup on with their own folder. +process.env.NORBIX_PROJECT_CONFIG = '0' + // hub.norbix.ai is "already discovered": the default host's built-in // addresses are in the host cache, so no test asks the real Hub. A test of // discovery itself uses its own host and its own fake fetch. diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index eb2da20..e833301 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -97,6 +97,7 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "target:option", "force:boolean", "dir:string", + "project-file:boolean", ], "id": "ai init", "supportsDryRun": true, diff --git a/tests/_cli.ts b/tests/_cli.ts index ad8fa6d..eda7700 100644 --- a/tests/_cli.ts +++ b/tests/_cli.ts @@ -131,14 +131,14 @@ export interface CliResult { } /** Run the built CLI with pipes for every stream (non-interactive). */ -export function cli(home: string, args: string[], env: Record = {}): Promise { +export function cli(home: string, args: string[], env: Record = {}, cwd: string = home): Promise { return new Promise((resolve) => { const child = execFile( process.execPath, [CLI, ...args], { env: {...process.env, HOME: home, USERPROFILE: home, CI: undefined, NO_COLOR: undefined, FORCE_COLOR: undefined, ...env}, - cwd: home, + cwd, maxBuffer: 10 * 1024 * 1024, }, (error, stdout, stderr) => { diff --git a/tests/ai-init.test.ts b/tests/ai-init.test.ts index f90a0d4..491b76c 100644 --- a/tests/ai-init.test.ts +++ b/tests/ai-init.test.ts @@ -1,4 +1,4 @@ -import {existsSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs' +import {existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' @@ -6,7 +6,7 @@ import {beforeEach, describe, expect, it} from 'vitest' import {claudeSkill, recipeBlock} from '../src/lib/agent-files.js' import {seedDefaultHost} from '../test/seed.js' -import {cli, makeHome, parseSingleJson} from './_cli.js' +import {cli, makeHome, parseSingleJson, startFakeGateway} from './_cli.js' const home = makeHome(1) let project: string @@ -28,15 +28,16 @@ describe('norbix ai init', () => { expect(r.code).toBe(0) const doc = parseSingleJson(r.stdout) as Report expect(doc.dryRun).toBe(true) - expect(doc.request?.files.map((f) => `${f.action} ${f.path}`)).toEqual(['create .claude/skills/norbix/SKILL.md', 'create CLAUDE.md']) + expect(doc.request?.files.map((f) => `${f.action} ${f.path}`)).toEqual(['create .claude/skills/norbix/SKILL.md', 'create CLAUDE.md', 'create .norbix/project.json']) expect(existsSync(join(project, '.claude'))).toBe(false) expect(existsSync(join(project, 'CLAUDE.md'))).toBe(false) + expect(existsSync(join(project, '.norbix'))).toBe(false) }) it('writes the skill and the CLAUDE.md block, then refuses a second run without --force', async () => { const first = await cli(home, ['ai', 'init', '--dir', project, '--json']) expect(first.code).toBe(0) - expect((parseSingleJson(first.stdout) as Report).files?.map((f) => f.path)).toEqual(['.claude/skills/norbix/SKILL.md', 'CLAUDE.md']) + expect((parseSingleJson(first.stdout) as Report).files?.map((f) => f.path)).toEqual(['.claude/skills/norbix/SKILL.md', 'CLAUDE.md', '.norbix/project.json']) const skill = readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8') expect(skill).toMatch(/^---\nname: norbix\ndescription: Use when the task touches the Norbix backend/) @@ -86,12 +87,13 @@ describe('norbix ai init', () => { 'CLAUDE.md', 'AGENTS.md', '.cursor/rules/norbix.mdc', + '.norbix/project.json', ]) expect(readFileSync(join(project, '.cursor/rules/norbix.mdc'), 'utf8')).toMatch(/^---\ndescription: /) }) it('the block and the skill carry the rules: two-step browser sign-in approved by the user, --json, --dry-run first, no secrets', async () => { - const r = await cli(home, ['ai', 'init', '--target', 'all', '--dir', project]) + const r = await cli(home, ['ai', 'init', '--target', 'all', '--dir', project, '--no-project-file']) expect(r.code).toBe(0) expect(r.stdout).toContain('Signing in: the agent runs `norbix login --no-browser --json` itself and shows you a link') @@ -131,6 +133,59 @@ describe('norbix ai init', () => { }) }) +describe('ai init saves the repo\'s Norbix: .norbix/project.json (N3)', () => { + it('writes host + project + env the CLI resolves, names them in the block, and later commands in the repo use them before [default]', async () => { + // [default] is for another installation (the Happy Tummy case: hub.finlo.space). + writeFileSync(join(home, '.norbix', 'config'), readFileSync(join(home, '.norbix', 'config'), 'utf8') + '\n[default]\nhost=127.0.0.1:1\napi_key=other-key\nproject_id=pr_Other\n') + const gw = await startFakeGateway() + const host = `127.0.0.1:${gw.port}` + try { + const r = await cli(home, ['ai', 'init', '--dir', project, '--host', host, '--project', 'pr_HappyTummy', '--env', 'test', '--json']) + expect(r.code).toBe(0) + expect(JSON.parse(readFileSync(join(project, '.norbix/project.json'), 'utf8'))).toEqual({host, projectId: 'pr_HappyTummy', env: 'TEST'}) + const claude = readFileSync(join(project, 'CLAUDE.md'), 'utf8') + expect(claude).toContain(`- This repo's Norbix: host \`${host}\`, project \`pr_HappyTummy\`, environment \`TEST\` — saved in \`.norbix/project.json\``) + expect(claude.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(12) + expect(readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8')).toContain(`This repo's Norbix: host \`${host}\``) + + // A command run inside the repo (a sub-folder) — no --host, no --project. + const sub = join(project, 'src', 'deep') + mkdirSync(sub, {recursive: true}) + const who = await cli(home, ['whoami', '--json'], {NORBIX_PROJECT_CONFIG: undefined}, sub) + const doc = parseSingleJson(who.stdout) as Record + expect(doc).toMatchObject({host, hostSource: 'project file', projectId: 'pr_HappyTummy', env: 'TEST', envSource: 'project file'}) + expect(doc.profile).toBe('(none)') // the [default] of the other host is not used — its key is never sent here + expect(doc.auth).toBe('none') + + // Outside the repo: the [default] profile as before. + const outside = await cli(home, ['whoami', '--json'], {NORBIX_PROJECT_CONFIG: undefined}, tmpdir()) + // (that host does not answer — the error shows which host it tried) + expect(JSON.stringify(parseSingleJson(outside.stdout))).toContain('127.0.0.1:1') + + // --host / --project still win. + const flags = await cli(home, ['whoami', '--json', '--project', 'pr_X'], {NORBIX_PROJECT_CONFIG: undefined}, sub) + expect(parseSingleJson(flags.stdout)).toMatchObject({projectId: 'pr_X', host}) + + // env use inside the repo writes the repo file. + const use = await cli(home, ['env', 'use', 'staging', '--json'], {NORBIX_PROJECT_CONFIG: undefined}, sub) + expect(use.code).toBe(0) + expect(JSON.parse(readFileSync(join(project, '.norbix/project.json'), 'utf8')).env).toBe('STAGING') + } finally { + await gw.close() + writeFileSync(join(home, '.norbix', 'config'), readFileSync(join(home, '.norbix', 'config'), 'utf8').split('\n[default]')[0]) + } + }) + + it('an existing project.json is kept without --force (and named in the block)', async () => { + mkdirSync(join(project, '.norbix'), {recursive: true}) + writeFileSync(join(project, '.norbix/project.json'), '{"host":"cloud.kept.example","projectId":"pr_Kept"}') + const r = await cli(home, ['ai', 'init', '--dir', project, '--host', 'cloud.example.com', '--json']) + expect(r.code).toBe(0) + expect(JSON.parse(readFileSync(join(project, '.norbix/project.json'), 'utf8'))).toEqual({host: 'cloud.kept.example', projectId: 'pr_Kept'}) + expect(readFileSync(join(project, 'CLAUDE.md'), 'utf8')).toContain('host `cloud.kept.example`, project `pr_Kept`') + }) +}) + describe('the agent rules: sign in in two steps, CI with a key', () => { it('the block tells the agent to start the sign-in, show the link, and wait — never to hand login to the user only', () => { const block = recipeBlock() diff --git a/tests/project-config.test.ts b/tests/project-config.test.ts new file mode 100644 index 0000000..97a2466 --- /dev/null +++ b/tests/project-config.test.ts @@ -0,0 +1,62 @@ +import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' + +import {findProjectConfig, projectConfigPath} from '../src/lib/project-config.js' + +/** N3: the nearest `.norbix/project.json`, never the home folder's own. */ + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'norbix-project-config-')) + delete process.env.NORBIX_PROJECT_CONFIG +}) +afterEach(() => { + process.env.NORBIX_PROJECT_CONFIG = '0' +}) + +const write = (dir: string, body: string) => { + mkdirSync(join(dir, '.norbix'), {recursive: true}) + writeFileSync(projectConfigPath(dir), body) +} + +describe('findProjectConfig', () => { + it('finds the file in a parent folder and normalises env to upper case', () => { + const repo = join(root, 'repo') + write(repo, '{"host":"cloud.example.com","projectId":"pr_1","env":"test"}') + const deep = join(repo, 'a', 'b') + mkdirSync(deep, {recursive: true}) + expect(findProjectConfig(deep, join(root, 'home'))).toEqual({ + path: projectConfigPath(repo), + config: {host: 'cloud.example.com', projectId: 'pr_1', env: 'TEST'}, + }) + }) + + it('the nearest one wins', () => { + write(root, '{"projectId":"pr_outer"}') + const inner = join(root, 'inner') + write(inner, '{"projectId":"pr_inner"}') + expect(findProjectConfig(inner, '/nowhere')?.config.projectId).toBe('pr_inner') + }) + + it('stops at the home folder: ~/.norbix belongs to the CLI, not to a repo', () => { + const home = join(root, 'home') + write(home, '{"projectId":"pr_home"}') + const below = join(home, 'code', 'repo') + mkdirSync(below, {recursive: true}) + expect(findProjectConfig(below, home)).toBeUndefined() + }) + + it('NORBIX_PROJECT_CONFIG=0 turns the lookup off', () => { + write(root, '{"projectId":"pr_1"}') + process.env.NORBIX_PROJECT_CONFIG = '0' + expect(findProjectConfig(root, '/nowhere')).toBeUndefined() + }) + + it('broken JSON is a usage error that names the file', () => { + write(root, '{nope') + expect(() => findProjectConfig(root, '/nowhere')).toThrow(/project\.json is not valid JSON/) + }) +}) From d52ed8b349fc3030f33470550e9c2b4d3ecb2931 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:57:33 +0300 Subject: [PATCH 10/19] feat(env): a change never goes to PROD by default (pickio N-21) With no --env, no profile env and no sign-in env the CLI sent no norbix-env header, and the gateway ran the call in PROD (MapOrProd). Writes now follow the MCP surface's AiToolEnvPolicy: a change (every command with --dry-run, every non-read hub/api call, raw with a method other than GET) needs a named environment. --env / NORBIX_ENV (PROD included) or a saved non-PROD default (.norbix/project.json, profile, env use) is sent; no environment is refused with exit 2 ENV_REQUIRED, PROD only from a saved default with exit 2 PROD_NOT_EXPLICIT; at a terminal the CLI asks instead. --yes does not answer it; --dry-run is never refused. Reads keep the server default. Account/project-wide settings (account, project, module, env, AI service users) and the integration test commands are not affected. whoami, --env help, the README, docs/agent-contract.md and the ai init agent files say so. Breaking: scripts that change data without naming an environment now exit 2 instead of writing to PROD. Add --env PROD (or NORBIX_ENV=PROD) to keep writing to PROD, or --env TEST. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++++ docs/agent-contract.md | 8 ++ package.json | 2 +- src/base.ts | 70 +++++++++++++++- src/commands/ai/llm/test.ts | 3 + src/commands/ai/mcp/test.ts | 3 + src/commands/email/integration/test.ts | 3 + src/commands/email/validation/test.ts | 3 + src/commands/push/integration/test.ts | 3 + src/commands/raw.ts | 4 + src/commands/sms/integration/test.ts | 3 + src/commands/whoami.ts | 2 +- src/lib/agent-files.ts | 4 +- src/lib/namespace-command.ts | 10 ++- test/login.test.ts | 3 + test/setup.ts | 6 ++ tests/ai-init.test.ts | 8 +- tests/env-policy.test.ts | 112 +++++++++++++++++++++++++ 18 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 tests/env-policy.test.ts diff --git a/README.md b/README.md index e4c276d..939a712 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,27 @@ explicit `--profile` for another host wins, and the file is ignored. shows `hostSource: project file`. The home folder is never searched; `NORBIX_PROJECT_CONFIG=0` turns the lookup off. +**Environments: a change never lands in PROD by default.** Every call +works in an environment: `--env` / `NORBIX_ENV`, else the repo's +`.norbix/project.json`, the profile's `env`, the sign-in's (`norbix env use`). +With none, no `norbix-env` header is sent and the server uses PROD. Reads keep +that default. A **change** (every command with `--dry-run`, and every +non-read `hub` / `api` / `raw` call) follows the same rule as the MCP tools +(`AiToolEnvPolicy`): + +| environment of the change | result | +| --- | --- | +| named with `--env` / `NORBIX_ENV` (PROD included) | sent | +| a saved default that is not PROD (repo file, profile, `env use`) | sent | +| none at all — it would be PROD | refused: exit 2 `ENV_REQUIRED` | +| PROD, but only from a saved default | refused: exit 2 `PROD_NOT_EXPLICIT` | + +At a terminal the CLI asks instead of refusing; `--yes` does not answer it — +PROD is chosen per call with `--env PROD`. A `--dry-run` is never refused. +Account- and project-wide settings (`account`, `project`, `module`, `env`, +AI service users) are not inside an environment and are not affected. +Scripts that deploy to PROD set `NORBIX_ENV=PROD`. + **Profiles (AWS-style)** — one INI file at `~/.norbix/config`: ```ini diff --git a/docs/agent-contract.md b/docs/agent-contract.md index ebadb25..1e72bc5 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -138,6 +138,14 @@ command that makes several calls (`files upload`) shows the first. `enable`, `invite`, `unblock`, `upload`, `publish`, `unpublish`, `config set / unset`, `env use`, `logout`, `raw`. - **Read-only**: everything else; no `--yes`, no `--dry-run`. +- **The environment of a change.** A destructive or mutating call inside an + environment needs one named: `--env` / `NORBIX_ENV` (PROD included), or a + saved non-PROD default (`.norbix/project.json`, the profile, `env use`). + With none — the server would use PROD — it exits 2 `ENV_REQUIRED`; with + PROD only from a saved default, exit 2 `PROD_NOT_EXPLICIT`. Nothing is + sent. `--yes` does not answer it; `--dry-run` is never refused. Reads, + `account`, `project`, `module`, `env` and AI service users are not + affected. Same rule as the MCP tools' `AiToolEnvPolicy`. ## 6. Shared code diff --git a/package.json b/package.json index bea58fc..894f0fb 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ } }, "env": { - "description": "Manage project environments (PROD, TEST, ...): list, create (a new env needs its own database: --connection-string-env or --managed), use, delete" + "description": "Manage project environments (PROD, TEST, ...): list, create (a new env needs its own database: --connection-string-env or --managed), use, delete. A change needs a named env: --env, or a saved non-PROD default; PROD only with --env PROD" }, "files": { "description": "Upload, download and manage files", diff --git a/src/base.ts b/src/base.ts index 832203e..ba0a252 100644 --- a/src/base.ts +++ b/src/base.ts @@ -222,7 +222,7 @@ 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). A change needs one: with none (or PROD only from a saved default) it is refused — pass --env PROD to change PROD on purpose', env: 'NORBIX_ENV', helpGroup: 'GLOBAL', }), @@ -374,6 +374,59 @@ export abstract class BaseCommand extends Command { } await this.prepareHost(flags) + if (isEnvWriteCommand(ctor, this.id)) await this.assertWriteEnv(flags) + } + + /** + * Which commands are "writes inside an environment" (the env policy below + * applies): a command with --dry-run changes something; `envWrite = false` + * opts out (dynamic commands decide per call), and project- or account-wide + * settings (ENV_FREE_COMMANDS) have no environment at all. + */ + static envWrite?: boolean + + /** + * The environment rule for a change (Pickio N-21), the CLI twin of the MCP + * surface's AiToolEnvPolicy: + * + * - --env / NORBIX_ENV names it: any environment, PROD included. + * - a saved default (.norbix/project.json, `env use`, the profile) that + * is not PROD: used. + * - no environment at all — the server would use PROD — or PROD only from + * a saved default: refused (exit 2, ENV_REQUIRED / PROD_NOT_EXPLICIT); + * at a terminal the CLI asks instead. --yes does not answer it: a + * change in PROD is chosen per call, with --env PROD. + * + * Reads keep the old default (no header = PROD). A dry run sends nothing, + * so it is never refused. + */ + protected async assertWriteEnv(flags: GlobalFlags): Promise { + if (flags['dry-run']) return + const ctx = this.resolveContext(flags) + if (ctx.envSource === 'flag/env') return + const env = ctx.env?.trim().toUpperCase() + if (env && env !== 'PROD') return + + const unset = !env + const message = unset + ? `\`${this.commandName}\` changes data and no environment is set, so it would run in PROD.` + : `\`${this.commandName}\` changes data in PROD, and PROD comes only from a saved default (${ctx.envSource}${ctx.envSource === 'project file' && ctx.projectFile ? `: ${ctx.projectFile.path}` : ''}).` + const hint = + 'Name the environment on the call: --env TEST (or NORBIX_ENV=TEST), or --env PROD to change PROD on purpose. ' + + (unset ? 'To make a non-PROD environment the default here: norbix env use TEST (in a repo set up by `norbix ai init` it is saved in .norbix/project.json).' : 'A saved default is never enough for PROD.') + if (this.isInteractive()) { + const {confirm} = await import('@inquirer/prompts') + if (await confirm({message: `${message} Run it in PROD?`, default: false})) return + throw new CliError({exit: EXIT.CANCELLED, code: 'CANCELLED', message: 'Cancelled.', hint}) + } + + throw new CliError({ + exit: EXIT.USAGE, + code: unset ? 'ENV_REQUIRED' : 'PROD_NOT_EXPLICIT', + message, + hint, + docs: 'norbix env --help', + }) } /** Discover the host `flags` point at, so `resolveContext` knows its Hub. */ @@ -1008,6 +1061,21 @@ export abstract class BaseCommand extends Command { } } +/** + * Commands that change something but not inside an environment: account and + * project settings (CORS, languages, roles of the account, module switches — + * project-wide by design), the environments themselves, and local files. + */ +export const ENV_FREE_COMMANDS = ['account', 'project', 'env', 'module', 'ai:init', 'ai:service-user', 'config', 'configure', 'login', 'logout', 'profiles'] + +/** True for a command the environment rule applies to (see `assertWriteEnv`). */ +export function isEnvWriteCommand(ctor: {envWrite?: boolean; flags?: Record}, id: string | undefined): boolean { + if (ctor.envWrite !== undefined) return ctor.envWrite + if (!ctor.flags || !('dry-run' in ctor.flags)) return false + const cid = id ?? '' + return !ENV_FREE_COMMANDS.some((prefix) => cid === prefix || cid.startsWith(`${prefix}:`)) +} + /** `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/ai/llm/test.ts b/src/commands/ai/llm/test.ts index e5c21c9..56fa9d7 100644 --- a/src/commands/ai/llm/test.ts +++ b/src/commands/ai/llm/test.ts @@ -3,6 +3,9 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class AiLlmTest extends BaseCommand { + /** A check, not a change: the env rule for writes does not apply. */ + static envWrite = false + static description = 'Check that an LLM integration works: a live call to the provider' static examples = [ diff --git a/src/commands/ai/mcp/test.ts b/src/commands/ai/mcp/test.ts index bbe826d..b5518f5 100644 --- a/src/commands/ai/mcp/test.ts +++ b/src/commands/ai/mcp/test.ts @@ -3,6 +3,9 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class AiMcpTest extends BaseCommand { + /** A check, not a change: the env rule for writes does not apply. */ + static envWrite = false + static description = 'Check that an MCP server integration works: connect to the server' static examples = [ diff --git a/src/commands/email/integration/test.ts b/src/commands/email/integration/test.ts index 94f5ad2..f3a5a6c 100644 --- a/src/commands/email/integration/test.ts +++ b/src/commands/email/integration/test.ts @@ -3,6 +3,9 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class EmailIntegrationTest extends BaseCommand { + /** A check, not a change: the env rule for writes does not apply. */ + static envWrite = false + static description = `Send a test e-mail through an integration Point this at the Fake integration while you are developing — it accepts the diff --git a/src/commands/email/validation/test.ts b/src/commands/email/validation/test.ts index 3c1ad93..bd9a19f 100644 --- a/src/commands/email/validation/test.ts +++ b/src/commands/email/validation/test.ts @@ -3,6 +3,9 @@ import {Args} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class EmailValidationTest extends BaseCommand { + /** A check, not a change: the env rule for writes does not apply. */ + static envWrite = false + static description = `Check that an email-validation integration can reach its provider The provider checks one test address; no e-mail is sent.` diff --git a/src/commands/push/integration/test.ts b/src/commands/push/integration/test.ts index 4def92b..dd0f9b3 100644 --- a/src/commands/push/integration/test.ts +++ b/src/commands/push/integration/test.ts @@ -3,6 +3,9 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class PushIntegrationTest extends BaseCommand { + /** A check, not a change: the env rule for writes does not apply. */ + static envWrite = false + static description = `Send a test push through an integration Point this at the Fake integration while you are developing — it accepts the diff --git a/src/commands/raw.ts b/src/commands/raw.ts index ff4c69c..765de1c 100644 --- a/src/commands/raw.ts +++ b/src/commands/raw.ts @@ -34,10 +34,14 @@ path and it becomes v3.` query: Flags.string({char: 'q', description: 'Query parameter key=value (repeatable)', multiple: true}), } + static envWrite = false + async run(): Promise { const {args, flags} = await this.parse(Raw) const ctx = await this.freshContext(flags) this.assertEndpoints(ctx) + // A raw write (any method but GET) follows the env rule like every change. + if (flags.method !== 'GET') await this.assertWriteEnv(flags) if (!ctx.projectId) this.error('No project ID configured. Run `norbix login` or pass --project.') const token = ctx.bearerToken ?? ctx.apiKey diff --git a/src/commands/sms/integration/test.ts b/src/commands/sms/integration/test.ts index 82e33b4..2ce424c 100644 --- a/src/commands/sms/integration/test.ts +++ b/src/commands/sms/integration/test.ts @@ -3,6 +3,9 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' export default class SmsIntegrationTest extends BaseCommand { + /** A check, not a change: the env rule for writes does not apply. */ + static envWrite = false + static description = `Send a test SMS through an integration Point this at the Fake integration while you are developing — it accepts the diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 1776dd3..c018714 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -36,7 +36,7 @@ export default class Whoami extends BaseCommand { apiKey: ctx.apiKey ? redact(ctx.apiKey) : undefined, projectId: ctx.projectId, accountId: ctx.accountId, - env: ctx.env ?? '(not set — the server uses PROD)', + env: ctx.env ?? '(not set — reads use PROD; changes are refused until you name one: --env, or norbix env use )', envSource: ctx.envSource, projectFile: ctx.projectFile?.path, region: diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts index 87020a4..bf016dc 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -51,7 +51,7 @@ export function recipeBlock(target?: RepoTarget): string { '- Pass `--json` to every command whose output you read: stdout is then exactly one JSON document (a result, or `{"error": {code, message, exit, fieldErrors, hint, docs}}`).', '- Project context (your projects, environments, regions, modules, integrations, CORS, languages — never secrets): read `.norbix-ai/context.md` when it is there; run `norbix ai context --write` at the start of a task and again after your context was compressed or summarized (`norbix login` refreshes it too).', '- Discover, do not guess: `norbix schema --json` (every command, `destructive`, `supportsDryRun`), then `norbix --help`. No named command? `norbix hub --json` lists the SDK methods and their fields.', - '- Before any change, run it with `--dry-run --json` (sends nothing) and show the user the request. Then re-run without `--dry-run`, adding `--yes` only for a destructive command (without it the command exits 3 and sends nothing).', + '- Before any change, run it with `--dry-run --json` (sends nothing) and show the user the request. Then re-run without `--dry-run`, adding `--yes` only for a destructive command (without it the command exits 3 and sends nothing). A change needs an environment: `--env TEST`, or `--env PROD` only when the user means PROD (without one: exit 2 `ENV_REQUIRED` / `PROD_NOT_EXPLICIT`).', '- Never print, paste or ask for API keys, passwords or tokens, and never read `~/.norbix`. Credentials come from `norbix login`, a `--profile`, or `NORBIX_*` variables the user set (CI: `NORBIX_HOST` + `NORBIX_API_KEY` + `NORBIX_PROJECT_ID`).', '- Branch on the exit code: 0 ok · 2 usage (fix the call) · 3 needs `--yes` · 4 auth · 5 not found · 6 validation (`fieldErrors`) · 7 network · 8 server (retry with backoff) · 9 cancelled.', '- Full contract: `docs/agent-contract.md` in the norbix-code/cli repo; `error.hint` and `error.docs` name the next step.', @@ -82,7 +82,7 @@ export function claudeSkill(target?: RepoTarget): string { ' Then load the project context: read `.norbix-ai/context.md` if it is there, else run `norbix ai context --write` (it writes that file: every project you may work on — environments, regions, modules, integrations with their default per environment, CORS origins, languages; no secrets). Read it again whenever your context was compressed or summarized; `norbix ai context --json` has a `version` that changes only when the summary does.', '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.', - '4. Change in two steps: `norbix ... --dry-run --json` prints the exact request and sends nothing — show it to the user. Then re-run without `--dry-run`, adding `--yes` only if the command is destructive. Never add `--yes` to a first attempt.', + '4. Change in two steps: `norbix ... --dry-run --json` prints the exact request and sends nothing — show it to the user. Then re-run without `--dry-run`, adding `--yes` only if the command is destructive. Never add `--yes` to a first attempt. Every change names its environment: `--env TEST`, or `--env PROD` only when the user asked for PROD — with none (or PROD only from a saved default) it exits 2 `ENV_REQUIRED` / `PROD_NOT_EXPLICIT`, never silently PROD.', '5. Branch on the exit code and read `error.hint` / `error.docs`: 0 ok · 2 usage · 3 add `--yes` after a dry run · 4 auth (sign in again as in step 1) · 5 not found (check id / `--env` / `--project`) · 6 validation (`error.fieldErrors`) · 7 network · 8 server (retry with backoff, keep `traceId`) · 9 cancelled.', '', '## Secrets', diff --git a/src/lib/namespace-command.ts b/src/lib/namespace-command.ts index 0612444..57e66a8 100644 --- a/src/lib/namespace-command.ts +++ b/src/lib/namespace-command.ts @@ -2,7 +2,7 @@ import {Norbix} from '@norbix.ai/ts' import {BaseCommand, type DryRunValidation, type GlobalFlags} from '../base.js' import {usageError} from './cli-error.js' -import {MODULE_ALIASES, camelSplit, isDestructive, matchMethods, parseArgv, typeFields} from './dispatch.js' +import {MODULE_ALIASES, camelSplit, isDestructive, isReadOnly, matchMethods, parseArgv, typeFields} from './dispatch.js' import {readJsonInput} from './json.js' import {describeFields, fieldKinds, methodInfo, type FieldKind} from './request-fields.js' @@ -24,7 +24,12 @@ next word). Force a type with --field:str, --field:num, --field:bool or * Shared engine for `norbix hub ...` and `norbix api ...` — resolves the * module and method from plain words and calls the SDK. */ +/** SDK modules whose calls are not inside an environment. */ +const ENV_FREE_MODULES = new Set(['account', 'environments', 'projects', 'regions', 'licensing', 'accessToken', 'auth', 'oauth', 'wellKnown', 'echo', 'public']) + export abstract class NamespaceCommand extends BaseCommand { + static envWrite = false + protected abstract readonly target: 'api' | 'hub' /** @@ -205,6 +210,9 @@ export abstract class NamespaceCommand extends BaseCommand { // Context (auth, region, project) is resolved before any confirmation or // dry run, so a dry run that would fail for real fails here too. const client = this.client(flags) + // Changes inside an environment follow the env rule (BaseCommand.assertWriteEnv); + // the account / environments modules are not inside one. + if (!isReadOnly(method) && !ENV_FREE_MODULES.has(moduleName)) await this.assertWriteEnv(flags) if (isDestructive(method)) { await this.confirmOrFail(`Run ${method}(${JSON.stringify(request)})?`, flags) } diff --git a/test/login.test.ts b/test/login.test.ts index 336178f..45c0d9f 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -20,6 +20,9 @@ import {ownerOnly, seedDefaultHost} from './seed.js' * are stubbed; HOME is the test sandbox. */ +// Sign-in saves no environment unless one is named; the suite's NORBIX_ENV (test/setup.ts) is not one here. +delete process.env.NORBIX_ENV + vi.mock('@inquirer/prompts', () => ({ input: vi.fn(async () => ''), password: vi.fn(async () => 'pw'), diff --git a/test/setup.ts b/test/setup.ts index b2cf4a5..7ef2a0d 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -30,6 +30,12 @@ process.env.NORBIX_NO_UPDATE_CHECK = '1' // tests of that file turn the lookup on with their own folder. process.env.NORBIX_PROJECT_CONFIG = '0' +// A change needs a named environment (BaseCommand.assertWriteEnv, Pickio +// N-21). The suite names PROD the way a script does — NORBIX_ENV — so every +// test still checks its command against the default environment; +// tests/env-policy.test.ts removes it to test the rule itself. +process.env.NORBIX_ENV = 'PROD' + // hub.norbix.ai is "already discovered": the default host's built-in // addresses are in the host cache, so no test asks the real Hub. A test of // discovery itself uses its own host and its own fake fetch. diff --git a/tests/ai-init.test.ts b/tests/ai-init.test.ts index 491b76c..4fca2cc 100644 --- a/tests/ai-init.test.ts +++ b/tests/ai-init.test.ts @@ -151,23 +151,23 @@ describe('ai init saves the repo\'s Norbix: .norbix/project.json (N3)', () => { // A command run inside the repo (a sub-folder) — no --host, no --project. const sub = join(project, 'src', 'deep') mkdirSync(sub, {recursive: true}) - const who = await cli(home, ['whoami', '--json'], {NORBIX_PROJECT_CONFIG: undefined}, sub) + const who = await cli(home, ['whoami', '--json'], {NORBIX_PROJECT_CONFIG: undefined, NORBIX_ENV: undefined}, sub) const doc = parseSingleJson(who.stdout) as Record expect(doc).toMatchObject({host, hostSource: 'project file', projectId: 'pr_HappyTummy', env: 'TEST', envSource: 'project file'}) expect(doc.profile).toBe('(none)') // the [default] of the other host is not used — its key is never sent here expect(doc.auth).toBe('none') // Outside the repo: the [default] profile as before. - const outside = await cli(home, ['whoami', '--json'], {NORBIX_PROJECT_CONFIG: undefined}, tmpdir()) + const outside = await cli(home, ['whoami', '--json'], {NORBIX_PROJECT_CONFIG: undefined, NORBIX_ENV: undefined}, tmpdir()) // (that host does not answer — the error shows which host it tried) expect(JSON.stringify(parseSingleJson(outside.stdout))).toContain('127.0.0.1:1') // --host / --project still win. - const flags = await cli(home, ['whoami', '--json', '--project', 'pr_X'], {NORBIX_PROJECT_CONFIG: undefined}, sub) + const flags = await cli(home, ['whoami', '--json', '--project', 'pr_X'], {NORBIX_PROJECT_CONFIG: undefined, NORBIX_ENV: undefined}, sub) expect(parseSingleJson(flags.stdout)).toMatchObject({projectId: 'pr_X', host}) // env use inside the repo writes the repo file. - const use = await cli(home, ['env', 'use', 'staging', '--json'], {NORBIX_PROJECT_CONFIG: undefined}, sub) + const use = await cli(home, ['env', 'use', 'staging', '--json'], {NORBIX_PROJECT_CONFIG: undefined, NORBIX_ENV: undefined}, sub) expect(use.code).toBe(0) expect(JSON.parse(readFileSync(join(project, '.norbix/project.json'), 'utf8')).env).toBe('STAGING') } finally { diff --git a/tests/env-policy.test.ts b/tests/env-policy.test.ts new file mode 100644 index 0000000..a51fdd3 --- /dev/null +++ b/tests/env-policy.test.ts @@ -0,0 +1,112 @@ +import {appendFileSync, mkdirSync, mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' + +import {cli, makeHome, parseSingleJson, startFakeGateway, type FakeGateway} from './_cli.js' + +/** + * Pickio N-21: a change never lands in PROD by default. The CLI's twin of the + * MCP surface's AiToolEnvPolicy (BaseCommand.assertWriteEnv). The built CLI + * runs as a child with pipes — a script or an agent, never a terminal. + */ + +let gateway: FakeGateway +let home: string +const NO_ENV = {NORBIX_ENV: undefined} + +beforeAll(async () => { + gateway = await startFakeGateway() + home = makeHome(gateway.port) + appendFileSync( + join(home, '.norbix', 'config'), + [`[xprod]`, `host=127.0.0.1:${gateway.port}`, 'api_key=k', 'project_id=p', 'env=PROD', `[xtest]`, `host=127.0.0.1:${gateway.port}`, 'api_key=k', 'project_id=p', 'env=TEST', ''].join('\n'), + ) +}) + +afterAll(async () => { + await gateway.close() +}) + +beforeEach(() => { + gateway.hits.length = 0 +}) + +const INSERT = ['db', 'insert', 'orders', '--doc', '{"n":1}', '--json'] +const errorOf = (stdout: string) => (parseSingleJson(stdout) as {error: {code: string; message: string; hint: string; exit: number}}).error + +describe('a change with no environment, or PROD only from a saved default, is refused', () => { + it('no env anywhere: exit 2 ENV_REQUIRED, the hint names --env TEST / --env PROD, nothing is sent', async () => { + const r = await cli(home, [...INSERT, '--profile', 'x'], NO_ENV) + expect(r.code).toBe(2) + const e = errorOf(r.stdout) + expect(e.code).toBe('ENV_REQUIRED') + expect(e.message).toBe('`db insert` changes data and no environment is set, so it would run in PROD.') + expect(e.hint).toContain('--env TEST') + expect(e.hint).toContain('--env PROD to change PROD on purpose') + expect(gateway.hits).toHaveLength(0) + }) + + it('--yes does not answer it', async () => { + const r = await cli(home, ['db', 'delete', 'orders', '--id', 'abc', '--yes', '--json', '--profile', 'x'], NO_ENV) + expect(errorOf(r.stdout).code).toBe('ENV_REQUIRED') + expect(gateway.hits).toHaveLength(0) + }) + + it('PROD saved in the profile is not enough: exit 2 PROD_NOT_EXPLICIT', async () => { + const r = await cli(home, [...INSERT, '--profile', 'xprod'], NO_ENV) + expect(r.code).toBe(2) + expect(errorOf(r.stdout)).toMatchObject({code: 'PROD_NOT_EXPLICIT'}) + expect(errorOf(r.stdout).message).toContain('PROD comes only from a saved default (profile [xprod])') + expect(gateway.hits).toHaveLength(0) + }) + + it('PROD saved in the repo file (.norbix/project.json) is not enough either', async () => { + const repo = mkdtempSync(join(tmpdir(), 'norbix-env-policy-')) + mkdirSync(join(repo, '.norbix')) + writeFileSync(join(repo, '.norbix', 'project.json'), JSON.stringify({host: `127.0.0.1:${gateway.port}`, projectId: 'p', env: 'PROD'})) + const r = await cli(home, [...INSERT, '--api-key', 'k'], {...NO_ENV, NORBIX_PROJECT_CONFIG: undefined}, repo) + expect(errorOf(r.stdout).code).toBe('PROD_NOT_EXPLICIT') + expect(errorOf(r.stdout).message).toContain('project file') + }) + + it('dynamic writes (hub …) and raw writes follow the same rule', async () => { + const hub = await cli(home, ['hub', 'scheduler', 'delete', 'task', '--id', 'abc', '--yes', '--json', '--profile', 'x'], NO_ENV) + expect(errorOf(hub.stdout).code).toBe('ENV_REQUIRED') + const raw = await cli(home, ['raw', '/v3/scheduler/tasks', '-X', 'POST', '--json', '--profile', 'x'], NO_ENV) + expect(errorOf(raw.stdout).code).toBe('ENV_REQUIRED') + expect(gateway.hits).toHaveLength(0) + }) +}) + +describe('what is allowed', () => { + it('--env TEST, --env PROD, NORBIX_ENV=PROD: the change is sent', async () => { + expect((await cli(home, [...INSERT, '--profile', 'x', '--env', 'TEST'], NO_ENV)).code).toBe(0) + expect((await cli(home, [...INSERT, '--profile', 'x', '--env', 'PROD'], NO_ENV)).code).toBe(0) + expect((await cli(home, [...INSERT, '--profile', 'x'], {NORBIX_ENV: 'PROD'})).code).toBe(0) + expect((await cli(home, [...INSERT, '--profile', 'xprod', '--env', 'PROD'], NO_ENV)).code).toBe(0) + expect(gateway.hits).toHaveLength(4) + }) + + it('a saved default that is not PROD (profile, repo file, env use) is used', async () => { + const r = await cli(home, [...INSERT, '--profile', 'xtest'], NO_ENV) + expect(r.code).toBe(0) + expect(gateway.hits).toHaveLength(1) + }) + + it('reads keep the default (no env = the server default)', async () => { + expect((await cli(home, ['db', 'find', 'orders', '--json', '--profile', 'x'], NO_ENV)).code).toBe(0) + expect((await cli(home, ['hub', 'scheduler', 'tasks', 'get', '--json', '--profile', 'x'], NO_ENV)).code).toBe(0) + }) + + it('a dry run sends nothing, so it is never refused', async () => { + expect((await cli(home, [...INSERT, '--dry-run', '--profile', 'x'], NO_ENV)).code).toBe(0) + expect(gateway.hits).toHaveLength(0) + }) + + it('project- and account-wide settings are not inside an environment', async () => { + const r = await cli(home, ['project', 'set-name', 'Pickio', '--json', '--profile', 'x'], NO_ENV) + expect(r.stdout).not.toContain('ENV_REQUIRED') + }) +}) From d5a999f646500df4b8524359cb82c2366bf8a137 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 12:58:55 +0300 Subject: [PATCH 11/19] docs(email,sms): one Fake integration per environment, not per project (pickio N-11) The gateway allows one Fake per project AND environment (Email.Integrations.cs), so PROD (seeded by module enable) and TEST can each have one. The help of email/sms integration save and docs/email.md, docs/sms.md said "one Fake per project". Co-Authored-By: Claude Opus 5.5 --- docs/email.md | 5 +++-- docs/sms.md | 3 ++- src/commands/email/integration/save.ts | 3 ++- src/commands/sms/integration/save.ts | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/email.md b/docs/email.md index 6550cc3..d017ce1 100644 --- a/docs/email.md +++ b/docs/email.md @@ -41,8 +41,9 @@ first two, for scripts that treat every module alike. `--provider` is one of `Fake`, `Smtp`, `SendGrid`, `MailGun`, `AwsSes`. **Fake** needs nothing else: it accepts every send and contacts no mail service, so -nothing reaches a real inbox — use it while you develop. A project can hold one -Fake and it has no update path: delete it and add it again. The real providers +nothing reaches a real inbox — use it while you develop. Each environment of a +project can hold one Fake (PROD and TEST may each have their own; `module +enable email` adds PROD's) and it has no update path: delete it and add it again. The real providers need a sender (`--from`) and their credentials: put the provider-specific fields in `--config` as a JSON object (inline, `@file.json`, or `-` for stdin) so a key does not land in your shell history. diff --git a/docs/sms.md b/docs/sms.md index 436378e..d92c3e5 100644 --- a/docs/sms.md +++ b/docs/sms.md @@ -39,7 +39,8 @@ first two, for scripts that treat every module alike. `--provider` is one of `Fake`, `Twilio`, `Vonage`, `Plivo`, `Telnyx`, `Bird`, `Telesign`, `Sinch`. **Fake** needs nothing else: it accepts every send and contacts no SMS service, so nothing reaches a real phone — use it while you -develop. A project can hold one Fake and it has no update path: delete it and +develop. Each environment of a project can hold one Fake (PROD and TEST may each +have their own) and it has no update path: delete it and add it again. The real providers need their credentials: put the provider-specific fields in `--config` as a JSON object (inline, `@file.json`, or `-` for stdin). diff --git a/src/commands/email/integration/save.ts b/src/commands/email/integration/save.ts index c804180..38aca72 100644 --- a/src/commands/email/integration/save.ts +++ b/src/commands/email/integration/save.ts @@ -11,7 +11,8 @@ export default class EmailIntegrationSave extends BaseCommand { --provider picks the body shape. Fake needs nothing else: it accepts every send and contacts no mail service, so nothing reaches a real inbox — use it while you -develop (one Fake per project, no update — delete and add it again). The real +develop (one Fake per environment — +PROD and TEST each may have one; no update — delete and add it again). The real providers need a sender (--from) and their credentials: put the provider-specific fields in --config (inline JSON, @file.json, or - for stdin). Pass --id to update an existing integration. The output of \`email integration diff --git a/src/commands/sms/integration/save.ts b/src/commands/sms/integration/save.ts index ccb4f1c..4da30d7 100644 --- a/src/commands/sms/integration/save.ts +++ b/src/commands/sms/integration/save.ts @@ -10,8 +10,8 @@ export default class SmsIntegrationSave extends BaseCommand { static description = `Create or update an SMS integration --provider picks the body shape. Fake needs nothing else: it accepts every send -and contacts no SMS service, so use it while you develop (one Fake per project, -no update — delete and add it again). The real providers need their +and contacts no SMS service, so use it while you develop (one Fake per environment — +PROD and TEST each may have one; no update — delete and add it again). The real providers need their credentials — put the provider-specific fields in --config (inline JSON, @file.json, or - for stdin). Pass --id to update an existing integration.` From 0060419e4d797166de5f03460d5cd61d84e394e8 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:00:48 +0300 Subject: [PATCH 12/19] fix(login): pick up the approval while "Press ENTER" is still shown (N2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `norbix login` printed the link and waited at "Press ENTER to open the browser" before it started polling, so a person who opened the printed link and pressed Allow stayed signed out until they pressed Enter. The device-token poll now starts right after the code is printed; ENTER only opens the browser, and the prompt is aborted (AbortSignal) as soon as the poll ends — approved, denied or expired — so the CLI never waits for a key that is no longer needed. Co-Authored-By: Claude Opus 5.5 --- src/commands/login.ts | 34 ++++++++++++++++++++++++++++------ test/login.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 6e6d67d..c994eb0 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -276,17 +276,21 @@ A shell with no terminal (scripts, CI, agents) without --api-key, this.log('') this.log(`Approve this sign-in on the Norbix dashboard: ${link}`) this.log('Check that the dashboard shows the same code, then pick the roles the CLI gets.') + // Polling starts now, not after ENTER (N2): a person who opens the + // printed link by hand and approves is signed in at once. ENTER only + // opens the browser; the prompt is closed as soon as the poll ends. + const poll = pollDeviceToken(hub, start, (m) => this.log(m)) if (flags['no-browser'] || !canOpenBrowser()) { this.log('No browser is opened here: open the link on any device.') + this.log(`Waiting for you to approve in the browser (the code expires in ${minutes} minutes)...`) } else if (this.isInteractive()) { - const {input} = await import('@inquirer/prompts') - await input({message: 'Press ENTER to open the browser...'}) - openBrowser(link) + this.log(`Waiting for your approval (the code expires in ${minutes} minutes) — opened the link yourself? Just approve it there.`) + void this.offerBrowser(link, poll) + } else { + this.log(`Waiting for you to approve in the browser (the code expires in ${minutes} minutes)...`) } - this.log(`Waiting for you to approve in the browser (the code expires in ${minutes} minutes)...`) - - const token = await pollDeviceToken(hub, start, (m) => this.log(m)) + const token = await poll return this.saveSignIn(ctx, hub, token, { host, projectId, @@ -297,6 +301,24 @@ A shell with no terminal (scripts, CI, agents) without --api-key, }) } + /** + * "Press ENTER to open the browser" while the sign-in is already being + * polled. The prompt is aborted when the poll settles (approved, denied or + * expired), so the CLI never waits for a key that is no longer needed. + */ + private async offerBrowser(link: string, poll: Promise): Promise { + const {input} = await import('@inquirer/prompts') + const controller = new AbortController() + const settle = () => controller.abort() + poll.then(settle, settle) + try { + await input({message: 'Press ENTER to open the browser'}, {signal: controller.signal}) + if (!controller.signal.aborted) openBrowser(link) + } catch { + // Aborted because the sign-in finished (or the prompt was closed): nothing to do. + } + } + /** Agent step 2: poll the saved code for up to 90 s. */ private async finishPending(ctx: ResolvedContext): Promise { const pending = readPending(ctx.hubKey) diff --git a/test/login.test.ts b/test/login.test.ts index 45c0d9f..f014ff7 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -360,6 +360,31 @@ describe('norbix login (browser sign-in)', () => { expect(existsSync(HUB_SESSION())).toBe(false) }) + it('N2: polls while the ENTER prompt is open — approving the printed link signs in without ENTER, and the prompt is closed', async () => { + const prompts = await import('@inquirer/prompts') + let signal: AbortSignal | undefined + // A person who never presses ENTER: the prompt only ends when it is aborted. + vi.mocked(prompts.input).mockImplementationOnce( + (_config: unknown, context?: {signal?: AbortSignal}) => { + signal = context?.signal + return new Promise((_resolve, reject) => signal?.addEventListener('abort', () => reject(new Error('aborted')))) as never + }, + ) + stubNetwork({ + '/v3/echo': [{body: ECHO}], + '/v3/auth/device/start': [{body: START}], + '/v3/auth/device/token': [{body: {error: 'authorization_pending'}}, {body: SUCCESS}], + }) + + const result = (await runLogin()) as {status: string} + + expect(result.status).toBe('signed-in') + expect(readJson(HUB_SESSION()).bearerToken).toBe('access-1') + expect(opened).toEqual([]) // ENTER was never pressed: no browser + expect(signal?.aborted).toBe(true) // the prompt does not keep the CLI waiting + expect(output.some((l) => l.includes('opened the link yourself? Just approve it there.'))).toBe(true) + }) + it('a machine without a desktop (or over SSH): no ENTER prompt, no browser, the link is printed and the CLI keeps waiting', async () => { machine.desktop = false const prompts = await import('@inquirer/prompts') From 34af3554084739816a4945ce034ce9e1ce38d343 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:05:42 +0300 Subject: [PATCH 13/19] fix(files): take only the env's default files integration, never one from the list (N6) The Hub resolves defaultIntegrationId for the environment of the call (Hub.Files/Integrations/GetAll.cs), but its integration list is project-wide and its items carry no environment (FetchFilesIntegrationsQuery.cs), so "the only one in the list" may be another environment's. Without a default the command now stops (exit 2) and lists the integrations instead of picking one; with none at all it exits 5 NO_FILES_INTEGRATION. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++---- src/lib/files.ts | 14 ++++++++------ test/files.test.ts | 18 +++++++++--------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 939a712..cc16d41 100644 --- a/README.md +++ b/README.md @@ -433,10 +433,11 @@ Commands live in `src/commands//.ts` — one file per command File commands use the project's **default files integration** of the current environment (`--env`), like the database commands use the default database -integration — one read of the Hub's integration list. When the environment has -none, they exit 5 (`NO_FILES_INTEGRATION`, "This project has no files -integration in environment TEST"); several with no default exit 2 and list -them. To pick another one, pass `--integration ` (env var: +integration — one read of the Hub's integration list (its default is per +environment). With no files integration at all they exit 5 +(`NO_FILES_INTEGRATION`); with integrations but no default in this +environment they exit 2 and list them (the list is project-wide, so none is +picked for you). To pick another one, pass `--integration ` (env var: `NORBIX_FILES_INTEGRATION_ID`) or save it: ```sh diff --git a/src/lib/files.ts b/src/lib/files.ts index 0d7583c..50c77c2 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -19,8 +19,11 @@ export const DRY_RUN_FILES_INTEGRATION = '' * The project's default files integration in the current environment — * like the database commands, which use the default database integration * when no --integration is given (N6). One read of the Hub's integration list: - * its `defaultIntegrationId`, else the only integration there is. None, or - * several without a default, are two different errors. + * its `defaultIntegrationId` is resolved for the environment of the call + * (Hub.Files/Integrations/GetAll.cs). The list itself is project-wide (its + * items carry no environment), so an integration is never picked from it — + * it may belong to another environment. No integration at all, and no + * default here, are two different errors. */ export async function defaultFilesIntegration(hub: Pick, env: string | undefined): Promise { const where = `environment ${env ?? 'PROD'}` @@ -39,12 +42,11 @@ export async function defaultFilesIntegration(hub: Pick, if (res.defaultIntegrationId) return res.defaultIntegrationId const items = (res.list?.items ?? []) as Array<{viewId: string; integrationName?: string}> - if (items.length === 1) return items[0].viewId if (items.length === 0) { throw new CliError({ exit: EXIT.NOT_FOUND, code: 'NO_FILES_INTEGRATION', - message: `This project has no files integration in ${where}.`, + message: `This project has no files integration (looked up for ${where}).`, hint: `Add one in the dashboard (Files → Integrations) or with \`norbix hub files saveFilesIntegration --body ''\`` + `${env && env !== 'PROD' ? `, or pass --env for the environment that has one` : ''}; then run the command again.`, @@ -53,8 +55,8 @@ export async function defaultFilesIntegration(hub: Pick, } throw usageError( - `This project has ${items.length} files integrations in ${where} and none is the default.`, - `Pass --integration (or save one: norbix config set files_integration_id ):\n${items.map((i) => ` ${i.viewId}${i.integrationName ? ` ${i.integrationName}` : ''}`).join('\n')}`, + `No default files integration in ${where}.`, + `Pass --integration (or save one: norbix config set files_integration_id ). The project's files integrations (all environments — the list does not say which):\n${items.map((i) => ` ${i.viewId}${i.integrationName ? ` ${i.integrationName}` : ''}`).join('\n')}`, 'norbix integrations files --help', ) } diff --git a/test/files.test.ts b/test/files.test.ts index e3a37c5..4e343ee 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -110,17 +110,17 @@ describe('norbix files list', () => { expect(paths()).toEqual(['GET /v3/files/integrations', 'GET /v3/files/fi_Default']) }) - it('no default but exactly one integration: uses it', async () => { - fakeFetch([['/files/integrations', {list: {items: [{viewId: 'fi_Only'}]}}]]) - const {error} = await runCommand(['files', 'delete', 'a.txt', '--yes', ...noIntegration]) - expect(error).toBeUndefined() - expect(paths().at(-1)).toContain('/files/fi_Only') + it('no default here but one in the list: not picked — the list is project-wide, it may be another environment\'s', async () => { + fakeFetch([['/files/integrations', {list: {items: [{viewId: 'fi_Prod'}]}}]]) + const {error} = await runCommand(['files', 'delete', 'a.txt', '--yes', '--env', 'TEST', ...noIntegration]) + expect(error?.message).toBe('No default files integration in environment TEST.') + expect(paths()).toEqual(['GET /v3/files/integrations']) }) it('none in the environment: exit 5 NO_FILES_INTEGRATION that names the environment', async () => { fakeFetch([['/files/integrations', {list: {items: []}}]]) const {error} = await runCommand(['files', 'list', '--env', 'TEST', ...noIntegration]) - expect(error?.message).toContain('This project has no files integration in environment TEST.') + expect(error?.message).toContain('This project has no files integration (looked up for environment TEST).') expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(5) expect(paths()).toEqual(['GET /v3/files/integrations']) }) @@ -128,7 +128,7 @@ describe('norbix files list', () => { it('several and no default: exit 2 listing them, so the caller picks one', async () => { fakeFetch([['/files/integrations', {list: {items: [{viewId: 'fi_A', integrationName: 'S3'}, {viewId: 'fi_B'}]}}]]) const {error} = await runCommand(['files', 'list', ...noIntegration]) - expect(error?.message).toContain('2 files integrations in environment PROD and none is the default') + expect(error?.message).toBe('No default files integration in environment PROD.') expect((error as {oclif?: {exit?: number}})?.oclif?.exit).toBe(2) expect(paths()).toHaveLength(1) }) @@ -178,7 +178,7 @@ describe('norbix files get-by-id', () => { const {error} = await runCommand(['files', 'get-by-id', 'nbfl_7hK2abc', '--project', PROJECT, '--api-key', API_KEY, '--region', REGION]) // The Hub (fake) has no files integration: only the lookup was sent. - expect(error?.message).toContain('This project has no files integration in environment PROD.') + expect(error?.message).toContain('This project has no files integration (looked up for environment PROD).') expect(calls.map((c) => new URL(c.url).pathname)).toEqual(['/v3/files/integrations']) }) }) @@ -427,7 +427,7 @@ describe('norbix files publish / unpublish', () => { ]) // The Hub (fake) has no files integration: only the lookup was sent. - expect(error?.message).toContain('This project has no files integration in environment PROD.') + expect(error?.message).toContain('This project has no files integration (looked up for environment PROD).') expect(calls.map((c) => new URL(c.url).pathname)).toEqual(['/v3/files/integrations']) }) From e5a16439180d548232bcb53e7aa77dec35a3789e Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:08:50 +0300 Subject: [PATCH 14/19] fix(env): aggregates, distinct values and diagnostics are reads for the env rule (pickio N-21) hub/api calls whose verb is aggregate, distinct, execute(Aggregate), analyze, has, inspect, query, read, wait or run only read, but the env rule for changes treated them as writes and refused them without --env. They are read verbs now; a test runs distinct and aggregate with no environment. Co-Authored-By: Claude Opus 5.5 --- src/lib/dispatch.ts | 5 ++++- tests/env-policy.test.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/dispatch.ts b/src/lib/dispatch.ts index 353f9e3..93ae899 100644 --- a/src/lib/dispatch.ts +++ b/src/lib/dispatch.ts @@ -325,7 +325,10 @@ export function isDestructive(method: string): boolean { } /** Read-only verbs: no --dry-run needed, nothing changes. */ -const READ_VERBS = new Set(['get', 'list', 'find', 'count', 'search', 'preview', 'render', 'reveal', 'check', 'validate', 'export', 'download', 'ping', 'echo', 'test', 'verify']) +// aggregate … run: every SDK method with that verb only reads (aggregates, distinct values, +// diagnostics, waitForProjectActive, hasPasskey, analyzeImportFile) — the env rule for changes +// (BaseCommand.assertWriteEnv) must not refuse them. +const READ_VERBS = new Set(['get', 'list', 'find', 'count', 'search', 'preview', 'render', 'reveal', 'check', 'validate', 'export', 'download', 'ping', 'echo', 'test', 'verify', 'aggregate', 'distinct', 'execute', 'analyze', 'has', 'inspect', 'query', 'read', 'wait', 'run']) export function isReadOnly(method: string): boolean { return READ_VERBS.has(camelSplit(method)[0]) diff --git a/tests/env-policy.test.ts b/tests/env-policy.test.ts index a51fdd3..ab7eb61 100644 --- a/tests/env-policy.test.ts +++ b/tests/env-policy.test.ts @@ -98,6 +98,9 @@ describe('what is allowed', () => { it('reads keep the default (no env = the server default)', async () => { expect((await cli(home, ['db', 'find', 'orders', '--json', '--profile', 'x'], NO_ENV)).code).toBe(0) expect((await cli(home, ['hub', 'scheduler', 'tasks', 'get', '--json', '--profile', 'x'], NO_ENV)).code).toBe(0) + // POST reads through hub/api: aggregates and distinct values are not changes + expect((await cli(home, ['api', 'database', 'distinct', '--collectionName', 'orders', '--field', 'status', '--json', '--profile', 'x'], NO_ENV)).code).toBe(0) + expect((await cli(home, ['api', 'database', 'aggregate', '--collectionName', 'orders', '--pipeline', '[]', '--json', '--profile', 'x'], NO_ENV)).code).toBe(0) }) it('a dry run sends nothing, so it is never refused', async () => { From 5a676b7a77047e4f20119997906d53b9304b49fe Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:08:50 +0300 Subject: [PATCH 15/19] docs(env): env create help names the managed-service trial, not every trial (N5) The gateway's EnvironmentCreationPolicy blocks a norbix.ai managed-service trial; a self-hosted install with a licence is not on trial, and the Happy Tummy client (self-hosted) has a TEST environment. Co-Authored-By: Claude Opus 5.5 --- src/commands/env/create.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/env/create.ts b/src/commands/env/create.ts index 98457c1..dc11a32 100644 --- a/src/commands/env/create.ts +++ b/src/commands/env/create.ts @@ -31,7 +31,8 @@ the environment afterwards (--env ). Give the database one of two ways: There is no "same server as PROD, new database" option: the Hub does not hand out PROD's connection string, so pass it yourself. Creating an environment is -not available while the account is on a trial (each one is an extra database). +not available during a norbix.ai managed-service trial (each one is an extra +database); a self-hosted licence is not a trial. Then work in it: \`norbix env use \` or --env per command.` From d152bc1664295b53764422fcf2cacd3d156df4e7 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:19:25 +0300 Subject: [PATCH 16/19] fix(ai): ai init reports .norbix/project.json with / on Windows too (N3) The planned path was built with path.join, so Windows reported .norbix\project.json while every other agent file uses /. Co-Authored-By: Claude Opus 5.5 --- src/commands/ai/init.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/ai/init.ts b/src/commands/ai/init.ts index ac6b725..8707155 100644 --- a/src/commands/ai/init.ts +++ b/src/commands/ai/init.ts @@ -70,7 +70,8 @@ written.` const read = (p: string) => readFileSync(join(root, p), 'utf8') // What the CLI resolves right now (cache only, no network): the repo's target. const ctx = this.resolveContext(flags) - const filePath = join(PROJECT_DIR, PROJECT_FILE) + // Always "/" — the path is reported to the caller and joined onto root later. + const filePath = `${PROJECT_DIR}/${PROJECT_FILE}` const kept = exists(filePath) && !flags.force ? readProjectConfig(join(root, filePath)) : undefined const repo: RepoTarget = kept ?? {host: storedHost(ctx.host), projectId: ctx.projectId, env: ctx.env?.toUpperCase()} const planned = targets.flatMap((t) => planTarget(t, exists, read, flags.force, flags['project-file'] ? repo : undefined)) From 9ee8eba04aeaddf89bc04adf1aea576003e49d2a Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:21:23 +0300 Subject: [PATCH 17/19] fix(cli): encode every / of the package name in the registry URL (N4) CodeQL js/incomplete-sanitization: replace('/') changes only the first one. The name has one, so the URL was right, but replaceAll says what is meant. Co-Authored-By: Claude Opus 5.5 --- src/lib/update-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts index d024068..1f16416 100644 --- a/src/lib/update-check.ts +++ b/src/lib/update-check.ts @@ -22,7 +22,7 @@ import {fileURLToPath} from 'node:url' */ export const PACKAGE = '@norbix.ai/cli' -export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE.replace('/', '%2f')}/latest` +export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE.replaceAll('/', '%2f')}/latest` export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000 const COMMANDS_THAT_ALWAYS_SHOW = new Set(['login', 'ai:init', 'whoami']) From 2e5cb05db95310ba5e9bed7dc4cc8df7888d8408 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:35:02 +0300 Subject: [PATCH 18/19] feat(users): users import, repair-roles and repair-logins (N7, N20) Friendly commands for the new admin Hub routes on the gateway (POST /membership/users/import, /repair-roles, /repair-logins). They check or count by default (dryRun: true) and write only with --apply, which follows the env rule for changes (a named environment; PROD only with --env PROD). users import reads JSON (array or {users}) or CSV (roles split on ";", profile columns into userGeneralInfo), sends requests of 1000 users, continues row numbers across them and prints the per-row table; --dry-run masks hashes and salts. @norbix.ai/ts 4.26.0 has no typed methods for these routes yet, so they go through the new BaseCommand.hubRequest (SDK headers, refreshed session, gateway refusals as the error envelope). Switch them to the typed SDK calls after the next SDK release. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + src/base.ts | 84 ++++++++++- src/commands/users/import.ts | 80 +++++++++++ src/commands/users/repair-logins.ts | 46 ++++++ src/commands/users/repair-roles.ts | 46 ++++++ src/lib/user-import.ts | 178 ++++++++++++++++++++++++ tests/__snapshots__/schema.test.ts.snap | 33 +++++ tests/users-admin.test.ts | 136 ++++++++++++++++++ 8 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 src/commands/users/import.ts create mode 100644 src/commands/users/repair-logins.ts create mode 100644 src/commands/users/repair-roles.ts create mode 100644 src/lib/user-import.ts create mode 100644 tests/users-admin.test.ts diff --git a/README.md b/README.md index cc16d41..77f2c06 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ 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 users import [--apply]` | Import users with their existing password hashes (CodeMash V1 `servicestack-saltedhash`, Norbix `servicestack-pbkdf2`); checks every row by default, `--apply` writes; 1000 per request | +| `norbix users repair-roles / repair-logins [--apply]` | Admin repairs: contacts left with `[null]` roles, logins stored without a project id; count by default, `--apply` repairs | | `norbix env list/create/use/delete` | Manage project environments; `env create TEST --connection-string-env VAR` (or `--managed`) creates one with its database | | `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) | diff --git a/src/base.ts b/src/base.ts index ba0a252..3c83de3 100644 --- a/src/base.ts +++ b/src/base.ts @@ -5,7 +5,7 @@ import {Norbix} from '@norbix.ai/ts' import {Command, Flags, ux} from '@oclif/core' import {CliError, formatErrorText, toEnvelope, usageError} from './lib/cli-error.js' -import {EXIT} from './lib/exit-codes.js' +import {EXIT, exitForStatus} from './lib/exit-codes.js' import { DEFAULT_HOST, cachedHost, @@ -181,6 +181,14 @@ export interface DryRunValidation { note?: string } +/** A `hubRequest` dry run already printed its report: `catch` exits 0 quietly. */ +export class DryRunDone extends Error { + constructor() { + super('dry run') + this.name = 'DryRunDone' + } +} + /** Thrown by the dry-run middleware instead of sending; caught in `catch`. */ class DryRunStop extends Error { constructor(readonly http: DryRunRequest) { @@ -273,6 +281,8 @@ export abstract class BaseCommand extends Command { /** The SDK call captured by a dry run, filled by the client proxy. */ private dryRunCall?: {method: string; request: unknown} + /** The last report `dryRun` printed (a `hubRequest` dry run returns it from `catch`). */ + private lastDryRun?: DryRunReport /** A dry run's input check, set by the command before the stopped call. */ protected dryRunValidation?: DryRunValidation @@ -325,6 +335,7 @@ export abstract class BaseCommand extends Command { const report: DryRunReport = {dryRun: true, method: call.method, request: call.request} if (call.http) report.http = call.http this.printDryRun(report) + this.lastDryRun = report return report } @@ -915,6 +926,73 @@ export abstract class BaseCommand extends Command { return read(this.client(flags)) } + /** + * A Hub call the installed SDK has no typed method for yet (new gateway + * routes before the next `@norbix.ai/ts` release). Same headers as the SDK + * and `norbix raw` (auth, project, account, env, region, language), the + * session refreshed first. A dry run prints the request — with the fields + * named in `redact` masked — and sends nothing. A gateway refusal becomes the + * usual error envelope. Switch the caller to the typed SDK method once the + * SDK has it. + */ + protected async hubRequest( + flags: GlobalFlags, + call: {method: 'POST' | 'GET' | 'PUT' | 'DELETE'; path: string; body?: unknown; name: string; redact?: (body: unknown) => unknown}, + ): Promise { + const ctx = await this.freshContext(flags) + if (!ctx.projectId) throw new NoProjectError(flags) + this.assertEndpoints(ctx) + const token = ctx.bearerToken ?? ctx.apiKey + if (!token && !flags['dry-run']) { + throw new CliError({exit: EXIT.AUTH, code: 'UNAUTHENTICATED', message: 'Not authenticated.', hint: 'Run `norbix login`, or pass --api-key.', docs: 'norbix login --help'}) + } + + const url = `${ctx.hubUrl.replace(/\/$/, '')}/${ctx.hubVersion ?? 'v3'}${call.path}` + const headers: Record = {Accept: 'application/json', 'norbix-project-id': ctx.projectId, Authorization: `Bearer ${token ?? ''}`} + if (ctx.accountId) headers['norbix-account-id'] = ctx.accountId + if (ctx.env) headers['norbix-env'] = ctx.env.toUpperCase() + if (ctx.region) headers['nb-region'] = ctx.region + const language = this.acceptLanguage(flags) + if (language) headers['Accept-Language'] = language + if (call.body !== undefined) headers['Content-Type'] = 'application/json' + + if (flags['dry-run']) { + const shown = call.redact ? call.redact(call.body) : call.body + this.dryRun({method: call.name, request: shown ?? {}, http: {method: call.method, url, headers: {...headers, Authorization: 'Bearer ***'}, body: shown}}) + throw new DryRunDone() + } + + let res: Response + try { + res = await fetch(url, {method: call.method, headers, body: call.body === undefined ? undefined : JSON.stringify(call.body)}) + } catch (error) { + throw new CliError({exit: EXIT.NETWORK, code: 'NETWORK_ERROR', message: `Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`, url}) + } + + const text = await res.text() + let data: unknown + try { + data = text ? JSON.parse(text) : {} + } catch { + data = text + } + + const status = (data as {responseStatus?: {errorCode?: string; message?: string; isSuccess?: boolean; errors?: Array<{errorCode?: string; message?: string}>}})?.responseStatus + const refused = !res.ok || status?.isSuccess === false || Boolean(status?.errorCode) + if (refused) { + const first = status?.errors?.[0] + throw new CliError({ + exit: res.status === 404 && !status ? EXIT.NOT_FOUND : exitForStatus(res.status), + code: status?.errorCode ?? first?.errorCode ?? `HTTP_${res.status}`, + message: status?.message ?? first?.message ?? (res.status === 404 ? `This Hub has no ${call.method} ${call.path} — it is older than this command.` : `HTTP ${res.status}`), + status: res.status, + url, + }) + } + + return data as T + } + /** * The files integration a `files` command uses: --integration / * NORBIX_FILES_INTEGRATION_ID → the profile's files_integration_id → the @@ -1037,6 +1115,10 @@ export abstract class BaseCommand extends Command { // `this.exit(n)` — already decided, nothing to print. if ((error as {code?: string}).code === 'EEXIT') throw error + if (error instanceof DryRunDone) { + if (this.jsonEnabled() && this.lastDryRun) this.logJson(this.lastDryRun) + return this.lastDryRun + } // A CLI error raised inside the SDK's middleware (an ended sign-in) // arrives wrapped as a network error: report the CLI error itself. diff --git a/src/commands/users/import.ts b/src/commands/users/import.ts new file mode 100644 index 0000000..c09d9e6 --- /dev/null +++ b/src/commands/users/import.ts @@ -0,0 +1,80 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' +import {batches, mergeReports, readImportFile, redactRows, reportTable, type ImportReport} from '../../lib/user-import.js' + +export default class UsersImport extends BaseCommand { + static description = `Import users with their existing password hashes (CodeMash V1 → Norbix) + +Checks by default and writes nothing: the Hub validates every row and answers +valid / exists / invalid per row. Add --apply to write. Users keep their +passwords: the hash is stored as it is and re-hashed at the first sign-in. +A login that already exists is answered "exists" and left alone, so a file +can be sent again. The file is split into requests of 1000 users. + +The file is JSON — an array of users, or {"users": [...]} — or CSV with a +header row. Fields: email or userName (one of them), hashFormat +(servicestack-saltedhash for CodeMash V1, servicestack-pbkdf2 for Norbix), +passwordHash and passwordSalt (base64, exactly as stored; the salt only for +V1), roles (names or role ids; in CSV separated by ";"), createdOn (Unix +ms), and the profile (JSON: userGeneralInfo {…}; CSV: firstName, lastName, +displayName, phone, language, … as columns). + +Writes into the environment of the call: --apply needs a named environment +(--env TEST, or --env PROD on purpose — see \`norbix env --help\`). Project +or account administrators only. --dry-run prints the request with the hashes +masked and sends nothing.` + + static examples = [ + '<%= config.bin %> users import users.json --env TEST', + '<%= config.bin %> users import users.json --dry-run', + '<%= config.bin %> users import users.csv --env TEST --apply', + '<%= config.bin %> users import users.json --env PROD --apply --json', + ] + + static args = { + file: Args.string({required: true, description: 'JSON or CSV file with the users'}), + } + + static flags = { + ...BaseCommand.dryRunFlags, + apply: Flags.boolean({description: 'Write the users (default: check every row and write nothing)', default: false}), + 'ignore-default-roles': Flags.boolean({description: "Do not add the project's default roles", default: false}), + } + + /** The env rule applies to --apply only; the check is a read. */ + static envWrite = false + + async run(): Promise { + const {args, flags} = await this.parse(UsersImport) + const rows = readImportFile(args.file) + if (flags.apply) await this.assertWriteEnv(flags) + + const parts: Array<{report: ImportReport; offset: number}> = [] + let offset = 0 + for (const users of batches(rows)) { + // Typed SDK call once @norbix.ai/ts has it: hub.membership.importUsers. + const res = await this.hubRequest<{result?: ImportReport}>(flags, { + method: 'POST', + path: '/membership/users/import', + name: 'hub POST /membership/users/import', + body: {dryRun: !flags.apply, ignoreDefaultRoles: flags['ignore-default-roles'], users}, + redact: redactRows, + }) + if (res.result) parts.push({report: res.result, offset}) + offset += users.length + } + + const report = mergeReports(parts) + const counts = `${report.received} received: ${report.imported} imported, ${report.valid} valid, ${report.existing} already there, ${report.invalid} invalid, ${report.failed} failed` + this.print( + [ + reportTable(report), + '', + report.dryRun ? `Checked — nothing written. ${counts}.` : `${counts}.`, + ...(report.dryRun && report.valid > 0 ? ['Write them with the same command and --apply.'] : []), + ].join('\n'), + ) + return report + } +} diff --git a/src/commands/users/repair-logins.ts b/src/commands/users/repair-logins.ts new file mode 100644 index 0000000..b57397f --- /dev/null +++ b/src/commands/users/repair-logins.ts @@ -0,0 +1,46 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class UsersRepairLogins extends BaseCommand { + static description = `Give logins stored without a project id the id of this project (when a role id or a contact of this project shows they belong to it) + +Counts by default and writes nothing; add --apply to repair. Works in the +environment of the call: --apply needs a named environment (--env TEST, or +--env PROD on purpose). Project or account administrators only.` + + static examples = [ + '<%= config.bin %> users repair-logins --env TEST', + '<%= config.bin %> users repair-logins --apply --env TEST --dry-run', + '<%= config.bin %> users repair-logins --env PROD --apply', + ] + + static flags = { + ...BaseCommand.dryRunFlags, + apply: Flags.boolean({description: 'Repair (default: count and write nothing)', default: false}), + } + + /** The env rule applies to --apply only; the count is a read. */ + static envWrite = false + + async run(): Promise { + const {flags} = await this.parse(UsersRepairLogins) + if (flags.apply) await this.assertWriteEnv(flags) + // Typed SDK call once @norbix.ai/ts has the route. + const res = await this.hubRequest<{result?: Record}>(flags, { + method: 'POST', + path: '/membership/users/repair-logins', + name: 'hub POST /membership/users/repair-logins', + body: {dryRun: !flags.apply}, + }) + const result = res.result ?? {} + this.print( + [ + ...'loginsWithoutProject loginsToRepair loginsRepaired accountLoginsSkipped otherProjectLoginsSkipped loginsWithoutEvidence'.split(' ').map((k) => `${k.padEnd(28)} ${String(result[k] ?? 0)}`), + '', + flags.apply ? 'Repaired.' : 'Counted — nothing written. Repair with the same command and --apply.', + ].join('\n'), + ) + return result + } +} diff --git a/src/commands/users/repair-roles.ts b/src/commands/users/repair-roles.ts new file mode 100644 index 0000000..761e0dd --- /dev/null +++ b/src/commands/users/repair-roles.ts @@ -0,0 +1,46 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' + +export default class UsersRepairRoles extends BaseCommand { + static description = `Give contacts with no role the roles their logins hold (repair of the [null] roles of sign-ups before 2.2.0, client finding N20) + +Counts by default and writes nothing; add --apply to repair. Works in the +environment of the call: --apply needs a named environment (--env TEST, or +--env PROD on purpose). Project or account administrators only.` + + static examples = [ + '<%= config.bin %> users repair-roles --env TEST', + '<%= config.bin %> users repair-roles --apply --env TEST --dry-run', + '<%= config.bin %> users repair-roles --env PROD --apply', + ] + + static flags = { + ...BaseCommand.dryRunFlags, + apply: Flags.boolean({description: 'Repair (default: count and write nothing)', default: false}), + } + + /** The env rule applies to --apply only; the count is a read. */ + static envWrite = false + + async run(): Promise { + const {flags} = await this.parse(UsersRepairRoles) + if (flags.apply) await this.assertWriteEnv(flags) + // Typed SDK call once @norbix.ai/ts has the route. + const res = await this.hubRequest<{result?: Record}>(flags, { + method: 'POST', + path: '/membership/users/repair-roles', + name: 'hub POST /membership/users/repair-roles', + body: {dryRun: !flags.apply}, + }) + const result = res.result ?? {} + this.print( + [ + ...'contactsWithoutRoles contactsToRepair contactsRepaired contactsWithoutLoginRoleIds legacyRoleNamesIgnored'.split(' ').map((k) => `${k.padEnd(28)} ${String(result[k] ?? 0)}`), + '', + flags.apply ? 'Repaired.' : 'Counted — nothing written. Repair with the same command and --apply.', + ].join('\n'), + ) + return result + } +} diff --git a/src/lib/user-import.ts b/src/lib/user-import.ts new file mode 100644 index 0000000..c1e7ff7 --- /dev/null +++ b/src/lib/user-import.ts @@ -0,0 +1,178 @@ +import {readFileSync} from 'node:fs' +import {extname} from 'node:path' + +import {usageError} from './cli-error.js' + +/** + * `norbix users import`: the rows of POST /membership/users/import (gateway + * ImportUsers.cs, client finding N7) read from a JSON or CSV file. + */ + +/** The gateway takes at most this many users per request. */ +export const MAX_ROWS_PER_REQUEST = 1000 + +export const HASH_FORMATS = ['servicestack-saltedhash', 'servicestack-pbkdf2'] + +export interface ImportRow { + email?: string + userName?: string + userGeneralInfo?: Record + roles?: string[] + createdOn?: number + hashFormat?: string + passwordHash?: string + passwordSalt?: string +} + +export interface ImportOutcome { + row: number + login?: string + outcome: string + userId?: string + errors?: Array<{code?: string; message: string}> +} + +export interface ImportReport { + dryRun: boolean + received: number + imported: number + valid: number + existing: number + invalid: number + failed: number + rows: ImportOutcome[] +} + +const ROW_FIELDS = new Set(['email', 'userName', 'roles', 'createdOn', 'hashFormat', 'passwordHash', 'passwordSalt']) +/** CSV columns that go into userGeneralInfo (UserGeneralInfoDto). */ +const PROFILE_FIELDS = new Set([ + 'phone', 'primaryEmail', 'displayName', 'firstName', 'lastName', 'fullName', 'addressLine1', 'addressLine2', + 'country', 'city', 'state', 'postalCode', 'company', 'gender', 'birthDate', 'timeZone', 'language', +]) + +/** The users of a file: a JSON array (or `{"users": [...]}`), or a CSV with a header row. */ +export function readImportFile(path: string): ImportRow[] { + let text: string + try { + text = readFileSync(path, 'utf8').replace(/^/, '') + } catch (error) { + throw usageError(`Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}.`, undefined, 'norbix users import --help') + } + + const rows = extname(path).toLowerCase() === '.csv' || (!text.trimStart().startsWith('[') && !text.trimStart().startsWith('{')) ? fromCsv(text) : fromJson(text, path) + if (rows.length === 0) throw usageError(`${path} holds no users.`, undefined, 'norbix users import --help') + return rows +} + +function fromJson(text: string, path: string): ImportRow[] { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (error) { + throw usageError(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}.`, undefined, 'norbix users import --help') + } + + const list = Array.isArray(parsed) ? parsed : (parsed as {users?: unknown})?.users + if (!Array.isArray(list) || list.some((r) => typeof r !== 'object' || r === null || Array.isArray(r))) { + throw usageError(`${path} must be a JSON array of users, or {"users": [...]}.`, 'One user: {"email": "…", "hashFormat": "servicestack-saltedhash", "passwordHash": "…", "passwordSalt": "…"}.', 'norbix users import --help') + } + + return list as ImportRow[] +} + +/** RFC 4180 CSV: a header row, quoted fields with "" for a quote. */ +export function parseCsv(text: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let field = '' + let quoted = false + for (let i = 0; i < text.length; i++) { + const c = text[i] + if (quoted) { + if (c === '"' && text[i + 1] === '"') { + field += '"' + i++ + } else if (c === '"') quoted = false + else field += c + } else if (c === '"') quoted = true + else if (c === ',') { + row.push(field) + field = '' + } else if (c === '\n' || c === '\r') { + if (c === '\r' && text[i + 1] === '\n') i++ + row.push(field) + if (row.some((f) => f !== '')) rows.push(row) + row = [] + field = '' + } else field += c + } + + row.push(field) + if (row.some((f) => f !== '')) rows.push(row) + return rows +} + +function fromCsv(text: string): ImportRow[] { + const [header, ...lines] = parseCsv(text) + if (!header) return [] + const columns = header.map((h) => h.trim()) + const unknown = columns.filter((c) => !ROW_FIELDS.has(c) && !PROFILE_FIELDS.has(c)) + if (unknown.length > 0) { + throw usageError( + `Unknown CSV column${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}.`, + `Columns: ${[...ROW_FIELDS].join(', ')} (roles separated by ";"), and profile fields: ${[...PROFILE_FIELDS].join(', ')}.`, + 'norbix users import --help', + ) + } + + return lines.map((cells) => { + const row: ImportRow = {} + const info: Record = {} + columns.forEach((column, i) => { + const value = (cells[i] ?? '').trim() + if (!value) return + if (column === 'roles') row.roles = value.split(';').map((r) => r.trim()).filter(Boolean) + else if (column === 'createdOn') row.createdOn = /^\d+$/.test(value) ? Number(value) : Date.parse(value) + else if (column === 'birthDate') info.birthDate = /^\d+$/.test(value) ? Number(value) : Date.parse(value) + else if (ROW_FIELDS.has(column)) (row as Record)[column] = value + else info[column] = value + }) + if (Object.keys(info).length > 0) row.userGeneralInfo = info + return row + }) +} + +/** Split into requests of at most MAX_ROWS_PER_REQUEST users. */ +export function batches(rows: T[], size = MAX_ROWS_PER_REQUEST): T[][] { + const out: T[][] = [] + for (let i = 0; i < rows.length; i += size) out.push(rows.slice(i, i + size)) + return out +} + +/** One report for several requests: counts added, row numbers continued. */ +export function mergeReports(reports: Array<{report: ImportReport; offset: number}>): ImportReport { + const total: ImportReport = {dryRun: true, received: 0, imported: 0, valid: 0, existing: 0, invalid: 0, failed: 0, rows: []} + for (const {report, offset} of reports) { + total.dryRun = report.dryRun + for (const key of ['received', 'imported', 'valid', 'existing', 'invalid', 'failed'] as const) total[key] += report[key] ?? 0 + total.rows.push(...(report.rows ?? []).map((r) => ({...r, row: r.row + offset}))) + } + + return total +} + +/** The request body for a dry-run print: hashes and salts masked. */ +export function redactRows(body: unknown): unknown { + const b = body as {users?: ImportRow[]} + if (!b?.users) return body + return {...b, users: b.users.map((u) => ({...u, ...(u.passwordHash ? {passwordHash: '***'} : {}), ...(u.passwordSalt ? {passwordSalt: '***'} : {})}))} +} + +/** The per-row table. */ +export function reportTable(report: ImportReport): string { + const lines = report.rows.map((r) => { + const errors = (r.errors ?? []).map((e) => `${e.code ? `${e.code} ` : ''}${e.message}`).join('; ') + return [String(r.row).padStart(5), r.outcome.padEnd(8), (r.login ?? '').padEnd(32), r.userId ?? '', errors].join(' ').trimEnd() + }) + return [' row outcome login user / errors', ...lines].join('\n') +} diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index e833301..08a35f7 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -3502,6 +3502,19 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "users get", "supportsDryRun": false, }, + { + "args": [ + "file", + ], + "destructive": false, + "flags": [ + "dry-run:boolean", + "apply:boolean", + "ignore-default-roles:boolean", + ], + "id": "users import", + "supportsDryRun": true, + }, { "args": [ "email", @@ -3532,6 +3545,26 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "users policies", "supportsDryRun": false, }, + { + "args": [], + "destructive": false, + "flags": [ + "dry-run:boolean", + "apply:boolean", + ], + "id": "users repair-logins", + "supportsDryRun": true, + }, + { + "args": [], + "destructive": false, + "flags": [ + "dry-run:boolean", + "apply:boolean", + ], + "id": "users repair-roles", + "supportsDryRun": true, + }, { "args": [], "destructive": false, diff --git a/tests/users-admin.test.ts b/tests/users-admin.test.ts new file mode 100644 index 0000000..d1dbd20 --- /dev/null +++ b/tests/users-admin.test.ts @@ -0,0 +1,136 @@ +import {mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' + +import {parseCsv, readImportFile} from '../src/lib/user-import.js' +import {cli, makeHome, parseSingleJson, startFakeGateway, type FakeGateway} from './_cli.js' + +/** + * `users import` (N7), `users repair-roles` (N20), `users repair-logins`: new + * gateway routes the installed SDK has no typed method for yet, sent through + * BaseCommand.hubRequest. The built CLI runs against a fake gateway. + */ + +const IMPORT = '/v3/membership/users/import' +const report = (n: number, dryRun = true) => ({ + result: {dryRun, received: n, imported: dryRun ? 0 : n, valid: dryRun ? n : 0, existing: 0, invalid: 0, failed: 0, rows: [{row: 1, login: 'a@example.com', outcome: dryRun ? 'valid' : 'imported'}]}, +}) + +let gateway: FakeGateway +let home: string +const dir = mkdtempSync(join(tmpdir(), 'norbix-users-admin-')) +const user = (i: number) => ({email: `u${i}@example.com`, hashFormat: 'servicestack-saltedhash', passwordHash: 'SECRET-HASH', passwordSalt: 'SALT'}) +const FILE = join(dir, 'users.json') +writeFileSync(FILE, JSON.stringify([user(1), user(2)])) +const BIG = join(dir, 'big.json') +writeFileSync(BIG, JSON.stringify({users: Array.from({length: 1500}, (_, i) => user(i))})) +const ARGS = ['--profile', 'x', '--json'] +const NO_ENV = {NORBIX_ENV: undefined} + +beforeAll(async () => { + gateway = await startFakeGateway({ + [IMPORT]: report(2), + '/v3/membership/users/repair-roles': {result: {dryRun: true, contactsWithoutRoles: 3, contactsToRepair: 2}}, + '/v3/membership/users/repair-logins': {result: {dryRun: true, loginsWithoutProject: 1}}, + }) + home = makeHome(gateway.port) +}) + +afterAll(async () => { + await gateway.close() +}) + +beforeEach(() => { + gateway.hits.length = 0 +}) + +const bodies = () => gateway.hits.map((h) => JSON.parse(h.body) as {dryRun: boolean; users?: unknown[]}) + +describe('norbix users import', () => { + it('checks by default: one POST with dryRun: true and the rows as given; no env needed for a check', async () => { + const r = await cli(home, ['users', 'import', FILE, ...ARGS], NO_ENV) + expect(r.code).toBe(0) + expect(gateway.hits.map((h) => `${h.method} ${h.url}`)).toEqual([`POST ${IMPORT}`]) + expect(bodies()[0]).toEqual({dryRun: true, ignoreDefaultRoles: false, users: [user(1), user(2)]}) + expect(parseSingleJson(r.stdout)).toMatchObject({dryRun: true, received: 2, valid: 2}) + }) + + it('--apply writes, and needs a named environment (the env rule for changes)', async () => { + const refused = await cli(home, ['users', 'import', FILE, '--apply', ...ARGS], NO_ENV) + expect(refused.code).toBe(2) + expect((parseSingleJson(refused.stdout) as {error: {code: string}}).error.code).toBe('ENV_REQUIRED') + expect(gateway.hits).toHaveLength(0) + + const ok = await cli(home, ['users', 'import', FILE, '--apply', '--env', 'TEST', '--ignore-default-roles', ...ARGS], NO_ENV) + expect(ok.code).toBe(0) + expect(bodies()[0]).toMatchObject({dryRun: false, ignoreDefaultRoles: true}) + }) + + it('more than 1000 users: requests of 1000, row numbers continue across them', async () => { + const r = await cli(home, ['users', 'import', BIG, ...ARGS], NO_ENV) + expect(r.code).toBe(0) + expect(bodies().map((b) => b.users?.length)).toEqual([1000, 500]) + const merged = parseSingleJson(r.stdout) as {received: number; rows: Array<{row: number}>} + expect(merged.received).toBe(4) // the fake answers 2 per request + expect(merged.rows.map((x) => x.row)).toEqual([1, 1001]) + }) + + it('--dry-run sends nothing and never prints a hash or salt', async () => { + const r = await cli(home, ['users', 'import', FILE, '--dry-run', ...ARGS], NO_ENV) + expect(r.code).toBe(0) + expect(gateway.hits).toHaveLength(0) + expect(r.stdout).not.toContain('SECRET-HASH') + expect(r.stdout).not.toContain('SALT"') + expect(parseSingleJson(r.stdout)).toMatchObject({dryRun: true, http: {method: 'POST'}}) + }) + + it('the text output is the per-row table and the counts', async () => { + const r = await cli(home, ['users', 'import', FILE, '--profile', 'x'], NO_ENV) + expect(r.stdout).toContain('row outcome') + expect(r.stdout).toContain('valid') + expect(r.stdout).toContain('Checked — nothing written. 2 received') + }) +}) + +describe('reading the file', () => { + it('CSV: header row, quoted fields, roles split on ";", profile columns into userGeneralInfo', () => { + const csv = join(dir, 'users.csv') + writeFileSync(csv, 'email,firstName,roles,hashFormat,passwordHash,passwordSalt,createdOn\r\na@example.com,"Ona, Jr.",Admin;Editor,servicestack-saltedhash,H==,S==,1700000000000\r\n') + expect(readImportFile(csv)).toEqual([ + {email: 'a@example.com', userGeneralInfo: {firstName: 'Ona, Jr.'}, roles: ['Admin', 'Editor'], hashFormat: 'servicestack-saltedhash', passwordHash: 'H==', passwordSalt: 'S==', createdOn: 1700000000000}, + ]) + expect(parseCsv('a,"b ""q"""\n')).toEqual([['a', 'b "q"']]) + }) + + it('an unknown CSV column, or JSON that is not a list of users, is a usage error', () => { + const bad = join(dir, 'bad.csv') + writeFileSync(bad, 'email,password\na,b\n') + expect(() => readImportFile(bad)).toThrow(/Unknown CSV column: password/) + const notList = join(dir, 'bad.json') + writeFileSync(notList, '{"x":1}') + expect(() => readImportFile(notList)).toThrow(/must be a JSON array of users/) + }) +}) + +describe('norbix users repair-roles / repair-logins', () => { + it('count by default (dryRun: true), --apply repairs and needs a named environment', async () => { + for (const kind of ['roles', 'logins']) { + gateway.hits.length = 0 + expect((await cli(home, ['users', `repair-${kind}`, ...ARGS], NO_ENV)).code).toBe(0) + expect((await cli(home, ['users', `repair-${kind}`, '--apply', ...ARGS], NO_ENV)).code).toBe(2) + expect((await cli(home, ['users', `repair-${kind}`, '--apply', '--env', 'PROD', ...ARGS], NO_ENV)).code).toBe(0) + expect(gateway.hits.map((h) => `${h.url} ${h.body}`)).toEqual([ + `/v3/membership/users/repair-${kind} {"dryRun":true}`, + `/v3/membership/users/repair-${kind} {"dryRun":false}`, + ]) + } + }) + + it('prints the counts the Hub returned', async () => { + const r = await cli(home, ['users', 'repair-roles', '--profile', 'x'], NO_ENV) + expect(r.stdout).toMatch(/contactsWithoutRoles\s+3/) + expect(r.stdout).toContain('Counted — nothing written.') + }) +}) From e41577263ac15cf57fd0c54f3ddc93f73f2940e2 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 14 Oct 2026 13:45:40 +0300 Subject: [PATCH 19/19] fix(ai): the repo's Norbix joins the intro line of the agent block (N3 + ai context) With feat/ai-context's project-context bullet and the N3 repo-target bullet the CLAUDE.md / AGENTS.md block grew to 13 lines; the block is kept at 12. The target sentence now ends the intro line instead of being a bullet of its own; the skill keeps it as its own paragraph. Co-Authored-By: Claude Opus 5.5 --- src/lib/agent-files.ts | 4 ++-- tests/ai-init.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts index bf016dc..d1af1e2 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -44,9 +44,9 @@ export function recipeBlock(target?: RepoTarget): string { MARKER_START, '## Norbix backend — use the `norbix` CLI', '', - 'This project uses Norbix (database, users, files, templates, campaigns, scheduler, logs). Talk to it with the `norbix` CLI from the shell; never edit the backend by hand.', + // The repo's target joins the intro line: the block stays at most 12 lines. + ['This project uses Norbix (database, users, files, templates, campaigns, scheduler, logs). Talk to it with the `norbix` CLI from the shell; never edit the backend by hand.', ...targetLine(target).map((l) => l.slice(2))].join(' '), '', - ...targetLine(target), '- Start with `norbix whoami --json`. `"auth": "none"` there (or exit code 4 from any other command) means not signed in. Sign in yourself in two steps: run `norbix login --no-browser --json`, show the user the `verificationUriComplete` link and the `userCode` (they open it, check the code and pick the roles you get — an AI service user, removable under Account → AI service users), then run `norbix login --wait --json` until it exits 0. Exit 4 with `AUTHORIZATION_PENDING` means not approved yet: run `--wait` again; `ACCESS_DENIED` / `EXPIRED_TOKEN`: start over. Their Norbix is not norbix.ai? Add `--host ` to both steps.', '- Pass `--json` to every command whose output you read: stdout is then exactly one JSON document (a result, or `{"error": {code, message, exit, fieldErrors, hint, docs}}`).', '- Project context (your projects, environments, regions, modules, integrations, CORS, languages — never secrets): read `.norbix-ai/context.md` when it is there; run `norbix ai context --write` at the start of a task and again after your context was compressed or summarized (`norbix login` refreshes it too).', diff --git a/tests/ai-init.test.ts b/tests/ai-init.test.ts index 4fca2cc..dd8fd1b 100644 --- a/tests/ai-init.test.ts +++ b/tests/ai-init.test.ts @@ -144,7 +144,7 @@ describe('ai init saves the repo\'s Norbix: .norbix/project.json (N3)', () => { expect(r.code).toBe(0) expect(JSON.parse(readFileSync(join(project, '.norbix/project.json'), 'utf8'))).toEqual({host, projectId: 'pr_HappyTummy', env: 'TEST'}) const claude = readFileSync(join(project, 'CLAUDE.md'), 'utf8') - expect(claude).toContain(`- This repo's Norbix: host \`${host}\`, project \`pr_HappyTummy\`, environment \`TEST\` — saved in \`.norbix/project.json\``) + expect(claude).toContain(`never edit the backend by hand. This repo's Norbix: host \`${host}\`, project \`pr_HappyTummy\`, environment \`TEST\` — saved in \`.norbix/project.json\``) expect(claude.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(12) expect(readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8')).toContain(`This repo's Norbix: host \`${host}\``)