diff --git a/README.md b/README.md index b321bfe..f4fe3e5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ norbix scheduler list | `norbix files list/info/get-by-id/upload/download/sign/delete/publish/unpublish` | Upload, download, manage and publish files | | `norbix files integrations test ` | Check that a files integration really works (live upload/read/list/delete probe) | | `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) | -| `norbix env list/use/delete` | Manage project environments (see *Environments* below) | +| `norbix env list/use/delete/promote/rollback`, `norbix env built-ins repair` | Manage project environments and move content TEST → PROD (see *Environments* below) | | `norbix logs list/trail` | Read project logs, follow one correlation ID | | `norbix scheduler list/get/save/enable/disable/delete` | Scheduler tasks — a cron that sends an email campaign; every scheduler endpoint, see [docs/scheduler.md](docs/scheduler.md) | | `norbix apikeys list/create/regenerate/revoke` | Service-user API keys for scripts, CI, SDKs and agents | @@ -269,6 +269,38 @@ for `--env PROD`), `NORBIX_ENV`, or a default saved with `norbix env use TEST` exit 2 `ENV_REQUIRED` and lists the environments; nothing is sent. Apps that use the API or an SDK directly are not affected: with no env they work in PROD. +#### Moving TEST to PROD (promotion) + +Build schemas, taxonomies and terms, templates, footers, signatures and +triggers in TEST, then move them up — never re-create them in PROD by hand +(the copies would get other ids and break triggers and term links). + +```sh +norbix env promote --from TEST --to PROD # preview (the default): nothing changes +norbix env promote --from TEST --to PROD --yes # promote; prints the rollback anchor +norbix env rollback --to PROD --version 279 # preview the undo; add --yes to run it +norbix env built-ins repair # built-in templates under PROD's ids; --yes to run +``` + +The preview shows a summary, every item by status (added / changed / +unchanged / deleted with its record count / kept), one line per source +integration with the choice that applies (`!` marks a real e-mail / SMS / +push sender) and what is never promoted (records, users, files, …). +By default Fake integrations are copied and everything else is left out; +change that per integration with `--seed `, `--skip ` or +`--map =`. A real run that would hide records stops with exit 3 +until you add `--confirm-record-loss`. For these three commands `--dry-run` +means the Hub's preview (the request is sent with `dryRun: true`). `--json` +prints the Hub's answer as it is. + +When a command creates or changes such content directly in an environment that +has a lower one (`db schema create --env PROD` while TEST exists), it still +runs, and stderr says: + +```text +Note: PROD is promoted from TEST — make the change in TEST and run `norbix env promote --from TEST --to PROD` +``` + ## JSON output Every command supports `--json` and prints clean JSON on stdout, so you can diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 646fddf..570fce3 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -68,8 +68,30 @@ A project always has PROD and may have more (TEST, STAGING, …). Check first: on stderr. Read that line. - Account commands (no project) and `norbix env …` are not checked. -The list of environments is cached for 5 minutes per project; `env list / -use / delete` and an "environment does not exist" error refresh it. +The list of environments (and the promotion ladder, `promotionOrder`) is +cached for 5 minutes per project; `env list / use / delete` and an +"environment does not exist" error refresh it. + +### Environments: build in TEST, promote to PROD + +Never build PROD by hand when the project has a lower env. Schemas, +taxonomies, terms, templates, footers, signatures and triggers are made in TEST +and promoted: + +1. `norbix env promote --from TEST --to PROD --json` — a preview (the + default), nothing changes. Show the user `summary`, `items` (deleted ones + with `recordCount`), `integrations` (`action`, `warning`, `realSender`) and + `notPromoted`. +2. After the user agreed: the same command with `--yes` + (`--seed / --skip / --map id=targetId` only for integrations the user + decided; `--confirm-record-loss` only when `requiresConfirmation` is true and + the user accepts it — exit 3 `CM-ERRORS-ENVIRONMENTS-013` means it was missing). +3. Keep `fromVersion`: `norbix env rollback --to PROD --version ` + (preview, then `--yes`) undoes it. + +A content command run straight in PROD while TEST exists prints +`Note: PROD is promoted from TEST — …` on stderr: make the change in TEST and +promote instead. ## 2. Always `--json` diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 5455de1..b4dc824 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -137,6 +137,10 @@ command that makes several calls (`files upload`) shows the first. `insert`, `update --id`, `replace`, `archive`, `unarchive`, `clone`, `enable`, `invite`, `unblock`, `upload`, `publish`, `unpublish`, `config set / unset`, `env use`, `logout`, `raw`. +- **Preview by default** (`env promote`, `env rollback`, `env built-ins repair`): + without `--yes` they ask the Hub for the plan and change nothing — here + `--dry-run` is that preview (the request is sent with `dryRun: true`), not + "send nothing". `--yes` runs them for real. - **Read-only**: everything else; no `--yes`, no `--dry-run`. ## 6. Shared code diff --git a/docs/tasks/promotion-phase2.md b/docs/tasks/promotion-phase2.md new file mode 100644 index 0000000..04048fd --- /dev/null +++ b/docs/tasks/promotion-phase2.md @@ -0,0 +1,78 @@ +# Environment promotion — phase 2 (CLI): `norbix env promote / rollback / built-ins repair` +This file: /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/docs/tasks/promotion-phase2.md (branch feat/promotion-phase2) +Gateway side (shipped 57b5f4bad): /Users/djovaisas/Projects/norbix/gateway/docs/tasks/promotion-phase2.md (branch refactoringV2) +Phase 1 contract: /Users/djovaisas/Projects/norbix/gateway/docs/tasks/promotion-v2.md (branch refactoringV2) + +## Goal +Give developers and agents first-class promotion commands with a readable preview (dry run by default), a hint when +they change content directly in a higher environment, and `norbix ai init` guidance that says "build in TEST, preview, +promote, roll back if needed — never build PROD by hand". + +Not in scope: team-member update / remove commands (another agent; may come in a later message); regenerating +`@norbix.ai/ts` (see decision below); the dashboard. + +## Plan +1. [done] docs(cli:env:promotion): this task file +2. [done] feat(cli:env:promotion): `norbix env promote --from TEST --to PROD` — preview by default (`--dry-run` says it explicitly), real run with `--yes`; `--confirm-record-loss`; `--seed / --skip / --map id=targetId`; human preview (summary, items by status, integrations with choices and warnings, not-promoted list); `--json` prints the raw answer; record-loss refusal explained with the plan +3. [done] feat(cli:env:promotion): `norbix env rollback --to PROD --version N` and `norbix env built-ins repair` (same preview / `--yes` rule) +4. [done] feat(cli:env:promotion-hint): a content write (schema, taxonomy, term, template, trigger, footer, signature) into an env that has a lower one prints on stderr: "PROD is promoted from TEST — make the change in TEST and run `norbix env promote --from TEST --to PROD`"; the env ladder (`promotionOrder`) is cached with the env list +5. [done] feat(cli:ai:init): the skill and the CLAUDE.md / AGENTS.md block get a short "Environments" section +6. [done] test(cli): tests for the three commands (fixture = the phase 1 contract example), the hint, `ai init`, the old `hub account environment promote --body` path; `tsc --noEmit` clean, `npm test` 39 files, 1067/1067 passed +7. [done] docs(cli): README + docs/project.md command table +8. [done] feat(cli:env:promotion): the integrations table shows the id in the target when it differs (`targetId`, gateway 44548a144: a seeded Files integration gets its own id; a map names the target integration) +9. [done] release(cli): pull request merged by nbx-ship (rebase); semantic-release publishes the minor from CI (trusted publishing) — PR and version in the final report + +## Decisions +- decision(cli:sdk-types): the CLI does not wait for a regenerated `@norbix.ai/ts`. 4.29.0 knows `promoteEnvironment` / `rollbackPromotion` but not `confirmRecordLoss`, `integrationChoices`, rollback `dryRun`, the v2 result fields, or the built-in repair endpoint. Regenerating needs a running Hub (`sync-types.mjs`) and a separate SDK release; the transport sends the whole request object, so the CLI builds the body with its own types (`src/lib/promotion.ts`) and calls the repair route through the SDK transport of the environments module (same auth, refresh, env rule, retry). Recorded under Findings for the SDK owners. +- decision(cli:env:dry-run): for these three commands `--dry-run` is the server's preview (the request is sent with `dryRun: true`, nothing changes) — not the usual "send nothing". It is also the default. A real run needs `--yes`. This is what an agent taught "dry run first" needs: the plan, not the HTTP request. +- decision(cli:env:ladder): which env feeds which comes from `promotionOrder` in the env list (gateway 57b5f4bad); it is cached with the env names (5 minutes). An older gateway sends no ladder: then PROD's lower env is TEST if the project has one, else the only other env; other envs get no hint. +- decision(cli:env:hint): an allowlist of command ids, printed on stderr only (stdout stays one JSON document), also on a `--dry-run` (that is when an agent should read it). Record commands never hint: records are not promoted. + +## Changes +| file (absolute, branch feat/promotion-phase2) | what changed | step | +|------|--------------|------| +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/docs/tasks/promotion-phase2.md | this file | 1 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/lib/promotion.ts:277 | new: v2 types, `--seed/--skip/--map` parsing, the human preview, repair rendering, the hint allowlist (`:360`), `lowerEnvOf` (`:386`) | 2, 3, 4 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/commands/env/promote.ts | new command | 2 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/package.json | `env` topic text, `env built-ins` subtopic | 2 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/commands/env/rollback.ts | new command | 3 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/commands/env/built-ins/repair.ts | new command (repair route through the SDK transport) | 3 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/base.ts:696, :709, :839 | hint decided with the env rule, printed once on stderr after a successful call or a `--dry-run` report | 4 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/lib/hosts.ts:466 | env cache keeps `promotionOrder`; `cachedPromotionOrder` | 4 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/lib/env-rule.ts | `promotionOrderOf` | 4 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/src/lib/agent-files.ts:68 | "Environments" bullet in the block, "Environments — build in TEST, promote to PROD" section and an example in the skill | 5 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/tests/ai-init.test.ts | block bullets (+1), line limits 12→13 (block) and 40→50 (skill), a new test for the Environments guidance | 5 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/tests/env-promote.test.ts (+ `__snapshots__/env-promote.test.ts.snap`) | new: 14 tests | 6 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/tests/__snapshots__/schema.test.ts.snap | three new commands in `norbix schema` | 6 | +| /Users/djovaisas/Projects/norbix/worktrees/cli/feat/promotion-phase2/README.md, docs/AGENTS.md, docs/agent-contract.md | "Moving TEST to PROD"; preview-by-default commands in the contract | 7 | + +## Findings +- gen(sdk-ts:types): `@norbix.ai/ts` 4.29.0 lags the gateway: no `confirmRecordLoss` / `integrationChoices` on `PromoteEnvironmentRequest`, no `dryRun` / `confirmRecordLoss` on `RollbackPromotionRequest`, only the old fields on `PromotionResultDto`, no built-in repair method, no `promotionOrder` on `ProjectEnvironmentsDto`. The CLI works around it with local types — open (next types regen of the SDKs) +```ts +// node_modules/@norbix.ai/ts/dist/types/hub2.dtos.d.ts:8026-8033 (4.29.0) + class PromoteEnvironmentRequest extends CodeMashRequestBase implements IReturn { + sourceEnv: string; + targetEnv: string; + dryRun: boolean; // <-- missing: confirmRecordLoss, integrationChoices +``` +```ts +// src/commands/env/promote.ts (branch feat/promotion-phase2) — the cast the CLI needs until then + return (await client.hub.account.promoteEnvironment(body(dryRun) as never)) as unknown as {item?: PromotionResult} +``` +- gap(cli:hint): `norbix hub ` (the generic path) and `norbix raw` never print the promotion hint — only the named content commands do — open +- gap(cli:hint): there is no `db taxonomy create` command in the CLI (taxonomies are made with `norbix hub database save-database-taxonomy`), so a taxonomy write gets no hint from the CLI; terms do (`db term create/update`) — open +- gap(cli:hint): on `--dry-run` the CLI sends nothing, so it reads the env list only from the 5-minute cache; with a cold cache a dry run prints no hint (the real run does) — open, by design +- chore(cli:git): `nbx-doctor` WARN — the main checkout /Users/djovaisas/Projects/norbix/sdks/cli is on `fix/sdk-1.3-and-deps` with an untracked `docs/tasks/agent-native-cli.md`; not mine, untouched + +## Acceptance #5 — "set up PROD like TEST" leads to a promotion with a preview first +- In a fresh folder `/tmp/happy-tummy-accept.yRfs` (scratch, outside every repo): `norbix ai init` (this branch's build) wrote `.claude/skills/norbix/SKILL.md` and `CLAUDE.md`. Both carry the Environments rules (`grep -n promot` → CLAUDE.md:11, SKILL.md:3, 23, 25, 28, 29, 30, 32, 45). Log: `~/scratch/promotion-phase2/accept-ai-init.txt`, `accept-grep.txt`. +- A headless agent in that folder (`claude -p "… Set up PROD like TEST. Do not run any command now — list the exact commands …" --strict-mcp-config --setting-sources project,local --disallowedTools "Bash Edit Write WebFetch WebSearch"`) answered, in order: `norbix whoami --json` → `norbix env promote --help` → **`norbix env promote --from TEST --to PROD --json` (preview)**, show summary / items with recordCount / integrations with realSender / notPromoted, ask about each integration and record loss → **`norbix env promote --from TEST --to PROD --yes --json [--seed …] [--map …] [--confirm-record-loss]` only after you agree**, keep `fromVersion` → rollback `norbix env rollback --to PROD --version ` (preview, then `--yes`); "I never edit PROD directly". Full answer: `~/scratch/promotion-phase2/accept-agent.txt`. +- MCP side (gateway 57b5f4bad): `promote_environment` says "USE IT whenever the user wants PROD … to match TEST … ALWAYS two calls: (1) dryRun: true …; (2) only after the user agreed … dryRun: false"; a `dryRun: true` call needs no confirmation and no env argument (L3 test `Promote_Is_An_Environment_Tool_Whose_Dry_Run_Is_A_Preview`); the Norbix assistant prompt routes "set up PROD like TEST" to a promotion (prompt v5). + +## Rejected / moved out +- decision(cli:sdk-types): regenerate `@norbix.ai/ts` first and wait for its release — rejected for this change: it needs a running Hub and a separate SDK release; the CLI's local types cover the gap — new ticket: none (listed under Findings for the next regen) + +## Needs you + +## Open questions +- none diff --git a/package.json b/package.json index b30969f..f8fa19e 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,12 @@ } }, "env": { - "description": "Manage project environments (PROD, TEST, ...)" + "description": "Project environments (PROD, TEST, ...): list, pick a default, promote TEST → PROD, roll back, repair built-ins", + "subtopics": { + "built-ins": { + "description": "PROD's built-in templates, footer and signature in every environment" + } + } }, "files": { "description": "Upload, download and manage files", diff --git a/src/base.ts b/src/base.ts index eb13f71..22b84c0 100644 --- a/src/base.ts +++ b/src/base.ts @@ -2,12 +2,13 @@ import {Norbix} from '@norbix.ai/ts' import {Command, Flags, ux} from '@oclif/core' import {CliError, formatErrorText, toEnvelope, usageError} from './lib/cli-error.js' -import {checkEnv, envsOf, type EnvSource} from './lib/env-rule.js' +import {checkEnv, envsOf, promotionOrderOf, type EnvSource} from './lib/env-rule.js' import {EXIT} from './lib/exit-codes.js' import { DEFAULT_HOST, cachedHost, cachedProjectEnvs, + cachedPromotionOrder, cachedProjectRegion, forgetProjectEnvs, saveProjectEnvs, @@ -34,6 +35,7 @@ import { type Session, } from './lib/profiles.js' import {SessionRefresher} from './lib/session-auth.js' +import {PROMOTED_CONTENT_COMMANDS, lowerEnvOf, promotionHint} from './lib/promotion.js' import {readStore, type StoredConfig} from './lib/store.js' /** One link of the SDK's transport middleware chain. */ @@ -658,7 +660,7 @@ export abstract class BaseCommand extends Command { /** Remember a freshly read environment list of the project (`env list`). */ protected rememberEnvs(ctx: ResolvedContext, response: unknown): void { const envs = envsOf(response) - if (ctx.projectId && envs) saveProjectEnvs(ctx.hubKey, ctx.projectId, envs) + if (ctx.projectId && envs) saveProjectEnvs(ctx.hubKey, ctx.projectId, envs, Date.now(), promotionOrderOf(response)) } /** The env rule ran once in this process (one check per command). */ @@ -674,10 +676,13 @@ export abstract class BaseCommand extends Command { this.envChecked ??= (async () => { if (!ctx.projectId) return let envs = cachedProjectEnvs(ctx.hubKey, ctx.projectId) + let order = envs ? cachedPromotionOrder(ctx.hubKey, ctx.projectId) : undefined if (!envs && !dryRun) { try { - envs = envsOf(await client().hub.environments.list()) - if (envs) saveProjectEnvs(ctx.hubKey, ctx.projectId, envs) + const answer = await client().hub.environments.list() + envs = envsOf(answer) + order = promotionOrderOf(answer) + if (envs) saveProjectEnvs(ctx.hubKey, ctx.projectId, envs, Date.now(), order) } catch (error) { // The sign-in ended, the Hub is down: let the command itself report it. const raw = (error as {raw?: unknown}).raw @@ -688,10 +693,33 @@ export abstract class BaseCommand extends Command { const verdict = checkEnv(dryRun && !envs ? ['PROD'] : envs, {env: ctx.env, source: ctx.envSource}) if (verdict.notice) process.stderr.write(verdict.notice + '\n') + this.promotionHintLine = this.promotionHintFor(ctx.env, envs, order) })() return this.envChecked } + /** The promotion hint of this run (a content write into an env with a lower one), printed once after the call. */ + private promotionHintLine?: string + + /** + * "PROD is promoted from TEST — make the change in TEST and run `norbix env + * promote --from TEST --to PROD`" for a command that writes promoted content + * (src/lib/promotion.ts) into an env that has a lower one; else undefined. + */ + private promotionHintFor(env: string | undefined, envs: string[] | undefined, order: string[] | undefined): string | undefined { + if (!this.id || !PROMOTED_CONTENT_COMMANDS.has(this.id) || !envs || envs.length < 2) return undefined + const target = (env ?? 'PROD').toUpperCase() + const lower = lowerEnvOf(target, order, envs) + return lower ? promotionHint(target, lower) : undefined + } + + /** Print the promotion hint on stderr (stdout stays one JSON document), once. */ + private printPromotionHint(): void { + if (!this.promotionHintLine) return + process.stderr.write(`Note: ${this.promotionHintLine}\n`) + this.promotionHintLine = undefined + } + /** One warning per deprecated setting and run, on stderr (stdout stays one JSON document). */ private warnDeprecated(names: string[], origin: string): void { for (const name of names) { @@ -807,7 +835,9 @@ export abstract class BaseCommand extends Command { if (!noProject && opts.account !== true) { middleware.push(async (call) => { if (!isEnvManagementCall(call.url)) await this.checkProjectEnv(ctx, () => client, Boolean(flags['dry-run'])) - return call.next() + const response = await call.next() + if (response.ok) this.printPromotionHint() + return response }) } @@ -981,6 +1011,7 @@ export abstract class BaseCommand extends Command { } if (this.jsonEnabled()) this.logJson(report) else this.printDryRun(report) + this.printPromotionHint() // The Hub said the input is wrong: the report is printed, the exit code says "would fail" (6). if (this.dryRunValidation?.valid === false) { const invalid = new Error('dry run: the Hub rejected the input') as Error & {oclif?: {exit?: number}; skipOclifErrorHandling?: boolean} diff --git a/src/commands/env/built-ins/repair.ts b/src/commands/env/built-ins/repair.ts new file mode 100644 index 0000000..479b843 --- /dev/null +++ b/src/commands/env/built-ins/repair.ts @@ -0,0 +1,50 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base.js' +import {REPAIR_PATH, renderRepair, type BuiltInRepairEnv, type RepairBody} from '../../../lib/promotion.js' + +/** The SDK transport of a module (the repair route is not in the SDK yet; same auth, env rule and retry). */ +type Transport = {send: (call: Record) => Promise} + +export default class EnvBuiltInsRepair extends BaseCommand { + static description = `Give every environment PROD's built-in e-mail / push / SMS templates and the "Default" footer and signature, under PROD's ids + +Run it when a promotion preview shows built-in templates (Password reset, New user verification, …) as added, deleted or matched by name. Idempotent: built-ins already there are "present"; an environment's own copy with the same name takes PROD's id ("realigned", its content is kept); a missing one is copied from PROD ("created"). + +A PREVIEW is the default (--dry-run says it explicitly). A real run needs --yes. Without --target every environment except PROD is repaired.` + + static examples = [ + '<%= config.bin %> env built-ins repair', + '<%= config.bin %> env built-ins repair --target TEST --dry-run --json', + '<%= config.bin %> env built-ins repair --yes', + ] + + static flags = { + target: Flags.string({description: 'Repair only this environment (default: every environment except PROD)'}), + 'dry-run': Flags.boolean({ + description: 'Preview only (the default): nothing changes', + default: false, + exclusive: ['yes'], + }), + yes: Flags.boolean({char: 'y', description: 'Repair for real (without it you get the preview)', default: false}), + } + + async run(): Promise { + const {flags} = await this.parse(EnvBuiltInsRepair) + const dryRun = !flags.yes + const body: RepairBody = {dryRun, ...(flags.target ? {environmentName: flags.target.trim().toUpperCase()} : {})} + const client = this.client({...flags, 'dry-run': false}) + const transport = (client.hub.environments as unknown as {transport: Transport}).transport + const res = (await transport.send({ + target: 'hub', + path: REPAIR_PATH, + method: 'POST', + request: body, + pathParams: [], + scope: 'project', + })) as {items?: BuiltInRepairEnv[]} + + if (!this.jsonEnabled()) this.log(renderRepair(res.items ?? [], dryRun)) + return res + } +} diff --git a/src/commands/env/promote.ts b/src/commands/env/promote.ts new file mode 100644 index 0000000..8f8321a --- /dev/null +++ b/src/commands/env/promote.ts @@ -0,0 +1,145 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' +import {CliError} from '../../lib/cli-error.js' +import {EXIT} from '../../lib/exit-codes.js' +import { + RECORD_LOSS_CODE, + choiceFlags, + parseChoices, + planOfError, + renderPromotion, + type PromoteBody, + type PromotionResult, +} from '../../lib/promotion.js' + +export default class EnvPromote extends BaseCommand { + static description = `Promote one environment into the next one up (e.g. TEST → PROD): schemas, taxonomies and terms, templates, footers, signatures and triggers + +Build and test in the lower environment, then move it up with this command — never re-create the same items in PROD by hand (they would get other ids). + +A PREVIEW is the default: the Hub works out the plan and changes nothing (--dry-run says it explicitly). The preview lists every item by status — added, changed, unchanged, deleted (with the number of records a deleted collection holds), kept — one line per source integration with the choice that applies, and what is never promoted (records, users, files, …). A real run needs --yes. In a terminal you are asked after the preview instead. + +Integrations: by default Fake providers are copied (seed) and everything else is left out (skip) — a database, a secret or a real sender is never copied unless you say so: --seed copies one, --skip leaves one out, --map = points the promoted content at an integration the target already has (same module). + +A real run that would hide records (a collection deleted in the target) refuses without --confirm-record-loss. The answer of a real run names its rollback anchor (fromVersion): \`norbix env rollback --to --version \` undoes it. + +--json prints the Hub's answer as it is (summary, items, integrations, notPromoted, requiresConfirmation, fromVersion, …).` + + static examples = [ + '<%= config.bin %> env promote --from TEST --to PROD', + '<%= config.bin %> env promote --from TEST --to PROD --dry-run --json', + '<%= config.bin %> env promote --from TEST --to PROD --yes', + '<%= config.bin %> env promote --from TEST --to PROD --seed int_6psM5F4vNOOXGEZbb29UHC --yes', + '<%= config.bin %> env promote --from TEST --to PROD --map int_2XKNuL8k2YoJJZF4ffEhpw=int_7aQvYc1Lr0mXb2kP9sTzWd --yes', + '<%= config.bin %> env promote --from TEST --to PROD --yes --confirm-record-loss', + ] + + static flags = { + from: Flags.string({description: 'Environment to promote FROM (the lower one, e.g. TEST)', required: true}), + to: Flags.string({description: 'Environment to promote INTO (the higher one, e.g. PROD)', required: true}), + 'dry-run': Flags.boolean({ + description: 'Preview only (the default): the Hub returns the plan and changes nothing', + default: false, + exclusive: ['yes'], + }), + yes: Flags.boolean({char: 'y', description: 'Run it for real (without it you get the preview)', default: false}), + 'confirm-record-loss': Flags.boolean({ + description: 'Agree that collections deleted in the target hide their records (they come back on rollback)', + default: false, + }), + seed: Flags.string({description: 'Copy this source integration (and its secret) into the target. Repeatable', multiple: true}), + skip: Flags.string({description: 'Leave this source integration out. Repeatable', multiple: true}), + map: Flags.string({ + description: 'Point promoted content at an existing target integration: =. Repeatable', + multiple: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(EnvPromote) + const source = flags.from.trim().toUpperCase() + const target = flags.to.trim().toUpperCase() + if (source === target) { + throw new CliError({ + exit: EXIT.USAGE, + code: 'USAGE_ERROR', + message: `--from and --to are both ${source}.`, + hint: 'Promote from the lower environment into the higher one, e.g. --from TEST --to PROD (`norbix env list`).', + docs: 'norbix env promote --help', + }) + } + + const choices = parseChoices(flags) + const body = (dryRun: boolean): PromoteBody => ({ + sourceEnv: source, + targetEnv: target, + dryRun, + ...(flags['confirm-record-loss'] ? {confirmRecordLoss: true} : {}), + ...(choices.length > 0 ? {integrationChoices: choices} : {}), + }) + const extra = [choiceFlags(choices), flags['confirm-record-loss'] ? '--confirm-record-loss' : ''].filter(Boolean).join(' ') + const rerun = `norbix env promote --from ${source} --to ${target}${extra ? ` ${extra}` : ''}` + // `--dry-run` here is the Hub's preview: the request IS sent (with dryRun: true). + const client = this.client({...flags, 'dry-run': false}) + const call = async (dryRun: boolean) => { + try { + return (await client.hub.account.promoteEnvironment(body(dryRun) as never)) as unknown as {item?: PromotionResult} + } catch (error) { + throw this.explain(error, {source, target, dryRun, rerun}) + } + } + + const askAfterPreview = !flags.yes && !flags['dry-run'] && this.isInteractive() + if (flags.yes) { + const res = await call(false) + this.show(res, {source, target, dryRun: false, rerun}) + // The project's content changed; the env list itself did not. + return res + } + + const preview = await call(true) + this.show(preview, {source, target, dryRun: true, rerun}) + if (!askAfterPreview || preview.item?.summary?.isNoOp || (preview.item?.blockers?.length ?? 0) > 0) return preview + + const {confirm} = await import('@inquirer/prompts') + if (preview.item?.requiresConfirmation && !flags['confirm-record-loss']) { + this.log('Re-run with --confirm-record-loss to apply a plan that hides records.') + return preview + } + + if (!(await confirm({message: `Promote ${source} → ${target} now?`, default: false}))) { + throw new CliError({exit: EXIT.CANCELLED, code: 'CANCELLED', message: 'Cancelled. Nothing was changed.'}) + } + + const res = await call(false) + this.log('') + this.show(res, {source, target, dryRun: false, rerun}) + return res + } + + private show(res: {item?: PromotionResult}, ctx: {source: string; target: string; dryRun: boolean; rerun: string}): void { + if (this.jsonEnabled()) return // oclif prints the returned answer + this.log(renderPromotion(res.item ?? {}, {kind: 'promote', ...ctx})) + } + + /** A refusal that carries the plan: print the plan (text mode), then a clear error. */ + private explain(error: unknown, ctx: {source: string; target: string; dryRun: boolean; rerun: string}): unknown { + const e = error as {raw?: unknown; code?: string; errorCode?: string; message?: string} + const plan = planOfError(e.raw) + const code = e.errorCode ?? e.code + if (plan && !this.jsonEnabled()) this.log(renderPromotion(plan, {kind: 'promote', ...ctx, dryRun: true}) + '\n') + if (code === RECORD_LOSS_CODE) { + return new CliError({ + exit: EXIT.CONFIRMATION_REQUIRED, + code, + message: `The promotion would hide records in ${ctx.target} (collections deleted there still hold records). Nothing was changed.`, + hint: `Check the DELETED items of the preview. If that is intended, re-run with --confirm-record-loss: ${ctx.rerun} --yes --confirm-record-loss`, + docs: 'norbix env promote --help', + ...(plan ? {context: {plan}} : {}), + }) + } + + return error + } +} diff --git a/src/commands/env/rollback.ts b/src/commands/env/rollback.ts new file mode 100644 index 0000000..94c6e1a --- /dev/null +++ b/src/commands/env/rollback.ts @@ -0,0 +1,105 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base.js' +import {CliError} from '../../lib/cli-error.js' +import {EXIT} from '../../lib/exit-codes.js' +import {RECORD_LOSS_CODE, planOfError, renderPromotion, type PromotionResult, type RollbackBody} from '../../lib/promotion.js' + +export default class EnvRollback extends BaseCommand { + static description = `Undo a promotion: the target environment goes back to its content before that promote + +--version is the rollback anchor (fromVersion) the promote printed. The integrations that promote copied into the target are removed (one that became the target's default is kept, with a warning). + +A PREVIEW is the default (--dry-run says it explicitly): nothing changes. A real run needs --yes; in a terminal you are asked after the preview. A rollback that deletes a collection holding records refuses without --confirm-record-loss. --json prints the Hub's answer as it is.` + + static examples = [ + '<%= config.bin %> env rollback --to PROD --version 279', + '<%= config.bin %> env rollback --to PROD --version 279 --dry-run --json', + '<%= config.bin %> env rollback --to PROD --version 279 --yes', + '<%= config.bin %> env rollback --to PROD --version 279 --yes --confirm-record-loss', + ] + + static flags = { + to: Flags.string({description: 'The environment to roll back (the target of the promote, e.g. PROD)', required: true}), + version: Flags.integer({description: 'The fromVersion the promote answered (its rollback anchor)', required: true, min: 0}), + 'dry-run': Flags.boolean({ + description: 'Preview only (the default): the Hub returns the plan and changes nothing', + default: false, + exclusive: ['yes'], + }), + yes: Flags.boolean({char: 'y', description: 'Run it for real (without it you get the preview)', default: false}), + 'confirm-record-loss': Flags.boolean({ + description: 'Agree that collections the rollback deletes hide their records', + default: false, + }), + } + + async run(): Promise { + const {flags} = await this.parse(EnvRollback) + const target = flags.to.trim().toUpperCase() + const body = (dryRun: boolean): RollbackBody => ({ + targetEnv: target, + fromVersion: flags.version, + dryRun, + ...(flags['confirm-record-loss'] ? {confirmRecordLoss: true} : {}), + }) + const rerun = `norbix env rollback --to ${target} --version ${flags.version}${flags['confirm-record-loss'] ? ' --confirm-record-loss' : ''}` + const ctx = {target, rerun} + const client = this.client({...flags, 'dry-run': false}) + const call = async (dryRun: boolean) => { + try { + return (await client.hub.account.rollbackPromotion(body(dryRun) as never)) as unknown as {item?: PromotionResult} + } catch (error) { + throw this.explain(error, ctx) + } + } + + if (flags.yes) { + const res = await call(false) + this.show(res, false, ctx) + return res + } + + const preview = await call(true) + this.show(preview, true, ctx) + if (flags['dry-run'] || !this.isInteractive() || preview.item?.summary?.isNoOp) return preview + if (preview.item?.requiresConfirmation && !flags['confirm-record-loss']) { + this.log('Re-run with --confirm-record-loss to apply a plan that hides records.') + return preview + } + + const {confirm} = await import('@inquirer/prompts') + if (!(await confirm({message: `Roll ${target} back to before version ${flags.version} now?`, default: false}))) { + throw new CliError({exit: EXIT.CANCELLED, code: 'CANCELLED', message: 'Cancelled. Nothing was changed.'}) + } + + const res = await call(false) + this.log('') + this.show(res, false, ctx) + return res + } + + private show(res: {item?: PromotionResult}, dryRun: boolean, ctx: {target: string; rerun: string}): void { + if (this.jsonEnabled()) return + this.log(renderPromotion(res.item ?? {}, {kind: 'rollback', dryRun, ...ctx})) + } + + private explain(error: unknown, ctx: {target: string; rerun: string}): unknown { + const e = error as {raw?: unknown; code?: string; errorCode?: string} + const plan = planOfError(e.raw) + const code = e.errorCode ?? e.code + if (plan && !this.jsonEnabled()) this.log(renderPromotion(plan, {kind: 'rollback', dryRun: true, ...ctx}) + '\n') + if (code === RECORD_LOSS_CODE) { + return new CliError({ + exit: EXIT.CONFIRMATION_REQUIRED, + code, + message: `The rollback would hide records in ${ctx.target}. Nothing was changed.`, + hint: `Check the DELETED items of the preview. If that is intended: ${ctx.rerun} --yes --confirm-record-loss`, + docs: 'norbix env rollback --help', + ...(plan ? {context: {plan}} : {}), + }) + } + + return error + } +} diff --git a/src/lib/agent-files.ts b/src/lib/agent-files.ts index 7bb06fe..61ab5af 100644 --- a/src/lib/agent-files.ts +++ b/src/lib/agent-files.ts @@ -31,6 +31,7 @@ export function recipeBlock(): string { '- 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).', '- Designing a database schema? Run `norbix db schema rules` first: emails and links need a format (`email` / `uri`), language / currency / country and every list people pick from are taxonomy pickers (enum only for developer flags), user ids are user references, no createdBy / owner fields. Fix every `warnings` entry a schema save returns.', + '- Environments: build in the lowest one (TEST), never in PROD by hand. To make PROD like TEST: `norbix env promote --from TEST --to PROD --json` (a preview, changes nothing) → show the user the items, integrations and `notPromoted` → after they agree, the same command with `--yes` (`--confirm-record-loss` only if they accept the records listed as deleted) → keep the printed `fromVersion`: `norbix env rollback --to PROD --version ` undoes it. A `Note: PROD is promoted from TEST …` on stderr means: make that change in TEST and promote.', '- 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.', @@ -44,7 +45,7 @@ export function claudeSkill(): string { return [ '---', 'name: norbix', - 'description: Use when the task touches the Norbix backend — database records, users, files, email/SMS/push templates and campaigns, scheduler tasks, logs, environments, API keys, webhooks — or when the user mentions norbix. Runs the `norbix` CLI from the shell with --json and --dry-run.', + 'description: Use when the task touches the Norbix backend — database records, users, files, email/SMS/push templates and campaigns, scheduler tasks, logs, environments and moving TEST to PROD (promotion), API keys, webhooks — or when the user mentions norbix. Runs the `norbix` CLI from the shell with --json and --dry-run.', '---', '', '# Norbix via the CLI', @@ -64,6 +65,17 @@ export function claudeSkill(): string { '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.', '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.', '', + '## Environments — build in TEST, promote to PROD', + '', + 'When the project has TEST (or STAGING) below PROD, never build PROD by hand: schemas, taxonomies and terms, templates, footers, signatures and triggers are made in the lowest environment and moved up. Re-creating them in PROD gives them other ids and breaks triggers and term links. Records, users and files are never promoted.', + '', + '1. Work in TEST: `--env TEST` on every change.', + '2. Preview: `norbix env promote --from TEST --to PROD --json` — the default is a preview, nothing changes. Show the user the `summary`, the `items` by status (`deleted` ones with `recordCount`), each integration with its `action` and `warning` (`realSender` = it really sends e-mail / SMS / push) and the `notPromoted` list.', + '3. Promote, only after the user agreed: the same command with `--yes`. Integrations: Fake ones are copied, everything else is left out unless the user chose `--seed ` or `--map =`. Add `--confirm-record-loss` only when `requiresConfirmation` is true and the user accepts it (exit 3 `CM-ERRORS-ENVIRONMENTS-013` means it was needed).', + '4. Keep the `fromVersion` of the answer: `norbix env rollback --to PROD --version ` (a preview first, then `--yes`) undoes the promotion.', + '', + 'A `Note: PROD is promoted from TEST …` on stderr after a change means you wrote content straight into PROD: make it in TEST and promote instead. Built-in templates out of line in the preview: `norbix env built-ins repair` (preview, then `--yes`).', + '', '## Secrets', '', 'Never print, echo, paste or ask for API keys, passwords or tokens, and never read `~/.norbix`. Do not type a key into a command. Credentials come from `norbix login`, a `--profile `, or `NORBIX_*` variables the user set. CI and scripts use `NORBIX_HOST` + `NORBIX_API_KEY` + `NORBIX_PROJECT_ID`, never a browser sign-in.', @@ -75,6 +87,7 @@ export function claudeSkill(): string { 'norbix db update orders --id 66b2f0a1c3d4e5f6a7b8c9d0 --update \'{"status":"shipped"}\' --dry-run --json', 'norbix users delete 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run --json # then: --yes --json', 'norbix hub scheduler --json # methods + fields', + 'norbix env promote --from TEST --to PROD --json # preview; then: --yes', '```', '', 'Context flags on every command: `--host`, `--profile`, `--project`, `--env` / `--prod`, `--region` (or `NORBIX_*` variables). Full contract: docs/agent-contract.md in norbix-code/cli.', diff --git a/src/lib/env-rule.ts b/src/lib/env-rule.ts index 550e1d3..1c63328 100644 --- a/src/lib/env-rule.ts +++ b/src/lib/env-rule.ts @@ -46,6 +46,14 @@ export function envsOf(response: unknown): string[] | undefined { return names.length > 0 ? orderEnvs(names) : undefined } +/** The promotion ladder (`promotionOrder`, lowest → PROD) in a `GetProjectEnvironments` answer, upper case; undefined when the Hub sent none. */ +export function promotionOrderOf(response: unknown): string[] | undefined { + const list = (response as {item?: {promotionOrder?: unknown}} | undefined)?.item?.promotionOrder + if (!Array.isArray(list)) return undefined + const names = list.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map((e) => e.trim().toUpperCase()) + return names.length > 0 ? names : undefined +} + export interface EnvCheck { /** The env named for this run, upper case; undefined = none. */ env?: string diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts index 101eb26..809db91 100644 --- a/src/lib/hosts.ts +++ b/src/lib/hosts.ts @@ -463,7 +463,7 @@ export async function fetchProjectRegion( */ export const PROJECT_ENVS_TTL_MS = 5 * 60 * 1000 -type ProjectEnvs = Record +type ProjectEnvs = Record function projectEnvsPath(hubKey: string): string { return join(HOSTS_DIR, `${hubKey}.envs.json`) @@ -477,12 +477,28 @@ export function cachedProjectEnvs(hubKey: string, projectId: string, now = Date. return age >= 0 && age < PROJECT_ENVS_TTL_MS ? entry.environments : undefined } -export function saveProjectEnvs(hubKey: string, projectId: string, environments: string[], now = Date.now()): void { +export function saveProjectEnvs( + hubKey: string, + projectId: string, + environments: string[], + now = Date.now(), + promotionOrder?: string[], +): void { const all = readJson(projectEnvsPath(hubKey)) ?? {} - all[projectId] = {environments, fetchedAt: new Date(now).toISOString()} + all[projectId] = {environments, fetchedAt: new Date(now).toISOString(), ...(promotionOrder ? {promotionOrder} : {})} writeJson(projectEnvsPath(hubKey), all) } +/** + * The cached promotion ladder of a project (lowest → PROD), while the env list + * is fresh; undefined when the Hub sent none (older gateways) or nothing is cached. + */ +export function cachedPromotionOrder(hubKey: string, projectId: string, now = Date.now()): string[] | undefined { + if (!cachedProjectEnvs(hubKey, projectId, now)) return undefined + const order = readJson(projectEnvsPath(hubKey))?.[projectId]?.promotionOrder + return Array.isArray(order) && order.length > 0 ? order : undefined +} + /** Drop the cached environments of a project (after `env` commands or an env error). */ export function forgetProjectEnvs(hubKey: string, projectId: string): void { const all = readJson(projectEnvsPath(hubKey)) diff --git a/src/lib/promotion.ts b/src/lib/promotion.ts new file mode 100644 index 0000000..44ee645 --- /dev/null +++ b/src/lib/promotion.ts @@ -0,0 +1,412 @@ +/** + * Environment promotion (gateway contract: gateway/docs/tasks/promotion-v2.md, + * "Preview / result contract"): the request bodies, the human preview, and the + * promotion hint for content written straight into a higher environment. + * + * The SDK (@norbix.ai/ts 4.29.0) does not type the v2 fields yet, so they are + * typed here; the SDK transport sends the whole body as it is. + * + * No oclif import. + */ + +import {usageError} from './cli-error.js' + +// ---------- the answer ---------- + +export interface PromotionSummary { + added?: number + changed?: number + unchanged?: number + deleted?: number + kept?: number + skipped?: number + recordsAtRisk?: number + integrationsSeeded?: number + integrationsSkipped?: number + integrationsMapped?: number + integrationsRemoved?: number + isNoOp?: boolean +} + +export interface PromotionItem { + type: string + id?: string + name?: string + status: string + matchedBy?: string + previousTargetId?: string + builtIn?: boolean + recordCount?: number + note?: string +} + +export interface PromotionIntegrationTarget { + id: string + name?: string + provider?: string + isDefault?: boolean +} + +export interface PromotionIntegration { + module: string + id: string + /** + * The id the integration has (or will have) in the target env. Equals `id` except a seeded + * Files integration (its own new id per target env) and a `map` (the mapped target integration). + */ + targetId?: string + name?: string + provider?: string + status?: string + defaultAction?: string + action?: string + mapToId?: string + holdsSecret?: boolean + isFake?: boolean + realSender?: boolean + targets?: PromotionIntegrationTarget[] + warning?: string +} + +export interface PromotionNotPromoted { + area: string + scope?: string + reason?: string + count?: number +} + +export interface PromotionRef { + contentType?: string + contentId?: string + refKind?: string + unresolvedRef?: string +} + +export interface PromotionResult { + summary?: PromotionSummary + items?: PromotionItem[] + integrations?: PromotionIntegration[] + notPromoted?: PromotionNotPromoted[] + requiresConfirmation?: boolean + blockers?: PromotionRef[] + warnings?: PromotionRef[] + termChanges?: Array<{taxonomyId?: string; termId?: string; slug?: string; change?: string}> + fromVersion?: number + wasDryRun?: boolean +} + +export interface BuiltInRepairEnv { + env: string + isNoOp?: boolean + items?: PromotionItem[] +} + +// ---------- the requests ---------- + +export type IntegrationAction = 'seed' | 'skip' | 'map' + +export interface IntegrationChoice { + id: string + action: IntegrationAction + mapToId?: string +} + +export interface PromoteBody { + sourceEnv: string + targetEnv: string + dryRun: boolean + confirmRecordLoss?: boolean + integrationChoices?: IntegrationChoice[] +} + +export interface RollbackBody { + targetEnv: string + fromVersion: number + dryRun: boolean + confirmRecordLoss?: boolean +} + +export interface RepairBody { + environmentName?: string + dryRun: boolean +} + +/** The built-in repair route (not in the SDK yet). */ +export const REPAIR_PATH = '/{version}/account/projects/environments/built-ins/repair' + +/** The gateway's "a real run would hide records" refusal. */ +export const RECORD_LOSS_CODE = 'CM-ERRORS-ENVIRONMENTS-013' + +/** + * `--seed a --skip b --map c=d` → the choices list. One id may appear once; + * a map needs `=`. + */ +export function parseChoices(flags: {seed?: string[]; skip?: string[]; map?: string[]}): IntegrationChoice[] { + const choices: IntegrationChoice[] = [] + const seen = new Set() + const add = (choice: IntegrationChoice) => { + if (seen.has(choice.id)) { + throw usageError( + `Integration ${choice.id} has more than one choice.`, + 'Give each source integration one of --seed, --skip or --map.', + 'norbix env promote --help', + ) + } + + seen.add(choice.id) + choices.push(choice) + } + + for (const id of flags.seed ?? []) add({id: id.trim(), action: 'seed'}) + for (const id of flags.skip ?? []) add({id: id.trim(), action: 'skip'}) + for (const pair of flags.map ?? []) { + const eq = pair.indexOf('=') + const id = eq > 0 ? pair.slice(0, eq).trim() : '' + const mapToId = eq > 0 ? pair.slice(eq + 1).trim() : '' + if (!id || !mapToId) { + throw usageError( + `--map needs =, got "${pair}".`, + 'Take both ids from the preview: the integration line (source) and its "targets" (target, same module).', + 'norbix env promote --help', + ) + } + + add({id, action: 'map', mapToId}) + } + + return choices +} + +/** The plan inside a gateway error body (`item`), when the refusal carries one. */ +export function planOfError(raw: unknown): PromotionResult | undefined { + const item = (raw as {item?: unknown} | undefined)?.item + return item && typeof item === 'object' ? (item as PromotionResult) : undefined +} + +// ---------- the human preview ---------- + +const STATUS_ORDER = ['added', 'changed', 'deleted', 'kept', 'skipped', 'unchanged', 'removed', 'present', 'created', 'realigned'] + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? '' : 's'}` +} + +function pad(text: string, width: number): string { + return text.length >= width ? text : text + ' '.repeat(width - text.length) +} + +/** One line per item: type, name, id, and what is special about it. */ +function itemLine(item: PromotionItem): string { + const extra: string[] = [] + if (item.recordCount !== undefined && item.recordCount !== null) extra.push(plural(item.recordCount, 'record')) + if (item.matchedBy === 'name') extra.push(`matched by name${item.previousTargetId ? ` (was ${item.previousTargetId})` : ''}`) + if (item.builtIn) extra.push('built-in') + const tail = extra.length > 0 ? ` [${extra.join(', ')}]` : '' + const note = item.note ? `\n ${item.note}` : '' + return ` ${pad(item.type, 16)} ${pad(item.name ?? '', 32)} ${item.id ?? ''}${tail}${note}` +} + +/** The items grouped by status, in a fixed order; unchanged items collapse to one line of names. */ +export function renderItems(items: PromotionItem[] = []): string[] { + if (items.length === 0) return [' (no content items)'] + const groups = new Map() + for (const item of items) { + const key = (item.status ?? 'other').toLowerCase() + groups.set(key, [...(groups.get(key) ?? []), item]) + } + + const keys = [...groups.keys()].sort((a, b) => { + const ia = STATUS_ORDER.indexOf(a) + const ib = STATUS_ORDER.indexOf(b) + return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) + }) + const lines: string[] = [] + for (const key of keys) { + const group = groups.get(key)! + lines.push(` ${key.toUpperCase()} (${group.length})`) + if (key === 'unchanged') { + lines.push(` ${group.map((i) => `${i.type} ${i.name ?? i.id ?? ''}`.trim()).join(', ')}`) + continue + } + + for (const item of group) lines.push(itemLine(item)) + } + + return lines +} + +/** One line per source integration with the choice that applies, real senders marked loudly. */ +export function renderIntegrations(integrations: PromotionIntegration[] = [], rollback = false): string[] { + if (integrations.length === 0) return [' (no integrations)'] + const lines: string[] = [] + for (const i of integrations) { + const flags = [ + i.realSender ? 'REAL SENDER' : '', + i.holdsSecret ? 'holds a secret' : '', + i.isFake ? 'fake' : '', + ].filter(Boolean) + const choice = rollback + ? (i.status ?? '') + : `${i.action ?? i.defaultAction ?? ''}${i.action && i.defaultAction && i.action !== i.defaultAction ? ` (default ${i.defaultAction})` : ''}` + + (i.mapToId ? ` → ${i.mapToId}` : '') + lines.push( + ` ${i.realSender ? '!' : ' '} ${pad(i.module, 10)} ${pad(i.name ?? '', 30)} ${pad(i.provider ?? '', 24)} ${pad(choice, 20)} ${i.id}` + + (flags.length > 0 ? ` [${flags.join(', ')}]` : ''), + ) + if (i.targetId && i.targetId !== i.id) { + const how = i.action === 'map' || i.status === 'mapped' ? 'the mapped target integration' : 'its own new id — apps use this id there' + lines.push(` id in the target: ${i.targetId} (${how})`) + } + if (i.warning) lines.push(` warning: ${i.warning}`) + if (!rollback && (i.targets?.length ?? 0) > 0) { + lines.push(` in target: ${i.targets!.map((t) => `${t.name ?? t.id} (${t.provider ?? '?'}${t.isDefault ? ', default' : ''}) ${t.id}`).join('; ')}`) + } + } + + return lines +} + +/** `--seed / --skip / --map` flags that repeat the choices the user gave (for the "run it" line). */ +export function choiceFlags(choices: IntegrationChoice[]): string { + return choices + .map((c) => (c.action === 'map' ? `--map ${c.id}=${c.mapToId}` : `--${c.action} ${c.id}`)) + .join(' ') +} + +export interface RenderContext { + kind: 'promote' | 'rollback' + source?: string + target: string + dryRun: boolean + /** The flags to repeat in the "run it for real" line. */ + rerun: string +} + +/** The whole preview / result as text, the way a person reads it. */ +export function renderPromotion(result: PromotionResult, ctx: RenderContext): string { + const s = result.summary ?? {} + const what = ctx.kind === 'promote' ? `${ctx.source} → ${ctx.target}` : `rollback of ${ctx.target}` + const lines: string[] = [] + lines.push(ctx.dryRun ? `Preview: ${what} — nothing was changed.` : ctx.kind === 'promote' ? `Promoted ${what}.` : `Rolled back ${ctx.target}.`) + + if (s.isNoOp) lines.push(`Nothing to change — ${ctx.target} already matches.`) + const at = s.recordsAtRisk ? ` (${plural(s.recordsAtRisk, 'record')} at risk)` : '' + lines.push( + `Summary: ${s.added ?? 0} added · ${s.changed ?? 0} changed · ${s.unchanged ?? 0} unchanged · ${s.deleted ?? 0} deleted${at} · ` + + `${s.kept ?? 0} kept · ${s.skipped ?? 0} skipped`, + ) + lines.push( + `Integrations: ${s.integrationsSeeded ?? 0} seeded · ${s.integrationsSkipped ?? 0} skipped · ${s.integrationsMapped ?? 0} mapped` + + (ctx.kind === 'rollback' || s.integrationsRemoved ? ` · ${s.integrationsRemoved ?? 0} removed` : ''), + ) + + lines.push('', 'Content', ...renderItems(result.items)) + lines.push('', ctx.kind === 'promote' ? 'Integrations (choice per source integration; ! = sends real e-mail / SMS / push)' : 'Integrations', ...renderIntegrations(result.integrations, ctx.kind === 'rollback')) + + if ((result.termChanges?.length ?? 0) > 0) { + const counts = new Map() + for (const t of result.termChanges!) counts.set(t.change ?? 'changed', (counts.get(t.change ?? 'changed') ?? 0) + 1) + lines.push('', `Taxonomy terms: ${[...counts].map(([k, n]) => `${n} ${k}`).join(' · ')}`) + } + + if ((result.notPromoted?.length ?? 0) > 0) { + lines.push('', 'Not promoted') + for (const n of result.notPromoted!) { + lines.push(` - ${n.area}${n.scope ? ` (${n.scope})` : ''}${n.count !== undefined && n.count !== null ? ` [${n.count}]` : ''}: ${n.reason ?? ''}`) + } + } + + if ((result.warnings?.length ?? 0) > 0) { + lines.push('', 'Warnings') + for (const w of result.warnings!) lines.push(` - ${w.refKind ?? ''}: ${[w.contentType, w.contentId].filter(Boolean).join(' ')} ${w.unresolvedRef ?? ''}`.trimEnd()) + } + + if ((result.blockers?.length ?? 0) > 0) { + lines.push('', 'Blockers (fix these first — nothing can be promoted while they exist)') + for (const b of result.blockers!) lines.push(` - ${b.refKind ?? ''}: ${[b.contentType, b.contentId].filter(Boolean).join(' ')} → ${b.unresolvedRef ?? ''}`) + } + + lines.push('') + if (ctx.dryRun) { + if ((result.blockers?.length ?? 0) > 0) { + lines.push('Fix the blockers, then preview again.') + } else { + if (result.requiresConfirmation) { + lines.push(`A real run hides the records listed under DELETED (they come back on rollback). Add --confirm-record-loss only if that is intended.`) + } + + lines.push(`To apply it: ${ctx.rerun} --yes${result.requiresConfirmation ? ' --confirm-record-loss' : ''}`) + if (ctx.kind === 'promote') lines.push('Change an integration choice with --seed , --skip or --map =.') + } + } else if (ctx.kind === 'promote' && result.fromVersion !== undefined && result.fromVersion !== null) { + lines.push(`Rollback anchor: fromVersion ${result.fromVersion}. To undo: norbix env rollback --to ${ctx.target} --version ${result.fromVersion}`) + } + + return lines.join('\n') +} + +/** The built-in repair answer as text. */ +export function renderRepair(envs: BuiltInRepairEnv[], dryRun: boolean): string { + const lines: string[] = [dryRun ? 'Preview: built-in templates, footer and signature — nothing was changed.' : 'Built-ins repaired.'] + if (envs.length === 0) lines.push('No environment to repair (the project has only PROD).') + for (const env of envs) { + lines.push('', `${env.env}${env.isNoOp ? ' — nothing to do' : ''}`, ...renderItems(env.items)) + } + + if (dryRun && envs.some((e) => !e.isNoOp)) lines.push('', 'To apply it: add --yes') + return lines.join('\n') +} + +// ---------- the promotion hint ---------- + +/** + * Commands that create or change content a promotion carries up: schemas, + * taxonomies and terms, templates, footers, signatures, triggers. Records, + * users and files are never promoted, so their commands never hint. The + * gateway's MCP surface has the same list by tool name + * (gateway AiToolPromotionHint.PromotedContentTools). + */ +export const PROMOTED_CONTENT_COMMANDS: ReadonlySet = new Set([ + 'db:schema:create', + 'db:schema:update', + 'db:schema:publish', + 'db:term:create', + 'db:term:update', + 'db:trigger:create', + 'email:template:create', + 'email:template:update', + 'email:clone', + 'email:footer:save', + 'email:signature:save', + 'push:template:create', + 'push:template:update', + 'push:clone', + 'sms:template:create', + 'sms:template:update', + 'sms:clone', + 'payments:trigger:save', +]) + +/** + * The environment right below `env` on the ladder, or undefined. `order` is + * lowest → PROD (`promotionOrder` of the env list). Without it, PROD's lower + * env is TEST if the project has one, else the first other env. + */ +export function lowerEnvOf(env: string | undefined, order: string[] | undefined, envs: string[] | undefined): string | undefined { + const target = (env ?? 'PROD').trim().toUpperCase() + if (order && order.length > 0) { + const upper = order.map((e) => e.trim().toUpperCase()) + const index = upper.indexOf(target) + return index > 0 ? upper[index - 1] : undefined + } + + if (target !== 'PROD' || !envs) return undefined + const others = envs.map((e) => e.trim().toUpperCase()).filter((e) => e !== 'PROD').sort() + if (others.length === 0) return undefined + return others.includes('TEST') ? 'TEST' : others[0] +} + +/** The hint sentence — the same words the gateway's MCP answers carry. */ +export function promotionHint(env: string, lower: string): string { + return `${env} is promoted from ${lower} — make the change in ${lower} and run \`norbix env promote --from ${lower} --to ${env}\`` +} diff --git a/tests/__snapshots__/env-promote.test.ts.snap b/tests/__snapshots__/env-promote.test.ts.snap new file mode 100644 index 0000000..1b76001 --- /dev/null +++ b/tests/__snapshots__/env-promote.test.ts.snap @@ -0,0 +1,83 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`norbix env built-ins repair > previews by default, repairs with --yes, one env with --target 1`] = ` +"Preview: built-in templates, footer and signature — nothing was changed. + +TEST + PRESENT (1) + EmailTemplate Password reset tmpl_6kbXOl8AX1HdVMyFwURznj [built-in] + REALIGNED (1) + EmailFooter Default ftr_1 [built-in] + takes PROD's id + +To apply it: add --yes +" +`; + +exports[`norbix env promote > previews by default: sends dryRun true, prints the readable plan, changes nothing, exit 0 1`] = ` +"Preview: TEST → PROD — nothing was changed. +Summary: 1 added · 1 changed · 2 unchanged · 1 deleted (1 record at risk) · 0 kept · 0 skipped +Integrations: 1 seeded · 2 skipped · 0 mapped + +Content + ADDED (1) + Schema orders sch_3jucP9sDknGQ7R4zBwQKc4 + CHANGED (1) + Schema faq sch_src [matched by name (was sch_old)] + matched by name; the collection takes the source id, its records stay + DELETED (1) + Schema legacy_faq sch_gone [1 record] + collection 'legacy_faq': 1 records would be deleted (they stay in the database and come back if you roll back) + UNCHANGED (2) + EmailTemplate Password reset, Taxonomy Shipping status + +Integrations (choice per source integration; ! = sends real e-mail / SMS / push) + Database TEST database MongoDbConnectionString skip int_db [holds a secret] + in target: Primary Database (MongoDbConnectionString, default) int_prod_db + ! Email mailgun MailGun skip int_mailgun [REAL SENDER, holds a secret] + in target: Test Me (Fake, default) int_fake + Files S3 files AwsS3 seed (default skip) int_s3 [holds a secret] + id in the target: int_s3_prod (its own new id — apps use this id there) + +Taxonomy terms: 1 created + +Not promoted + - Users, system users and their API keys (environment): each environment keeps its own users in its own database + - Roles and policies (project): not environment-scoped — unchanged + +A real run hides the records listed under DELETED (they come back on rollback). Add --confirm-record-loss only if that is intended. +To apply it: norbix env promote --from TEST --to PROD --yes --confirm-record-loss +Change an integration choice with --seed , --skip or --map =. +" +`; + +exports[`norbix env rollback > previews by default and runs with --yes 1`] = ` +"Preview: rollback of PROD — nothing was changed. +Summary: 0 added · 0 changed · 2 unchanged · 1 deleted (1 record at risk) · 0 kept · 0 skipped +Integrations: 1 seeded · 2 skipped · 0 mapped · 1 removed + +Content + ADDED (1) + Schema orders sch_3jucP9sDknGQ7R4zBwQKc4 + CHANGED (1) + Schema faq sch_src [matched by name (was sch_old)] + matched by name; the collection takes the source id, its records stay + DELETED (1) + Schema legacy_faq sch_gone [1 record] + collection 'legacy_faq': 1 records would be deleted (they stay in the database and come back if you roll back) + UNCHANGED (2) + EmailTemplate Password reset, Taxonomy Shipping status + +Integrations + ! Email mailgun MailGun removed int_mailgun [REAL SENDER, holds a secret] + +Taxonomy terms: 1 created + +Not promoted + - Users, system users and their API keys (environment): each environment keeps its own users in its own database + - Roles and policies (project): not environment-scoped — unchanged + +A real run hides the records listed under DELETED (they come back on rollback). Add --confirm-record-loss only if that is intended. +To apply it: norbix env rollback --to PROD --version 279 --yes --confirm-record-loss +" +`; diff --git a/tests/__snapshots__/schema.test.ts.snap b/tests/__snapshots__/schema.test.ts.snap index cc908af..c330843 100644 --- a/tests/__snapshots__/schema.test.ts.snap +++ b/tests/__snapshots__/schema.test.ts.snap @@ -1841,6 +1841,17 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "email validation test", "supportsDryRun": true, }, + { + "args": [], + "destructive": true, + "flags": [ + "target:string", + "dry-run:boolean", + "yes:boolean", + ], + "id": "env built-ins repair", + "supportsDryRun": true, + }, { "args": [ "name", @@ -1860,6 +1871,35 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = ` "id": "env list", "supportsDryRun": false, }, + { + "args": [], + "destructive": true, + "flags": [ + "from:string", + "to:string", + "dry-run:boolean", + "yes:boolean", + "confirm-record-loss:boolean", + "seed:string", + "skip:string", + "map:string", + ], + "id": "env promote", + "supportsDryRun": true, + }, + { + "args": [], + "destructive": true, + "flags": [ + "to:string", + "version:string", + "dry-run:boolean", + "yes:boolean", + "confirm-record-loss:boolean", + ], + "id": "env rollback", + "supportsDryRun": true, + }, { "args": [ "name", diff --git a/tests/ai-init.test.ts b/tests/ai-init.test.ts index 181010d..7af92b9 100644 --- a/tests/ai-init.test.ts +++ b/tests/ai-init.test.ts @@ -44,7 +44,7 @@ describe('norbix ai init', () => { expect(skill).toContain('--dry-run') const claude = readFileSync(join(project, 'CLAUDE.md'), 'utf8') expect(claude).toContain('') - expect(claude.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(12) + expect(claude.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(13) const second = await cli(home, ['ai', 'init', '--dir', project, '--json']) expect(second.code).toBe(2) @@ -103,6 +103,7 @@ describe('norbix ai init', () => { '- Discover, do not guess: `norbix schema', '- Before any change, run it with `--dry-', '- Designing a database schema? Run `norb', + '- Environments: build in the lowest one ', '- Never print, paste or ask for API keys', '- Branch on the exit code: 0 ok · 2 usag', '- Full contract: `docs/agent-contract.md', @@ -115,7 +116,7 @@ describe('norbix ai init', () => { expect(bullets[4]).toContain('`norbix db schema rules`') expect(bullets[4]).toContain('language / currency / country and every list people pick from are taxonomy pickers') expect(block).not.toMatch(/norbix login --api-key|nbk_/) - expect(block.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(12) + expect(block.split('\n').filter((l) => l.trim()).length).toBeLessThanOrEqual(13) const skill = readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8') expect(skill).toContain('## Secrets\n\nNever print, echo, paste or ask for API keys, passwords or tokens') @@ -124,7 +125,7 @@ describe('norbix ai init', () => { expect(skill).toContain('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.') expect(skill).toContain('`norbix ... --dry-run --json` prints the exact request and sends nothing — show it to the user.') expect(skill).not.toMatch(/--api-key|nbk_/) - expect(skill.split('\n').length).toBeLessThanOrEqual(40) + expect(skill.split('\n').length).toBeLessThanOrEqual(50) expect(readFileSync(join(project, '.cursor/rules/norbix.mdc'), 'utf8')).toContain(bullets[4]) }) @@ -176,4 +177,27 @@ describe('norbix --version', () => { expect(r.code).toBe(0) expect(r.stdout).toMatch(/^@norbix\.ai\/cli\/\d+\.\d+\.\d+ \w+-\w+ node-v\d+/) }) + // Promotion phase 2: an agent asked to "set up PROD like TEST" promotes — preview first, + // then --yes after the user agreed, rollback as the anchor — and never builds PROD by hand. + it('the block and the skill say: build in TEST, preview the promotion, promote, roll back — never build PROD by hand', async () => { + const r = await cli(home, ['ai', 'init', '--target', 'all', '--dir', project]) + expect(r.code).toBe(0) + + const block = readFileSync(join(project, 'AGENTS.md'), 'utf8') + const env = block.split('\n').find((l) => l.startsWith('- Environments:'))! + expect(env).toContain('never in PROD by hand') + expect(env).toContain('`norbix env promote --from TEST --to PROD --json` (a preview, changes nothing)') + expect(env).toContain('the same command with `--yes`') + expect(env).toContain('`norbix env rollback --to PROD --version `') + expect(readFileSync(join(project, 'CLAUDE.md'), 'utf8')).toContain(env) + + const skill = readFileSync(join(project, '.claude/skills/norbix/SKILL.md'), 'utf8') + expect(skill).toMatch(/^description: .*moving TEST to PROD \(promotion\)/m) + const section = skill.slice(skill.indexOf('## Environments'), skill.indexOf('## Secrets')) + expect(section).toContain('never build PROD by hand') + expect(section).toContain('2. Preview: `norbix env promote --from TEST --to PROD --json` — the default is a preview, nothing changes.') + expect(section).toContain('3. Promote, only after the user agreed: the same command with `--yes`.') + expect(section).toContain('`norbix env rollback --to PROD --version `') + expect(section).toContain('Note: PROD is promoted from TEST') + }) }) diff --git a/tests/env-promote.test.ts b/tests/env-promote.test.ts new file mode 100644 index 0000000..8fe2f3f --- /dev/null +++ b/tests/env-promote.test.ts @@ -0,0 +1,281 @@ +import {mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterEach, describe, expect, it} from 'vitest' + +import {lowerEnvOf, parseChoices, promotionHint} from '../src/lib/promotion.js' +import {ENVS_PATH, cli, makeHome, parseSingleJson, startFakeGateway, type FakeGateway} from './_cli.js' + +/** + * Promotion phase 2 (docs/tasks/promotion-phase2.md): `norbix env promote / + * rollback / built-ins repair`, seen from outside as an agent sees them, and + * the promotion hint of content commands. The fixture is the gateway's own + * contract example (gateway/docs/tasks/promotion-v2.md, "Preview / result + * contract") plus its deleted-collection and name-match lines. + */ + +const PROMOTE = '/v3/account/projects/environments/promote' +const ROLLBACK = '/v3/account/projects/environments/promote/rollback' +const REPAIR = '/v3/account/projects/environments/built-ins/repair' + +/** The contract example of phase 1, one line of each kind, plus a deleted collection and a name match. */ +const PLAN = { + summary: { + added: 1, changed: 1, unchanged: 2, deleted: 1, kept: 0, skipped: 0, recordsAtRisk: 1, + integrationsSeeded: 1, integrationsSkipped: 2, integrationsMapped: 0, integrationsRemoved: 0, isNoOp: false, + }, + items: [ + {type: 'Schema', id: 'sch_3jucP9sDknGQ7R4zBwQKc4', name: 'orders', status: 'added', builtIn: false}, + {type: 'Schema', id: 'sch_src', name: 'faq', status: 'changed', matchedBy: 'name', previousTargetId: 'sch_old', builtIn: false, note: 'matched by name; the collection takes the source id, its records stay'}, + {type: 'Schema', id: 'sch_gone', name: 'legacy_faq', status: 'deleted', recordCount: 1, builtIn: false, note: "collection 'legacy_faq': 1 records would be deleted (they stay in the database and come back if you roll back)"}, + {type: 'EmailTemplate', id: 'tmpl_6kbXOl8AX1HdVMyFwURznj', name: 'Password reset', status: 'unchanged', matchedBy: 'id', builtIn: true}, + {type: 'Taxonomy', id: 'txn_1', name: 'Shipping status', status: 'unchanged', matchedBy: 'id', builtIn: false}, + ], + integrations: [ + { + module: 'Database', id: 'int_db', name: 'TEST database', provider: 'MongoDbConnectionString', status: 'skipped', + defaultAction: 'skip', action: 'skip', holdsSecret: true, isFake: false, realSender: false, + targets: [{id: 'int_prod_db', name: 'Primary Database', provider: 'MongoDbConnectionString', isDefault: true}], + }, + { + module: 'Email', id: 'int_mailgun', name: 'mailgun', provider: 'MailGun', status: 'skipped', + defaultAction: 'skip', action: 'skip', holdsSecret: true, isFake: false, realSender: true, + targets: [{id: 'int_fake', name: 'Test Me', provider: 'Fake', isDefault: true}], + }, + { + module: 'Files', id: 'int_s3', targetId: 'int_s3_prod', name: 'S3 files', provider: 'AwsS3', status: 'seeded', + defaultAction: 'skip', action: 'seed', holdsSecret: true, isFake: false, realSender: false, targets: [], + }, + ], + notPromoted: [ + {area: 'Users, system users and their API keys', scope: 'environment', reason: 'each environment keeps its own users in its own database'}, + {area: 'Roles and policies', scope: 'project', reason: 'not environment-scoped — unchanged'}, + ], + requiresConfirmation: true, + contentMirrored: [], contentDeleted: [], integrationsSeeded: [], integrationsSkipped: [], + blockers: [], termChanges: [{taxonomyId: 'txn_1', termId: 't1', slug: 'shipped', change: 'created'}], + warnings: [], fromVersion: null, wasDryRun: true, +} + +let gateway: FakeGateway | undefined + +afterEach(async () => { + await gateway?.close() + gateway = undefined +}) + +async function project(answers: Record, environments = ['PROD', 'TEST'], promotionOrder?: string[]) { + gateway = await startFakeGateway({ + [ENVS_PATH]: {item: {environments, ...(promotionOrder ? {promotionOrder} : {})}}, + ...answers, + }) + const home = makeHome(gateway.port) + const run = (argv: string[]) => cli(home, [...argv, '--profile', 'x']) + return {gateway, run} +} + +const bodies = (g: FakeGateway, path: string) => g.hits.filter((h) => h.url.split('?')[0] === path).map((h) => JSON.parse(h.body) as Record) + +describe('norbix env promote', () => { + it('previews by default: sends dryRun true, prints the readable plan, changes nothing, exit 0', async () => { + const {gateway, run} = await project({[PROMOTE]: {item: PLAN}}) + + const r = await run(['env', 'promote', '--from', 'test', '--to', 'prod']) + + expect(r.code).toBe(0) + expect(bodies(gateway, PROMOTE)).toEqual([{sourceEnv: 'TEST', targetEnv: 'PROD', dryRun: true}]) + expect(r.stdout).toMatchSnapshot() + }) + + it('--dry-run --json prints the Hub answer as it is (one JSON document)', async () => { + const {gateway, run} = await project({[PROMOTE]: {item: PLAN}}) + + const r = await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--dry-run', '--json']) + + expect(r.code).toBe(0) + expect((parseSingleJson(r.stdout) as {item: unknown}).item).toEqual(PLAN) + expect(bodies(gateway, PROMOTE)[0].dryRun).toBe(true) + }) + + it('--yes runs it for real with the choices and the record-loss consent, and prints the rollback anchor', async () => { + const done = {...PLAN, wasDryRun: false, fromVersion: 279, requiresConfirmation: false} + const {gateway, run} = await project({[PROMOTE]: {item: done}}) + + const r = await run([ + 'env', 'promote', '--from', 'TEST', '--to', 'PROD', '--yes', '--confirm-record-loss', + '--seed', 'int_mailgun', '--skip', 'int_x', '--map', 'int_db=int_prod_db', + ]) + + expect(r.code).toBe(0) + expect(bodies(gateway, PROMOTE)).toEqual([{ + sourceEnv: 'TEST', targetEnv: 'PROD', dryRun: false, confirmRecordLoss: true, + integrationChoices: [ + {id: 'int_mailgun', action: 'seed'}, + {id: 'int_x', action: 'skip'}, + {id: 'int_db', action: 'map', mapToId: 'int_prod_db'}, + ], + }]) + expect(r.stdout).toContain('Promoted TEST → PROD.') + expect(r.stdout).toContain('Rollback anchor: fromVersion 279. To undo: norbix env rollback --to PROD --version 279') + }) + + it('a real run that would hide records: the plan, then exit 3 with the gateway code and the flag to add', async () => { + const {run} = await project({ + [PROMOTE]: {__status: 400, item: PLAN, responseStatus: {errorCode: 'CM-ERRORS-ENVIRONMENTS-013', message: 'Record loss not confirmed.'}}, + }) + + const text = await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--yes']) + expect(text.code).toBe(3) + expect(text.stdout).toContain('DELETED (1)') + expect(text.stderr).toContain('The promotion would hide records in PROD') + expect(text.stderr).toContain('--confirm-record-loss') + + const json = await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--yes', '--json']) + expect(json.code).toBe(3) + const err = (parseSingleJson(json.stdout) as {error: {code: string; context?: {plan?: unknown}}}).error + expect(err.code).toBe('CM-ERRORS-ENVIRONMENTS-013') + expect(err.context?.plan).toEqual(PLAN) + }) + + it('refuses bad input before sending: the same env twice, a malformed --map, one id with two choices', async () => { + const {gateway, run} = await project({[PROMOTE]: {item: PLAN}}) + + expect((await run(['env', 'promote', '--from', 'PROD', '--to', 'prod'])).code).toBe(2) + expect((await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--map', 'int_db'])).code).toBe(2) + expect((await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--seed', 'a', '--skip', 'a'])).code).toBe(2) + expect((await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--dry-run', '--yes'])).code).toBe(2) + expect(gateway.hits).toEqual([]) + }) + + it('is not held by the env rule: no --env needed on a project with TEST and PROD', async () => { + const {gateway, run} = await project({[PROMOTE]: {item: PLAN}}) + + const r = await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--json']) + + expect(r.code).toBe(0) + expect(gateway.hits).toHaveLength(1) + }) + + it('the old generic path still works: hub account environment promote --body', async () => { + const {gateway, run} = await project({[PROMOTE]: {item: PLAN}}) + + const r = await run(['hub', 'account', 'environment', 'promote', '--body', '{"sourceEnv":"TEST","targetEnv":"PROD","dryRun":true}', '--json']) + + expect(r.code).toBe(0) + expect(bodies(gateway, PROMOTE)).toEqual([{sourceEnv: 'TEST', targetEnv: 'PROD', dryRun: true}]) + }) +}) + +describe('norbix env rollback', () => { + it('previews by default and runs with --yes', async () => { + const plan = { + ...PLAN, + summary: {...PLAN.summary, added: 0, changed: 0, integrationsRemoved: 1}, + integrations: [{...PLAN.integrations[1], status: 'removed'}], + } + const {gateway, run} = await project({[ROLLBACK]: {item: plan}}) + + const preview = await run(['env', 'rollback', '--to', 'PROD', '--version', '279']) + const real = await run(['env', 'rollback', '--to', 'PROD', '--version', '279', '--yes', '--confirm-record-loss']) + + expect(preview.code).toBe(0) + expect(real.code).toBe(0) + expect(bodies(gateway, ROLLBACK)).toEqual([ + {targetEnv: 'PROD', fromVersion: 279, dryRun: true}, + {targetEnv: 'PROD', fromVersion: 279, dryRun: false, confirmRecordLoss: true}, + ]) + expect(preview.stdout).toMatchSnapshot() + expect(real.stdout).toContain('Rolled back PROD.') + }) +}) + +describe('norbix env built-ins repair', () => { + it('previews by default, repairs with --yes, one env with --target', async () => { + const answer = { + items: [{ + env: 'TEST', isNoOp: false, items: [ + {type: 'EmailTemplate', id: 'tmpl_6kbXOl8AX1HdVMyFwURznj', name: 'Password reset', status: 'present', builtIn: true}, + {type: 'EmailFooter', id: 'ftr_1', name: 'Default', status: 'realigned', previousTargetId: 'ftr_old', builtIn: true, note: 'takes PROD\'s id'}, + ], + }], + } + const {gateway, run} = await project({[REPAIR]: answer}) + + const preview = await run(['env', 'built-ins', 'repair']) + const real = await run(['env', 'built-ins', 'repair', '--target', 'test', '--yes', '--json']) + + expect(preview.code).toBe(0) + expect(real.code).toBe(0) + expect(bodies(gateway, REPAIR)).toEqual([{dryRun: true}, {dryRun: false, environmentName: 'TEST'}]) + expect(preview.stdout).toMatchSnapshot() + expect(parseSingleJson(real.stdout)).toEqual(answer) + }) +}) + +describe('the promotion hint', () => { + const file = join(mkdtempSync(join(tmpdir(), 'norbix-hint-')), 'orders.json') + writeFileSync(file, '{"type":"object","properties":{"title":{"type":"string"}}}') + const SCHEMA = ['db', 'schema', 'create', '--name', 'orders', '--file', file] + + it('a content write into PROD of a project with TEST prints the hint on stderr; stdout stays one JSON document', async () => { + const {run} = await project({}, ['PROD', 'TEST'], ['TEST', 'PROD']) + + const r = await run([...SCHEMA, '--env', 'PROD', '--json']) + + expect(r.stderr).toContain('Note: PROD is promoted from TEST — make the change in TEST and run `norbix env promote --from TEST --to PROD`') + expect(() => parseSingleJson(r.stdout)).not.toThrow() + }) + + it('names the env right below on a longer ladder, also on a --dry-run', async () => { + const {gateway, run} = await project({}, ['PROD', 'STAGING', 'TEST'], ['TEST', 'STAGING', 'PROD']) + + const prod = await run([...SCHEMA, '--env', 'PROD']) + const staging = await run([...SCHEMA, '--env', 'STAGING']) + expect(prod.stderr).toContain('Note: PROD is promoted from STAGING') + expect(staging.stderr).toContain('Note: STAGING is promoted from TEST') + + const dry = await run([...SCHEMA, '--env', 'PROD', '--dry-run']) + expect(dry.stderr).toContain('Note: PROD is promoted from STAGING') + expect(gateway.hits.filter((h) => h.method === 'POST')).toHaveLength(2) + }) + + it('no hint for the lowest env, a PROD-only project, a record write, or a read', async () => { + const two = await project({}, ['PROD', 'TEST'], ['TEST', 'PROD']) + expect((await two.run([...SCHEMA, '--env', 'TEST'])).stderr).not.toContain('promoted from') + expect((await two.run(['db', 'insert', 'orders', '--doc', '{"title":"x"}', '--env', 'PROD'])).stderr).not.toContain('promoted from') + expect((await two.run(['db', 'schemas', '--env', 'PROD'])).stderr).not.toContain('promoted from') + await gateway?.close() + + const prodOnly = await project({}, ['PROD'], ['PROD']) + expect((await prodOnly.run(SCHEMA)).stderr).not.toContain('promoted from') + }) + + it('an older gateway with no ladder: PROD falls back to TEST', async () => { + const {run} = await project({}, ['PROD', 'TEST']) + + expect((await run([...SCHEMA, '--env', 'PROD'])).stderr).toContain('Note: PROD is promoted from TEST') + }) +}) + +describe('promotion helpers', () => { + it('lowerEnvOf, parseChoices and the hint text', () => { + expect({ + ladder: lowerEnvOf('PROD', ['DEV', 'TEST', 'PROD'], undefined), + lowest: lowerEnvOf('DEV', ['DEV', 'TEST', 'PROD'], undefined), + noLadderQa: lowerEnvOf('PROD', undefined, ['PROD', 'QA']), + noLadderTest: lowerEnvOf('PROD', undefined, ['PROD', 'QA', 'TEST']), + noLadderStaging: lowerEnvOf('STAGING', undefined, ['PROD', 'STAGING', 'TEST']), + choices: parseChoices({seed: ['a'], map: ['b=c']}), + hint: promotionHint('PROD', 'TEST'), + }).toEqual({ + ladder: 'TEST', + lowest: undefined, + noLadderQa: 'QA', + noLadderTest: 'TEST', + noLadderStaging: undefined, + choices: [{id: 'a', action: 'seed'}, {id: 'b', action: 'map', mapToId: 'c'}], + hint: 'PROD is promoted from TEST — make the change in TEST and run `norbix env promote --from TEST --to PROD`', + }) + }) +})