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 efe9274..77f2c06 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,13 @@ 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) | -| `norbix env list/use/delete` | Manage project environments | +| `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) | | `norbix apikeys list/create/regenerate/revoke` | Service-user API keys for scripts, CI, SDKs and agents | @@ -64,6 +66,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 @@ -131,6 +134,45 @@ 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. + +**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 @@ -323,6 +365,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): @@ -344,6 +401,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. @@ -365,9 +431,16 @@ 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 (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 norbix config set files_integration_id @@ -375,8 +448,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/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/docs/database.md b/docs/database.md index 467fe3a..0cf8c0a 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` | @@ -165,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 ``` @@ -239,19 +244,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 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 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}` | + +`` 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`. @@ -268,8 +278,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 ``` @@ -282,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 @@ -295,7 +307,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/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/package.json b/package.json index ebff00d..894f0fb 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" @@ -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. 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 5db6d59..3c83de3 100644 --- a/src/base.ts +++ b/src/base.ts @@ -1,8 +1,11 @@ +import {existsSync} from 'node:fs' +import {join} from 'node:path' + 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, @@ -29,6 +32,8 @@ import { type Profile, 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' @@ -110,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. */ @@ -172,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) { @@ -213,7 +230,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', }), @@ -264,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 @@ -316,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 } @@ -365,6 +385,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. */ @@ -427,22 +500,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', ) @@ -453,11 +535,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. @@ -489,9 +571,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 @@ -532,7 +623,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 ?? @@ -558,7 +658,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, @@ -602,6 +704,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 +723,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 +732,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 @@ -769,6 +916,95 @@ 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)) + } + + /** + * 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 + * 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 @@ -879,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. @@ -903,6 +1143,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/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/ai/init.ts b/src/commands/ai/init.ts index a8decd4..8707155 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,13 @@ 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) + // 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)) const refused = planned.filter((f) => { if (f.action === 'create' && exists(f.path) && !flags.force) return true @@ -68,9 +89,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 +104,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/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/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/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/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/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/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/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/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/env/create.ts b/src/commands/env/create.ts new file mode 100644 index 0000000..dc11a32 --- /dev/null +++ b/src/commands/env/create.ts @@ -0,0 +1,103 @@ +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 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.` + + 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/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/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/commands/login.ts b/src/commands/login.ts index eaba801..c994eb0 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'] @@ -252,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, @@ -273,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) @@ -348,7 +394,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/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/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.` 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/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/commands/whoami.ts b/src/commands/whoami.ts index b11c95f..c018714 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 — reads use PROD; changes are refused until you name one: --env, or norbix env use )', + 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/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/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..d1af1e2 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -18,18 +18,40 @@ 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.', + // 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(' '), '', '- 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).', + '- 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.', @@ -39,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', @@ -50,15 +72,17 @@ 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):', ' - `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.', + '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', @@ -80,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)] } } @@ -104,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/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/src/lib/files.ts b/src/lib/files.ts index 6778065..50c77c2 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -1,13 +1,66 @@ +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` 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'}` + 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 === 0) { + throw new CliError({ + exit: EXIT.NOT_FOUND, + code: 'NO_FILES_INTEGRATION', + 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.`, + docs: 'norbix integrations files --help', + }) + } + + throw usageError( + `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', + ) +} + /** Resolve the files integration ID: flag > env (via oclif) > config file. */ export function resolveIntegration( flagValue: string | undefined, 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/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/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/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/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 0000000..1f16416 --- /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.replaceAll('/', '%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/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/test/files.test.ts b/test/files.test.ts index 62e68e0..4e343ee 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 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 (looked up for 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).toBe('No default files integration in environment PROD.') + 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 (looked up for 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 (looked up for 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 () => { diff --git a/test/login.test.ts b/test/login.test.ts index 91726df..f014ff7 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'), @@ -297,7 +300,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', ) }) @@ -356,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') diff --git a/test/setup.ts b/test/setup.ts index 785a243..7ef2a0d 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -22,6 +22,20 @@ 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' + +// 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' + +// 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/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index f55b20e..08a35f7 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, @@ -87,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, @@ -1102,6 +1113,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", @@ -1834,6 +1861,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", @@ -3459,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", @@ -3489,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/_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-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..dd8fd1b 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') @@ -100,6 +102,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 +112,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) @@ -130,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(`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}\``) + + // 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, 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, 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, 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, NORBIX_ENV: 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/db-routes.test.ts b/tests/db-routes.test.ts index f9e8bb1..a5b6ca8 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' @@ -130,6 +131,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 +155,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 +179,210 @@ 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('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 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 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({ @@ -439,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 () => { 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) + }) +}) diff --git a/tests/env-policy.test.ts b/tests/env-policy.test.ts new file mode 100644 index 0000000..ab7eb61 --- /dev/null +++ b/tests/env-policy.test.ts @@ -0,0 +1,115 @@ +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) + // 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 () => { + 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') + }) +}) 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/) + }) +}) 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) + }) +}) 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.') + }) +})