diff --git a/.claude/project/followups/CLOSED.md b/.claude/project/followups/CLOSED.md index f86ef9be..6094f6e8 100644 --- a/.claude/project/followups/CLOSED.md +++ b/.claude/project/followups/CLOSED.md @@ -1 +1,2 @@ - 2026-06-06 mssql-build-non-idempotent-create-type — "MSSQL build non-idempotent: bare CREATE TYPE blocks db.reset() rebuild" — *(closed 2026-06-06)* +- 2026-07-13 v1-38-sdk-integration-f-2 — "dt reader .dtz bad-path hangs instead of rejecting (unforwarded gunzip stream error)" — *(closed 2026-07-13)* diff --git a/.claude/project/followups/INDEX.md b/.claude/project/followups/INDEX.md index 2c8873bd..866a85f2 100644 --- a/.claude/project/followups/INDEX.md +++ b/.claude/project/followups/INDEX.md @@ -2,24 +2,26 @@ Auto-generated by `atomic followups render`. Do not edit. -Open: 7 • Stale: 0 • Last rendered: 2026-07-08 +Open: 10 • Stale: 0 • Last rendered: 2026-07-14 ## 📋 plans (1) - [configurable-sql-function-policy](configurable-sql-function-policy.md) — Configurable per-config SQL function allow/deny list + TUI editor → src/core/policy/classify.ts:79 (DESTRUCTIVE_FUNCTIONS) -## 🟡 risks (4) +## 🟡 risks (6) -- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (37d) -- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (0d) -- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (0d) -- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (0d) +- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (43d) +- [db-transfer-no-fk-negation-collision](db-transfer-no-fk-negation-collision.md) — citty --no-fk/--no-identity never actually toggle (negation collision) (1d) +- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (6d) +- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (6d) +- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (6d) +- [v1-45-rewind-tiebreak-f1](v1-45-rewind-tiebreak-f1.md) — rewind tiebreak tests rely on wall-clock timing, not forced tie (0d) ## 🔵 nits (2) -- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (37d) -- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (0d) +- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (43d) +- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (6d) -## ❓ questions (0) +## ❓ questions (1) -(none) +- [v1-21-31-hygiene-f2](v1-21-31-hygiene-f2.md) — Add release-engine paragraph to ignatius CLAUDE.md (2d) diff --git a/.claude/project/followups/db-transfer-no-fk-negation-collision.md b/.claude/project/followups/db-transfer-no-fk-negation-collision.md new file mode 100644 index 00000000..3f932442 --- /dev/null +++ b/.claude/project/followups/db-transfer-no-fk-negation-collision.md @@ -0,0 +1,24 @@ +--- +id: db-transfer-no-fk-negation-collision +title: citty --no-fk/--no-identity never actually toggle (negation collision) +created: "2026-07-13" +origin: | + docs/spec/v1-24-polish-batch.md, iter 1 implementer+reviewer (CP-3) +kind: finding +severity: risk +review_by: "2026-09-11" +status: open +file: src/cli/db/transfer.ts:305-306 +--- + +citty's raw-arg parser unconditionally strips any --no-X argv token and treats +it as negating a flag literally named X, regardless of what is declared. Two of +`db transfer`'s boolean flags are themselves named no-fk/no-identity (now noFk/noIdentity), +so passing --no-fk on the CLI has never set args.noFk to true -- it silently sets an +unrelated, undeclared args.fk = false instead. disableForeignKeys: args.noFk !== true +therefore always evaluates true regardless of the flag. Confirmed empirically both +before and after the v1-24 camelCase rename -- behavior is byte-identical, so that +ticket's "flag surface must not change" bar was met, but the flags have likely never +worked as documented. Needs a dedicated fix: likely renaming away from the no- prefix +pattern (e.g. --skip-fk-check/--skip-identity) or adding explicit non-auto-negated +boolean parsing. diff --git a/.claude/project/followups/v1-21-31-hygiene-f2.md b/.claude/project/followups/v1-21-31-hygiene-f2.md new file mode 100644 index 00000000..344a52bf --- /dev/null +++ b/.claude/project/followups/v1-21-31-hygiene-f2.md @@ -0,0 +1,18 @@ +--- +id: v1-21-31-hygiene-f2 +title: Add release-engine paragraph to ignatius CLAUDE.md +created: "2026-07-12" +origin: | + docs/spec/v1-21-31-hygiene.md, iter 1 implementer (CP-10) +kind: finding +severity: question +review_by: "2026-09-10" +status: open +file: /Users/alonso/projects/noorm/ignatius/CLAUDE.md +--- + +Ticket 31 (document release-engine split) asks for a matching paragraph in the ignatius repo's CLAUDE.md stating release-please as its engine and why it fits (conventional-commit-derived changelog, single-package manifest -- unlike monorepo's fixed-version group, which exists to keep two coupled packages in lockstep). Not applied during v1/21-31-hygiene because ignatius is a separate git repo (/Users/alonso/projects/noorm/ignatius) with no worktree isolation or review loop inside that branch's scope. + +Verbatim paragraph to add to ignatius/CLAUDE.md: + +"Ignatius releases via release-please (conventional-commit-derived changelog, single-package manifest) -- the right fit for a single-package repo, unlike monorepo's fixed-version group which exists specifically to keep two coupled packages in lockstep." diff --git a/.claude/project/followups/v1-45-rewind-tiebreak-f1.md b/.claude/project/followups/v1-45-rewind-tiebreak-f1.md new file mode 100644 index 00000000..651e5f67 --- /dev/null +++ b/.claude/project/followups/v1-45-rewind-tiebreak-f1.md @@ -0,0 +1,14 @@ +--- +id: v1-45-rewind-tiebreak-f1 +title: rewind tiebreak tests rely on wall-clock timing, not forced tie +created: "2026-07-14" +origin: | + docs/spec/v1-45-rewind-tiebreak.md, iter 1 reviewer (checkpoint 1) +kind: finding +severity: risk +review_by: "2026-09-12" +status: open +file: tests/core/change/manager.test.ts:291-316 +--- + +tests/core/change/manager.test.ts:291-316 (new tie-specific test) and the pre-existing test at ~line 250 force an appliedAt tie by relying on two sequential manager.run() calls landing within the same wall-clock second (SQLite CURRENT_TIMESTAMP second-precision), rather than deterministically forcing equal executed_at values. In the rare case the two calls straddle a second boundary, the test would still pass (non-tied ordering is unaffected by the tiebreak fix) but would not actually exercise the new appliedHistoryId tiebreak logic for that run -- false confidence, not a false failure. Fix: force the tie deterministically, e.g. by writing two history rows with an identical executed_at value directly, or by stubbing the clock during the two run() calls in this test file specifically. diff --git a/.claude/rules/tui-development.md b/.claude/rules/tui-development.md index 7cada4cb..a26ddf73 100644 --- a/.claude/rules/tui-development.md +++ b/.claude/rules/tui-development.md @@ -128,7 +128,7 @@ For fixed-position elements (like toast), use fixed width to reserve space and p Use hooks from `src/tui/hooks/useObserver.ts` for event subscriptions. These handle cleanup automatically. ```tsx -import { useOnEvent, useOnceEvent, useEmit, useEventPromise } from '../hooks/index.js'; +import { useOnEvent, useOnceEvent, useEmit } from '../hooks/index.js'; // Subscribe to events - cleanup on unmount useOnEvent('changeset:complete', (data) => { @@ -143,10 +143,6 @@ useOnceEvent('build:complete', (data) => setFinalResult(data), []); // Emit events via memoized callback const emitStart = useEmit('build:start'); emitStart({ schemaPath, fileCount }); - -// Promise-based with state management -const [result, error, pending, cancel] = useEventPromise('build:complete'); -if (pending) return ; ``` diff --git a/.claude/rules/typescript.md b/.claude/rules/typescript.md index c3d4117b..a19701d9 100644 --- a/.claude/rules/typescript.md +++ b/.claude/rules/typescript.md @@ -126,11 +126,13 @@ If you're going to re-throw the error unchanged in every case, skip `attempt` an ## Error Handling (ZERO TOLERANCE) -- **NEVER use try-catch** - This is a critical violation -- **ALWAYS use @logosdx/utils utilities**: `attempt`, `attemptSync`, `batch`, `circuitBreaker`, `debounce`, `throttle`, `memo`, `rateLimit`, `retry`, `withTimeout`, `ObserverEngine`, `FetchEngine` +- **NEVER use try-catch** - This is a critical violation. The zero tolerance targets try-catch specifically, not throwing or `attempt`/`attemptSync` - see Function Structure above for when to wrap deliberately vs. let errors propagate. +- **Mandated `@logosdx/utils` utilities**: `attempt`/`attemptSync` (the convention actually in use - 553 call sites across 175 files) and `retry` (used at `src/core/connection/factory.ts:93`). Use `attempt`/`attemptSync` per the Function Structure guidance above - only when the function does something with the error. +- **Available in `@logosdx/utils` but not currently used**: `batch`, `circuitBreaker`, `debounce`, `throttle`, `memo`/`memoize`, `rateLimit`, `withTimeout`, `FetchEngine`. Reach for them if a real need arises; they are not mandated because nothing in `src/` imports them today. +- `ObserverEngine` is `@logosdx/observer`, not `@logosdx/utils`. ```typescript -// CORRECT +// CORRECT - attempt() used deliberately: observes the error, emits, then stops const [result, err] = await attempt(() => db.execute(sql)); if (err) { @@ -139,6 +141,9 @@ if (err) { return; } +// ALSO CORRECT - nothing to add by wrapping; let the error propagate +return db.execute(sql); + // WRONG - Never do this try { const result = await db.execute(sql); @@ -264,7 +269,7 @@ assert(condition, 'message', CustomError); ## Class Patterns -Use private fields with `#` prefix. Namespace types under the class. +Use private fields with `#` prefix. ```typescript export class StateManager { @@ -280,15 +285,6 @@ export class StateManager { } -} - -export namespace StateManager { - - export interface Config { - name: string; - connection: ConnectionConfig; - } - } ``` diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 53093efc..8ea1645f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -31,6 +31,9 @@ jobs: bun install --frozen-lockfile cd docs && bun install --frozen-lockfile + - name: Check --json doc placement + run: bun run lint:docs + - name: Build docs env: VITEPRESS_BASE: / diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c16acaf3..22642ecb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -76,10 +76,18 @@ jobs: - name: Build binaries run: bun run build:binary + - name: Generate checksums + working-directory: packages/cli/bin + run: | + shasum -a 256 noorm-* > checksums.txt + cat checksums.txt + - name: Upload binaries to release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.tag }} - files: packages/cli/bin/noorm-* + files: | + packages/cli/bin/noorm-* + packages/cli/bin/checksums.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml index 0f9c01ea..0109b828 100644 --- a/.github/workflows/release-binary.yml +++ b/.github/workflows/release-binary.yml @@ -29,10 +29,18 @@ jobs: - name: Build binaries run: bun run build:binary + - name: Generate checksums + working-directory: packages/cli/bin + run: | + shasum -a 256 noorm-* > checksums.txt + cat checksums.txt + - name: Upload binaries to release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.tag }} - files: packages/cli/bin/noorm-* + files: | + packages/cli/bin/noorm-* + packages/cli/bin/checksums.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index d9ef9f22..fa093989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,13 +48,15 @@ The integration step needs postgres/mysql/mssql reachable (CI uses service conta ## Changesets -This is a pnpm monorepo with two publishable packages. Changeset frontmatter must reference the correct workspace package name: +This is a bun workspace monorepo with two publishable packages. Changeset frontmatter must reference the correct workspace package name: - **`@noormdev/cli`** — `packages/cli` (CLI/TUI) - **`@noormdev/sdk`** — `packages/sdk` (programmatic SDK) Never use `noorm` or `@noormdev/main` — those are not workspace packages and will fail the Release workflow. +Changesets is the release engine because it supports a fixed-version group: `@noormdev/cli` and `@noormdev/sdk` are coupled packages that always bump together on every release, which fits a two-package monorepo where the packages version in lockstep rather than independently. + ## Tech Stack diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..ee92eec1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Danilo Alonso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/TODO.md b/TODO.md index 87b5a849..28cb11e8 100644 --- a/TODO.md +++ b/TODO.md @@ -17,101 +17,10 @@ Core SDK is implemented and packaged (`@noormdev/sdk`). Remaining: ### Headless CLI Gaps -40 handlers implemented. Missing commands: - -**Database:** - -- [x] `db reset` - Teardown + build (idempotent rebuild) -- [x] `db drop` - Drop entire database -- [x] `db create` - Create database if not exists - -**Configuration:** - -- [x] `config validate` - Validate config can connect -- [x] `config list` - List available configs - -**SQL Execution:** - -- [x] `sql ` - Execute raw SQL -- [x] `sql -f ` - Execute SQL from file - -**Changes:** - -- [x] `change next` - Apply next pending change - -**Runner:** - -- [x] `run files ` - Run multiple specific files -- [x] `run exec` - Batch-execute selected SQL files (currently TUI-only `RunExecScreen`) - - -### TUI Parity Gaps - -Surfaces that exist in the TUI but have no headless CLI equivalent. Discovered after the citty migration audit. Blocks CI/CD adoption for anything beyond `change`/`run`/`vault` workflows. - -**Identity:** (entire domain TUI-only — no `noorm identity` command) - -- [x] **CI provisioning + runtime** — full `noorm ci` namespace for CI/CD: - - `noorm ci identity new` — generate a local keypair + env block for test CI - - `noorm ci identity enroll --config ` — generate + register identity in a prod DB's `identities` table with vault propagation - - `noorm ci init` — bootstrap ephemeral state.enc from env vars (identity + connection) - - `noorm ci secrets --file ` — batch-load secrets from dotenv file into active config's vault - - Absorbs and removes the former `noorm identity ci` diagnostic -- [x] `identity init` - Generate or regenerate an identity headlessly -- [x] `identity edit` - Edit identity metadata (name, email) -- [x] `identity export` - Export public key -- [x] `identity list` - List known users - -**Init:** - -- [x] `noorm init` - Bootstrap a project (identity setup + project setup) from CLI - -**Settings:** (entire domain TUI-only — no `noorm settings` command) - -- [x] `settings init` - Initialize `settings.yml` -- [x] `settings build` - Build/regenerate settings -- [x] `settings edit` - Interactive editor. Prompts for a field to edit (paths, strict, logging, stages, rules), applies the change, then loops back to the field picker. Exits on "Done" selection or `Esc`. One command covers everything. -- [x] `settings secret` - Interactive secret **requirement** declaration (config enforcement, not value storage). Declares that a given secret must be set for a particular stage (or all stages). Loop pattern: pick action (add/edit/rm requirement), pick scope (universal or a stage), apply, loop. Exits on "Done" or `Esc`. Actual secret values live in `secret/*` / vault. - -**Secrets:** (entire `secret/*` domain TUI-only — distinct from `vault`) - -- [x] `secret list` - List secrets -- [x] `secret set ` - Set a secret -- [x] `secret rm ` - Remove a secret - -**Configuration (additional):** - -- [x] `config cp ` - Copy a config -- [x] `config export ` - Export a config to file -- [x] `config import ` - Import a config from file - -**Changes (additional):** - -- [x] `change add ` - Create a new change -- [x] `change edit ` - Edit an existing change -- [x] `change rm ` - Delete a change -- [x] `change rewind ` - Rewind to a specific change -- [x] `change history detail ` - Show per-file execution history - -**Database (additional):** +All headless CLI commands are implemented except one: - [ ] `db dt-modify ` - Modify a `.dt` file (currently only TUI `DtModifyScreen`) -**Database Exploration:** - -- [x] `db explore views` + `db explore views ` - List and inspect views -- [x] `db explore procedures` + `db explore procedures ` - List and inspect stored procedures -- [x] `db explore functions` + `db explore functions ` - List and inspect functions -- [x] `db explore types` + `db explore types ` - List and inspect custom types -- [x] `db explore indexes` - List indexes -- [x] `db explore fks` - List foreign keys - -**SQL Terminal:** (CLI `sql` is one-shot only) - -- [x] `sql repl` - Interactive SQL REPL (currently TUI-only `SqlTerminalScreen`) -- [x] `sql history` - Show SQL execution history -- [x] `sql clear` - Clear SQL execution history - ### CI/CD Integration diff --git a/bun.lockb b/bun.lockb index f59b51af..3eee0052 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yml b/docker-compose.test.yml similarity index 100% rename from docker-compose.yml rename to docker-compose.test.yml diff --git a/docs/cli/flags.md b/docs/cli/flags.md index ca99e344..48fc937e 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -1,94 +1,100 @@ # CLI flag conventions -`noorm`'s CLI is built on [citty](https://github.com/unjs/citty), which -parses flags relative to the subcommand they belong to. A flag that -"works on every command" still has to be placed where the parser -expects it. This page covers the two rules that catch most users out. +`noorm`'s CLI is built on [citty](https://github.com/unjs/citty), which parses flags +relative to the subcommand they belong to — a flag typed before the subcommand name never +reaches the leaf command's own parser. `noorm` works around that for the one flag that +genuinely needs to run before dispatch, by stripping it out of `argv` before citty ever sees +it. This page covers that flag and the rule for everything else. ## Global flags vs per-subcommand flags -A flag is **global** when it's owned by the root `noorm` command and -must appear *before* the subcommand. A flag is **per-subcommand** when -it's owned by an individual command like `noorm sql query` and must -appear *after* the subcommand name. +A flag is **global** when `noorm` recognizes and hoists it regardless of where it appears — +before or after the subcommand. A flag is **per-subcommand** when it's owned by an +individual command like `noorm sql query` and only works *after* the subcommand name. -`noorm` has very few true globals on purpose — most flags vary in -meaning per command (`-c` is `--cwd` at the root, but `--config` on -many subcommands), so per-subcommand parsing is the safer default. +`noorm` hoists exactly one flag on purpose — most flags vary in meaning per command (`-c` is +`--cwd` at the root, but `--config` on many subcommands), so per-subcommand parsing is the +default for everything else, `--dry-run`/`--json`/`--yes` included. -### Globals (before the subcommand) +### The one global: `-c` / `--cwd ` | Flag | Purpose | |------|---------| -| `--cwd ` / `-c ` | Change working directory before resolving `.noorm/`. Same idea as `git -C`. | -| `--help` / `-h` | Show top-level help (citty renders it). | -| `--version` | Print the installed noorm version. | +| `--cwd ` / `-c ` | Change working directory before resolving `.noorm/`. Same idea as `git -C`. Recognized only **before** the subcommand — see the note below. | + +`--cwd`/`-c` is global for two reasons: + +1. It's consumed **before dispatch** — it sets the working directory everything else + (project discovery, config resolution) resolves against, so it's genuinely the CLI's own + flag rather than any subcommand's. +2. `-c` already means `--config` *after* the subcommand (see the overload note below). + Hoisting it lets `noorm -c run build` mean cwd while `noorm run build -c ` + means config, without either shadowing the other. Examples: noorm -c packages/db run build noorm --cwd /repos/myapp change ff - noorm --help + +`--help`/`-h` and `--version` are also recognized outside this mechanism — that's citty's own +framework behaviour, not a `noorm`-specific hoist. + +An unrecognized flag placed before the subcommand is an error, not a silent no-op: + + $ noorm --bogus-flag config list + Error: Unrecognized flag '--bogus-flag' before the subcommand — noorm can't forward it there. + The only root-level flag is -c/--cwd ; every other flag goes on the command that uses it. + Move '--bogus-flag' after the subcommand instead, e.g. noorm ... --bogus-flag. ### Per-subcommand flags (after the subcommand) -Everything else lives on a specific subcommand. Common per-subcommand -flags include `--json`, `--config `, `--force`, `--yes`, -`--dry-run`, and `--help`. To see exactly which flags a command -accepts, append `--help` to it: +Everything else lives on a specific subcommand — `--config `, `--force`, `--dry-run`, +`--json`, `--yes`/`-y`, and any command-specific flag. These are **not** hoisted, so they only +work after the subcommand name: + + noorm run build --dry-run + noorm config list --json + noorm sql repl --yes + +Placed before the subcommand instead, none of them are honoured — `noorm --dry-run run build` +is rejected exactly like `noorm --bogus-flag config list` above, naming `--dry-run` as the +offending flag. To see exactly which flags a command accepts, append `--help` to it: noorm change ff --help noorm db create --help noorm sql query --help -## The `--json` flag specifically - -`--json` is the single most common discovery snag. It is **not** a -global flag — every subcommand that supports machine-readable output -declares its own `--json`. That means it must appear after the -subcommand name, not before. - -Works (correct placement): - - noorm config list --json - noorm change ff --json - noorm sql query "SELECT 1" --json - noorm --config dev change ff --json +## The `--config` / `-c` overload -Does not work (citty parses `--json` as an unknown root flag, then -fails to find a subcommand): +`-c` is overloaded by position, which is the one sharp edge left in this system: - noorm --json config list - noorm --json change ff - noorm --json sql query "SELECT 1" +- `noorm -c run build` — global `--cwd`, because `-c` appears **before** the subcommand. +- `noorm run build -c ` — per-command `--config`, because `-c` appears **after** the subcommand. -The same rule applies to `--config` / `-c` as a per-command flag (the -form that switches the active config for one invocation). Both -placements happen to be valid for `--config` because the root accepts -it via the citty inheritance hook — but for `--json`, only the -post-subcommand form works. +The first non-flag token (the subcommand name) is the boundary that decides which meaning +applies. `--config` in long form has no global meaning at all — it only works after the +subcommand, so `noorm --config prod run build` is not the same as `noorm run build --config prod` +(the former is rejected as an unrecognized global flag). ## Mental model -Think of `noorm` as having a single global (`-c`/`--cwd`) for "where -should I run?" and everything else as command-scoped. When in doubt, -put the flag right next to the subcommand it modifies: +`noorm` has exactly one true global, `-c`/`--cwd`, and everything else is command-scoped. Put +every flag right next to the subcommand it modifies: noorm -If a flag isn't being honored, the most likely cause is that it -landed on the wrong side of the subcommand boundary. +If a flag isn't being honored, it's placed before the subcommand boundary — or it's a typo, +in which case `noorm` now says so instead of silently dropping it. ## Related - [`noorm` help conventions](./help.md) — how to find a command's flags. - [Headless reference](../headless.md) — full table of common flags. -- [Troubleshooting](../guide/troubleshooting.md) — quick fixes for the - `--json` placement issue and other common surprises. +- [Troubleshooting](../guide/troubleshooting.md) — quick fixes for common surprises. diff --git a/docs/cli/identity.md b/docs/cli/identity.md index 9d3826fd..2c54d71e 100644 --- a/docs/cli/identity.md +++ b/docs/cli/identity.md @@ -44,7 +44,7 @@ Print your public key so teammates can add you to encrypted vaults: ```bash noorm identity export -noorm --json identity export +noorm identity export --json ``` @@ -54,7 +54,7 @@ Show every identity discovered from database syncs (the audit trail of who has t ```bash noorm identity list -noorm --json identity list +noorm identity list --json ``` diff --git a/docs/cli/secret.md b/docs/cli/secret.md new file mode 100644 index 00000000..76dc0860 --- /dev/null +++ b/docs/cli/secret.md @@ -0,0 +1,71 @@ +# noorm secret + + +Manage **config-scoped local secrets** — values stored on disk in `.noorm/state/state.enc`, +tied to a single config, and never shared with the rest of the team. This is one of three +places a secret value can live; see [Which tier?](#which-tier) below before reaching for it. + + +## Subcommands + +| Command | Purpose | +|---------|---------| +| `noorm secret set ` | Store a secret for the active or named config | +| `noorm secret list` | List secret keys for a config (values never shown) | +| `noorm secret rm ` | Remove a secret from a config | + + +## Flags + +Common to every subcommand: + +- `--config ` / `-c ` — target a config other than the active one +- `--json` — emit machine-readable JSON on stdout + +`secret rm` additionally requires: + +- `--yes` / `-y` — confirm the deletion; omitted, the command exits non-zero without deleting + + +## Which tier? + +noorm resolves `$.secrets.KEY` in a template from three tiers, config-local first: + +| Tier | Command | Scope | Shared with team? | +|------|---------|-------|--------------------| +| Config-local | `noorm secret set` | One config | No — per-user, per-machine | +| Global-local | TUI only (`noorm ui` → Settings → Secrets) | Every config on this machine | No — per-user, per-machine | +| Vault | [`noorm vault set`](../guide/environments/vault.md) | Every config, every teammate | Yes — encrypted in the database | + +If a value should be different for you than for the rest of the team, it belongs in +`noorm secret` (or the TUI's global-local screen). If it should be the same for everyone — +CI included — it belongs in `noorm vault set`. `noorm secret set` on a value the whole team +needs is the mistake this page exists to prevent: it lands in your local `state.enc` only, +so a teammate or a CI runner resolving the same key finds nothing and either fails loudly (a +missing secret now throws — see [Templates](../guide/sql-files/templates.md)) or silently +picks up a stale vault value instead of the one you just set. + +See [Secrets](../guide/environments/secrets.md) for config-scoped vs. global-local, and +[Vault](../guide/environments/vault.md) for the full three-tier resolution hierarchy and +team-provisioning workflow. + + +## Examples + + noorm secret set API_KEY "sk-live-..." + noorm secret set DB_PASSWORD "secret123" --config prod + noorm secret set API_KEY "sk-live-..." --json + + noorm secret list + noorm secret list --config staging --json + + noorm secret rm OLD_KEY --yes + noorm secret rm OLD_KEY --yes --config prod + + +## Related + +- [`noorm settings secret`](./settings-secret.md) — edits which secrets a stage *requires*, + not their values. +- [Secrets guide](../guide/environments/secrets.md) — full CLI + TUI workflow. +- [Vault guide](../guide/environments/vault.md) — team-shared secrets and CI provisioning. diff --git a/docs/cli/settings-secret.md b/docs/cli/settings-secret.md index b35d42a5..991d353d 100644 --- a/docs/cli/settings-secret.md +++ b/docs/cli/settings-secret.md @@ -5,7 +5,7 @@ Interactive editor for the secret *requirement* declarations in `settings.yml`. Manages which secrets each stage requires — not the actual values. -For secret values, see [`noorm secret set`](../headless.md#secrets-config-scoped). +For secret values, see [`noorm secret set`](./secret.md). ## Environment diff --git a/docs/design/v1-49-54-cli-field-defects.md b/docs/design/v1-49-54-cli-field-defects.md new file mode 100644 index 00000000..53e42e62 --- /dev/null +++ b/docs/design/v1-49-54-cli-field-defects.md @@ -0,0 +1,242 @@ +# v1/49-54 — field-reported CLI defects + +Six issues filed 2026-07-25 against `noormdev/noorm` from one production MSSQL migration +(~155 files, 9 phase directories, hand-rolled zsh deploy script → noorm). + +One reporting effort, six symptoms. The traces show they are not six independent bugs: +four of them are the same structural failure wearing different clothes — **a contract is +documented, the plumbing to honour it is absent, and the absence is silent.** + +## The shared shape + +| # | Documented contract | What the code does | How it fails | +|---|---|---|---| +| 49 | `--dry-run` previews without executing | `run build` never declares the flag | silent — flag ignored, build runs | +| 50 | secrets resolve across three tiers | the three-tier resolver has zero callers | silent — renders `'undefined'` | +| 51 | env vars outrank stored config | `db create` reads stored config raw | silent — wrong database created | +| 52 | `--json` anywhere, text otherwise | text goes to stderr; `--json` dropped if leading | silent — empty stdout, exit 0 | + +Every one exits 0. That is the through-line worth fixing, not just the four instances. +A flag that is ignored must never be indistinguishable from a flag that was honoured. + +## Concepts + +### Silent flag drop (citty dispatch) + +`citty` forwards only `rawArgs.slice(subCommandArgIndex + 1)` to a subcommand +(`node_modules/citty/dist/index.mjs:217`). Any flag typed *before* the subcommand name is +discarded before the leaf command parses. The root command declares no `args` at all +(`src/cli/index.ts:44-71`), so nothing catches them. + +Verified empirically against the built CLI: + + $ node dist/cli/index.js config list --json + {"configs":[]} + + $ node dist/cli/index.js --json config list + No configurations found. # exit 0 + +Both documented forms appear in the docs. `docs/headless.md:55,641` writes +`noorm --dry-run run build` — the form that cannot work. The reporter used exactly that form. + +This single defect is why #49's repro looks the way it does and is half of #52. + +### Result output vs diagnostic output + +`Logger` has two writers (`src/core/logger/logger.ts:143`): `console` (stdout) and +`diagnostics` (stderr). `#writeConsole` (`:497-543`) routes: + +- `--json` → NDJSON event lines to **stdout** +- otherwise → human text to **stderr** + +So `noorm change list` writes its table to stderr, and `noorm change list --json` writes +log noise to stdout *ahead of* the payload. Both directions are backwards. A command's +**result** is stdout; its **diagnostics** are stderr, in both modes. + +`change list`/`history` compound it by calling `outputResult` only under `if (args.json)` +(`src/cli/change/list.ts:57-61`, `history.ts:54-58`) — the text branch never reaches the +one helper that writes to stdout correctly. + +### Connection resolution has two doors + +`resolveConfig` (`src/core/config/resolver.ts:211`) is canonical: it merges +`DEFAULTS <- stage <- stored <- env <- flags` (env at `:253`). Everything reaching it via +`withContext`/`createContext` honours `NOORM_CONNECTION_*`. + +`StateManager.getConfig(name)` (`src/core/state/manager.ts:349`) is a raw record read with +no merge. `db create` (`src/cli/db/create.ts:46`) and `db drop` (`src/cli/db/drop.ts:50`) +call it directly and then connect with the result. That is the whole of #51. + +`docs/dev/config.md:13-23` and `docs/headless.md:161-165` state the precedence as a blanket +guarantee for `noorm`, with no per-command exceptions. The docs are right; two commands +are wrong. + +### Secret tiers: documented, built, never connected + +`resolveSecret` and `buildSecretsContext` (`src/core/vault/resolve.ts:45,149`) implement the +documented three-tier precedence (config-local > global-local > vault). +`docs/dev/vault.md:169` names `buildSecretsContext()` as *the* template-render path. + +Neither function has a production caller. The render path instead calls +`stateManager.getAllSecrets(configName)` (`src/core/state/manager.ts:479`) — config-local +tier only, no vault merge — from `src/sdk/namespaces/run.ts:255` and five sibling sites. + +**Vault secrets never reach `$.secrets`, for any identity.** The reporter's diagnosis +("the vault tier silently yielded nothing for this identity") was correct about the symptom +and understated the cause: it yields nothing for everyone. + +Then two more layers of silence turn a missing secret into valid SQL: + +- `$.secrets` is a plain object (`src/core/template/context.ts:70`), so a missing key is + `undefined`, not an error. +- `sqlQuote` (`src/core/template/utils.ts:85-95`) guards `null` but not `undefined`; + `String(undefined)` → `"undefined"` → `'undefined'`. + +Net: `CREATE LOGIN worker WITH PASSWORD = 'undefined';` applied successfully. + +## Business rules + +1. **A flag that cannot be honoured is an error, never a no-op.** Unknown or + misplaced flags fail loudly with the correct form named. +2. **Results go to stdout; diagnostics go to stderr.** In every mode. `--json` stdout is + parseable without `tail -1`. +3. **Env-only mode is a first-class configuration source for every command that connects.** + No command reads a stored config raw and then connects with it. +4. **A secret that cannot be resolved has no correct rendering.** Fail, naming the key. + Never substitute a placeholder into SQL. +5. **Scaffolding teaches its own layout.** A command that creates a directory structure + either populates it or documents it. + +## Decisions + +### D1 — Only `-c`/`--cwd` is a root-level flag + +A flag goes on the command that uses it. `-c`/`--cwd` is the sole exception: it is consumed +before dispatch — it sets the working directory everything else (project discovery, config +resolution) resolves against — and `-c` already means `--config` after the subcommand, so the +split is genuinely load-bearing rather than a convenience. + +`--dry-run`, `--json`, `--yes` are **not** hoisted. They are per-subcommand, exactly like +`--config` and `--force` always were. A flag placed before the subcommand that isn't +`-c`/`--cwd` is rejected outright, naming the flag and the correct form — a flag that cannot +be honoured is still an error, never a silent no-op (Business rule 1). + +Implemented by narrowing `extractGlobalCwd` (`src/cli/index.ts`) to strip only `-c ` / +`--cwd ` / `--cwd=` from `rawArgs` before dispatch; every other flag-looking token +seen before the subcommand falls through to the error path. + +Rejected: declaring the flags at every level. It multiplies declaration sites, and citty +still would not forward a parent's parsed value to a child. + +### D2 — `run build` and `db teardown` declare and honour `--dry-run` + +Both omit `sharedArgs.dryRun` while their docs promise it +(`docs/cli/run.md:26`, `docs/guide/database/teardown.md:255`). The core gate +(`src/core/runner/runner.ts:608`) already works and is reached by five sibling commands; +`build` simply never sets the flag. Threading it through `BuildOptions` +(`src/sdk/types.ts:82-88`) reaches live code with no new branch. + +### D3 — `db create` / `db drop` resolve through `resolveConfig` + +Replace the raw `stateManager.getConfig` read with the canonical resolver, matching +`run build`. No new precedence logic — the merge already exists and is tested +(`tests/core/config/resolver.test.ts:234-260`). + +### D4 — Logger writers swap by purpose, not by mode + +Command results → stdout. Logger event stream → stderr, in both text and `--json` mode. +`change list`/`history` build their text and pass it to `outputResult` unconditionally, +matching the commands that already get this right (`src/cli/config/list.ts:40,59-63`). + +An empty result set prints an explicit empty-state line, so "no changesets" is never +indistinguishable from "no output". + +### D5 — Secrets fail hard, and the vault tier gets connected + +Two parts, both required. Failing hard alone would turn the reporter's broken login into a +loud error — correct, but they *had* set the secret via `vault set` as the docs instruct, so +it must also resolve. + +- `$.secrets` becomes a Proxy that throws on unknown-key access, naming the key and the + tiers searched. +- `sqlQuote` rejects `undefined` rather than stringifying it — defence in depth for any + other path that reaches it. +- The render path calls `buildSecretsContext` instead of `getAllSecrets`, so the documented + three tiers actually apply. + +Optional probing needs an explicit form. `$.secrets.KEY ?? default` cannot survive a +throwing `get` trap — `??` evaluates its left operand, which is the read that throws. So the +contract is: **read throws, `in` does not.** A template probing for an optional secret writes + + KEY in $.secrets ? $.secrets.KEY : default + +That is a feature, not a workaround. `?? default` silently accepts a missing secret, which +is the exact coercion that turned an unresolved password into the string `undefined`. +Requiring `in` makes "this secret is optional" a statement the template author wrote on +purpose, and leaves every unguarded read loud. + +`examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl` used the `?.[key] ?? null` form to +force a flag off when its secret is absent — a legitimate intent, expressed the unsafe way. +It moves to the `in` guard. + +### D6 — `change add` scaffolds SQL stubs + +`createChange` (`src/core/change/scaffold.ts:111-130`) already creates `change/` and +`revert/` directories plus `changelog.md`, but leaves both directories empty. An empty +changeset is not merely inert — `parseChange` throws +`ChangeValidationError` (`src/core/change/parser.ts:117`), which `#loadChange` +(`src/core/change/manager.ts:624`) converts to `ChangeNotFoundError`. The user is told the +change does not exist, when it does. + +Scaffold `change/001_.sql` and `revert/001_.sql` as commented stubs. + +`docs/headless.md:885` currently documents this as creating `change.sql` and `revert.sql` — +files, not directories. It is wrong today and must be corrected regardless. + +### D7 — Docs corrections are part of the fix, not follow-up + +Three doc statements are actively false and each one cost the reporter time: + +- `docs/dev/secrets.md:113,151-153` — claims there is no headless `noorm secret set` and + directs users to `noorm vault set` instead. `src/cli/secret/set.ts:12-65` is fully + headless, and `docs/guide/environments/secrets.md:27-40` documents it correctly. The dev + doc contradicts both the code and its own user guide. +- `docs/headless.md:885` — wrong `change add` output shape (D6). +- `docs/headless.md:55,641` — `noorm --dry-run run build`, a form that never works; + `--dry-run` is per-subcommand only, matching `--config`/`--force` (D1). + +`noorm secret` also has no `docs/cli/` page while `noorm vault` has two. + +## Out of scope + +- Changing the changeset directory convention. `change/` + `revert/` stays (issue #53 says so). +- Reworking the vault identity/enrolment model. D5 connects the existing resolver; it does + not redesign key distribution. +- The 18 pre-existing test failures on `next` (test-double gaps in + `tests/sdk/run-build-filtering.test.ts`, `tests/sdk/context.test.ts`, `tests/rpc/*`). + D5 touches `src/sdk/namespaces/run.ts:255`, which is the line those doubles trip on, so + they are repaired as a side effect — but they are not the goal. + +## Open question — #54 + +Filed as "not reliably reproducible", with the reporter explicitly asking to rule double +execution in or out at the source rather than chase the flake. Investigation pending; +this section is completed before the checkpoint that addresses it. + +## Change log + +### 2026-07-25 — D1 reversed: only `-c`/`--cwd` hoisted + +**What changed:** `--dry-run`, `--json`, `--yes` are no longer hoisted to any position. Only +`-c`/`--cwd` is a root-level flag; every other flag, including these three, is per-subcommand +and errors if placed before the subcommand — exactly like `--config`/`--force` always did. + +**Why:** the original D1 created an asymmetry with no principled justification: three flags +worked in either position while `--config`/`--force` never did, for no reason beyond "these +three seemed worth the convenience." The standard is now uniform — a flag goes on the command +that uses it — with `-c`/`--cwd` as the sole exception, because it is consumed before dispatch +and its short form collides with `--config` after the subcommand. + +**Superseded:** the original D1 hoisted `--dry-run`, `--json`, `--yes` out of `rawArgs` before +citty dispatch and re-injected them into the leaf command's parsed args, the same mechanism +`-c`/`--cwd` used. diff --git a/docs/dev/README.md b/docs/dev/README.md deleted file mode 100644 index d72e78a4..00000000 --- a/docs/dev/README.md +++ /dev/null @@ -1,201 +0,0 @@ -# noorm Documentation - - -## What is noorm? - -noorm is a database schema and change manager. It tracks which SQL files have run, manages database credentials securely, and coordinates team access to shared databases. - -Unlike ORMs that abstract away SQL, noorm embraces it. You write SQL for your target database. noorm handles the plumbing: tracking execution, managing credentials, coordinating team access. - - -## Core Concepts - - -### Identity - -Every database operation records who performed it. noorm supports two identity modes: - -- **Audit identity** - Simple name/email for tracking (auto-detected from git) -- **Cryptographic identity** - X25519 keypair for secure credential sharing - -[Read more about Identity](./identity.md) - - -### State - -All sensitive data lives in an encrypted state file. This includes database credentials, secrets for SQL templates, and your cryptographic identity. - -- AES-256-GCM encryption -- Identity-based or machine-based key derivation -- Automatic schema migrations - -[Read more about State Management](./state.md) - - -### Configuration - -Configs define database connections. They merge from multiple sources with clear precedence: - -``` -CLI flags > Environment > Stored config > Stage defaults > Defaults -``` - -Per-channel access roles (`viewer`/`operator`/`admin`) gate dangerous operations. Stages enforce team-wide constraints. - -[Read more about Configuration](./config.md) - - -## Quick Start - -```typescript -import { StateManager } from './core/state' -import { resolveConfig } from './core/config' -import { resolveIdentity } from './core/identity' - -// Load encrypted state -const state = new StateManager(process.cwd()) -await state.load() - -// Resolve current identity -const identity = await resolveIdentity() -console.log(`Running as: ${identity.name}`) - -// Get active config -const config = resolveConfig(state) -if (!config) { - console.error('No config found. Run: noorm config add') - process.exit(1) -} - -console.log(`Using database: ${config.connection.database}`) -``` - - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ CLI (Ink/React) │ -│ Commands, UI components, user interaction │ -└────────────────────────────┬────────────────────────────┘ - │ subscribes to events - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Observer (Events) │ -│ file:*, build:*, config:*, state:*, template:*, lock:* │ -│ teardown:*, sql-terminal:*, change:*, explore:* │ -└────────────────────────────┬────────────────────────────┘ - │ emits events - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Core Modules │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ -│ │ Identity │ │ State │ │ Config │ │ Lock │ │ -│ │ X25519 │ │ Encrypted│ │ Merge & │ │Concur- │ │ -│ │ Keypairs │ │ Storage │ │ Validate │ │ rency │ │ -│ └──────────┘ └──────────┘ └──────────┘ └────────┘ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ -│ │ Template │ │ Runner │ │Change │ │ Logger │ │ -│ │ Eta, SQL │ │ Execute, │ │ Versioned│ │ Events │ │ -│ │ Helpers │ │ Tracking │ │ Changes │ │Rotation│ │ -│ └──────────┘ └──────────┘ └──────────┘ └────────┘ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Explore │ │ Teardown │ │ SQL │ │ -│ │ Schema │ │ Truncate │ │ Terminal │ │ -│ │ Browser │ │ & Drop │ │ REPL │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -Core modules emit events. The CLI subscribes. This keeps business logic separate from UI concerns. - - -## File Structure - -``` -.noorm/ -├── state.enc # Encrypted configs, secrets, identity (gitignored) -├── settings.yml # Build rules, stages (version controlled) -├── noorm.log # Operation log (gitignored) -└── sql-history/ # SQL terminal history (gitignored) - ├── dev.json # History index per config - └── dev/ # Gzipped query results - └── *.results.gz - -~/.noorm/ -├── identity.key # Private key (mode 600) -└── identity.pub # Public key (mode 644) -``` - - -## Documentation Index - - -### Core - -| Document | Description | -|----------|-------------| -| [Data Model](./datamodel.md) | Complete type reference, database schemas, file formats | -| [Identity](./identity.md) | Audit tracking, cryptographic identity, secure sharing | -| [State](./state.md) | Encrypted storage, configs, secrets, persistence | -| [Config](./config.md) | Resolution, validation, protection, stages | -| [Secrets](./secrets.md) | Encrypted secrets, required vs optional, CLI workflow | -| [Settings](./settings.md) | Build rules, stages, project-wide behavior | - - -### Execution - -| Document | Description | -|----------|-------------| -| [Runner](./runner.md) | SQL execution, change detection, dry run, preview | -| [Change](./change.md) | Versioned changes, forward/rollback, execution history | -| [Template](./template.md) | Eta templating, data loading, helper inheritance | -| [Lock](./lock.md) | Concurrent operation protection, table-based locking | - - -### Database Tools - -| Document | Description | -|----------|-------------| -| [Explore](./explore.md) | Schema introspection, browse tables/views/functions | -| [Teardown](./teardown.md) | Data truncation, schema teardown, reset operations | -| [SQL Terminal](./sql-terminal.md) | Interactive SQL REPL, query history, result storage | - - -### Operations - -| Document | Description | -|----------|-------------| -| [Logger](./logger.md) | File logging, log viewer, event classification, rotation | - - -## Key Patterns - -**Error Handling** - Uses `attempt`/`attemptSync` from `@logosdx/utils` instead of try-catch: - -```typescript -const [result, err] = await attempt(() => dangerousOperation()) -if (err) { - observer.emit('error', { source: 'module', error: err }) - return -} -``` - -**Events** - Core modules emit, CLI subscribes: - -```typescript -// In core module -observer.emit('config:created', { name }) - -// In CLI -observer.on('config:created', ({ name }) => { - console.log(`Created: ${name}`) -}) -``` - -**Layered Configuration** - Multiple sources merge predictably: - -```typescript -// defaults ← stage ← stored ← env ← flags -const config = resolveConfig(state, { flags: { connection: { port: 5433 } } }) -``` diff --git a/docs/dev/change.md b/docs/dev/change.md index 01358c01..c503305c 100644 --- a/docs/dev/change.md +++ b/docs/dev/change.md @@ -156,15 +156,19 @@ const change = await createChange('/project/changes', { description: 'add-user-preferences', date: new Date(), // Optional, defaults to today }) -// Creates: 2025-12-18-add-user-preferences/ +// Creates: 2025-12-18-add-user-preferences/, already seeded with a runnable +// stub in each folder: change/001_add-user-preferences.sql and +// revert/001_add-user-preferences.sql (a comment naming what belongs there). +// An empty change/+revert/ pair fails validation, so createChange never +// returns one — see "Scaffolding a Runnable Stub" below. -// Add a SQL file +// Add a SQL file — sequence numbers continue from the stub, so this is 002 const updated = await addFile(change, 'change', { name: 'create-preferences-table', type: 'sql', content: 'CREATE TABLE user_preferences (...);', }) -// Creates: change/001_create-preferences-table.sql +// Creates: change/002_create-preferences-table.sql // Add a manifest file await addFile(updated, 'change', { @@ -172,13 +176,26 @@ await addFile(updated, 'change', { type: 'txt', paths: ['tables/preferences.sql', 'views/user_prefs.sql'], }) -// Creates: change/002_schema-refs.txt +// Creates: change/003_schema-refs.txt // Remove a file -await removeFile(change, 'change', '001_create-preferences-table.sql') +await removeFile(change, 'change', '001_add-user-preferences.sql') ``` +### Scaffolding a Runnable Stub + +`createChange` doesn't leave `change/` and `revert/` empty. An empty pair fails +`parseChange`'s validation, and the caller sees that misreported as "change not found" +rather than "needs editing" — so `createChange` scaffolds one stub file into each folder via +`addFile`, using the change's own slug as the filename: `001_.sql` in `change/`, +`001_.sql` in `revert/`. Each stub is a single comment naming what belongs there +("Add the SQL statements that apply this change" / "...undo this change"). A stub full of +comments still isn't runnable content — `executeChange` rejects files that are empty or +contain only comments/template placeholders, with a message that says so, before it ever +executes anything. + + ## Change Status Each change has a status based on its execution history: diff --git a/docs/dev/ci.md b/docs/dev/ci.md index 722cb8a9..814bec79 100644 --- a/docs/dev/ci.md +++ b/docs/dev/ci.md @@ -190,8 +190,8 @@ Same pipeline, but templates need vault-decrypted secrets: Use `--json` for structured output: ```bash -noorm --json ci init | jq '.stateFile' -noorm --json run build | jq '.status' +noorm ci init --json | jq '.stateFile' +noorm run build --json | jq '.status' ``` diff --git a/docs/dev/config.md b/docs/dev/config.md index ab8cc2a0..920e9f4a 100644 --- a/docs/dev/config.md +++ b/docs/dev/config.md @@ -112,7 +112,6 @@ NOORM_{PATH}_{TO}_{VALUE} → { path: { to: { value: '' } } } |----------|---------| | `NOORM_CONFIG` | Which config to use | | `NOORM_YES` | Skip confirmations | -| `NOORM_JSON` | JSON output mode | ```bash # CI/CD example: use stored config with overridden host diff --git a/docs/dev/headless.md b/docs/dev/headless.md index 86c3a29e..0eca21c7 100644 --- a/docs/dev/headless.md +++ b/docs/dev/headless.md @@ -30,7 +30,7 @@ Not every command accepts every flag — append `--help` to any command to see t **Example:** ```bash -noorm --json --config prod change ff +noorm change ff --config prod --json noorm change ff --help # Per-command help, rendered by citty ``` @@ -51,7 +51,7 @@ If you've already set an active config (via `noorm config use ` or through ```bash # These are equivalent if 'dev' is the active config noorm change ff -noorm --config dev change ff +noorm change ff --config dev ``` @@ -61,7 +61,7 @@ Specify a config by name using `--config` or the `NOORM_CONFIG` env var: ```bash # Via flag -noorm --config production change ff +noorm change ff --config production # Via env var export NOORM_CONFIG=production @@ -120,7 +120,7 @@ Execute all SQL files in the schema directory. ```bash noorm run build -noorm --force run build # Skip checksums +noorm run build --force # Skip checksums ``` **JSON output:** @@ -386,7 +386,7 @@ Colored console output with status icons: Use `--json` for machine-readable output: ```bash -noorm --json change ff | jq '.executed' +noorm change ff --json | jq '.executed' ``` JSON mode disables colors and outputs structured data. @@ -436,7 +436,7 @@ jobs: run: noorm change ff - name: Export schema (optional) - run: noorm --json -c prod db explore > schema.json + run: noorm -c prod db explore --json > schema.json ``` @@ -463,7 +463,7 @@ migrate: stage: deploy script: - npm ci - - noorm --config production change ff + - noorm change ff --config production only: - main environment: @@ -480,7 +480,7 @@ set -e CONFIG="${1:-}" # Optional, falls back to active config echo "Checking for pending changes..." -PENDING=$(noorm --json ${CONFIG:+-c "$CONFIG"} change | jq '[.[] | select(.status=="pending")] | length') +PENDING=$(noorm ${CONFIG:+-c "$CONFIG"} change --json | jq '[.[] | select(.status=="pending")] | length') if [ "$PENDING" -gt 0 ]; then echo "Applying $PENDING pending changes..." @@ -516,7 +516,7 @@ noorm change ff 2. **Use `--json` for scripting** - Easier to parse than text output ```bash - noorm --json change | jq '.[] | select(.status=="pending")' + noorm change --json | jq '.[] | select(.status=="pending")' ``` 3. **Check exit codes** - Non-zero means failure @@ -533,7 +533,7 @@ noorm change ff 6. **Test with `--dry-run`** - Preview operations before executing ```bash - noorm --dry-run change ff + noorm change ff --dry-run ``` 7. **Capture logs** - noorm appends to `.noorm/state/noorm.log` for debugging @@ -692,7 +692,9 @@ Status listing previously lived on the bare `change` handler -- it was moved to ### TUI-Only Wizards -Some commands (`config add`, `config edit`, `config rm`) don't have a sensible headless equivalent — they exist to walk users through credential entry interactively. These commands `import { app } from '../../tui/app.js'` and route the user into the appropriate TUI screen, then exit. See `src/cli/config/add.ts` for the canonical pattern. +Some commands (`config add`, `config edit`) don't have a sensible headless equivalent — they exist to walk users through credential entry interactively. Rather than importing the TUI, they print `Interactive only — run: noorm ui` to stderr and exit 1, leaving the user to launch the TUI themselves. See `src/cli/config/add.ts` for the canonical pattern. + +`config rm` used to live in this bucket but is now real and headless: bootstrap state (`initState`/`getStateManager`), gate the deletion through `checkConfigPolicy` (denies or requires `--yes`/`NOORM_YES` per the config's `user`-channel role for `config:rm`), then call `StateManager.deleteConfig()` — the same core path the TUI's removal screen uses, so the locked-stage guard applies identically. See `src/cli/config/rm.ts`. ### Registering Commands at the Root diff --git a/docs/dev/project-discovery.md b/docs/dev/project-discovery.md index 05d0c64f..5db32448 100644 --- a/docs/dev/project-discovery.md +++ b/docs/dev/project-discovery.md @@ -91,12 +91,12 @@ async function main(): Promise { If no project is found and the user is heading to the home screen, they're redirected to the init screen to create a project. -## SDK Usage +## Internal Usage Without `chdir` -SDKs might want discovery without the automatic `chdir`: +Internal callers (this module lives at `src/core/project.ts` — it is not exported from the published `@noormdev/sdk` package) might want discovery without the automatic `chdir`: ```typescript -import { findProjectRoot } from 'noorm/core' +import { findProjectRoot } from './core/project.js' // Find project root const { projectRoot, hasProject } = findProjectRoot() diff --git a/docs/dev/sdk.md b/docs/dev/sdk.md index 6ce4c5c3..046d1f4b 100644 --- a/docs/dev/sdk.md +++ b/docs/dev/sdk.md @@ -13,17 +13,17 @@ The noorm SDK provides programmatic access to noorm-managed databases. Use it fo ## Installation -The SDK is part of the main noorm package: +The SDK is published as a standalone `@noormdev/sdk` package: -```typescript -import { createContext } from 'noorm/sdk' +```bash +pnpm add @noormdev/sdk ``` ## Quick Start ```typescript -import { createContext } from 'noorm/sdk' +import { createContext } from '@noormdev/sdk' // Create a typed context for the 'dev' config const ctx = await createContext<{ users: { id: number; name: string } }>({ @@ -90,11 +90,9 @@ interface CreateContextOptions { } ``` -> **Finding the project root**: Unlike the CLI, the SDK does not automatically walk up directories to find the project. Pass `projectRoot` explicitly, or use [Project Discovery](./project-discovery.md) to find it first: +> **Finding the project root**: `projectRoot` defaults to `process.cwd()`. That's correct when your script runs from the project directory. When running from elsewhere (a monorepo tooling package, a CI step with a different working directory), pass the directory containing `.noorm/` explicitly: > ```typescript -> import { findProjectRoot } from 'noorm/core' -> const { projectRoot } = findProjectRoot() -> const ctx = await createContext({ projectRoot }) +> const ctx = await createContext({ projectRoot: '/path/to/project' }) > ``` ```typescript @@ -677,7 +675,7 @@ Encrypted team secrets stored in the database. All operations require a connecti Initialize the vault for this database. ```typescript -const [vaultKey, err] = await ctx.noorm.vault.init() +const vaultKey = await ctx.noorm.vault.init() ``` ##### `vault.status()` @@ -693,7 +691,7 @@ const status = await ctx.noorm.vault.status() Set a vault secret. ```typescript -const [, err] = await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey) +await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey) ``` ##### `vault.get(key, privateKey)` @@ -725,7 +723,7 @@ const keys = await ctx.noorm.vault.list() Delete a vault secret. ```typescript -const [deleted, err] = await ctx.noorm.vault.delete('OLD_KEY') +const deleted = await ctx.noorm.vault.delete('OLD_KEY') ``` ##### `vault.exists(key)` @@ -749,7 +747,7 @@ const result = await ctx.noorm.vault.propagate(privateKey) Copy vault secrets to another config's database. ```typescript -const [result, err] = await ctx.noorm.vault.copy(destConfig, ['API_KEY'], privateKey) +const result = await ctx.noorm.vault.copy(destConfig, ['API_KEY'], privateKey) ``` @@ -787,7 +785,7 @@ const dest = await createContext({ config: 'dev' }) await source.connect() await dest.connect() -const [result, err] = await source.noorm.transfer.to(dest.noorm.config, { +const result = await source.noorm.transfer.to(dest.noorm.config, { tables: ['users', 'posts'], onConflict: 'skip', }) @@ -801,10 +799,8 @@ await dest.disconnect() Generate a transfer plan without executing. ```typescript -const [plan, err] = await source.noorm.transfer.plan(dest.noorm.config) -if (plan) { - console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`) -} +const plan = await source.noorm.transfer.plan(dest.noorm.config) +console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`) ``` @@ -815,7 +811,7 @@ if (plan) { Export a table to a .dt file. Extension determines format: `.dt`, `.dtz` (gzipped), `.dtzx` (encrypted). ```typescript -const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.dtz') +const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz') ``` ##### `dt.importFile(filepath, options?)` @@ -823,7 +819,7 @@ const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.d Import a .dt file into the connected database. ```typescript -const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', { +const result = await ctx.noorm.dt.importFile('./exports/users.dtz', { onConflict: 'skip', }) ``` @@ -831,14 +827,16 @@ const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', { #### Event Subscriptions -Subscribe to core events via the observer: +Subscribe to core events via the process-global `noormObserver` bus: ```typescript -ctx.noorm.observer.on('file:after', (event) => { +import { noormObserver } from 'noorm/sdk' + +noormObserver.on('file:after', (event) => { console.log(`Executed ${event.filepath} in ${event.durationMs}ms`) }) -ctx.noorm.observer.on('change:complete', (event) => { +noormObserver.on('change:complete', (event) => { console.log(`Change ${event.name}: ${event.status}`) }) ``` @@ -850,7 +848,7 @@ ctx.noorm.observer.on('change:complete', (event) => { ### Test Suites (Jest/Vitest) ```typescript -import { createContext, Context } from 'noorm/sdk' +import { createContext, Context } from '@noormdev/sdk' describe('User API', () => { let ctx: Context @@ -887,7 +885,7 @@ describe('User API', () => { ### Scripts and Tooling ```typescript -import { createContext } from 'noorm/sdk' +import { createContext } from '@noormdev/sdk' // Data export script const ctx = await createContext({ config: 'prod' }) @@ -907,7 +905,7 @@ await ctx.disconnect() ### Type Generation ```typescript -import { createContext } from 'noorm/sdk' +import { createContext } from '@noormdev/sdk' const ctx = await createContext({ config: 'dev' }) await ctx.connect() @@ -925,7 +923,7 @@ await ctx.disconnect() ### CI/CD Pipeline ```typescript -import { createContext } from 'noorm/sdk' +import { createContext } from '@noormdev/sdk' const ctx = await createContext({ config: process.env.DB_CONFIG }) await ctx.connect() @@ -948,36 +946,36 @@ await ctx.disconnect() ## Error Handling +SDK methods throw named, `instanceof`-matchable errors and let them propagate — no `[value, error]` +tuples on the `ctx.noorm.*` surface. Wrap a call in `attempt()` (from `@logosdx/utils`) only when +you'll do something with the error (translate, recover, observe, knowingly ignore); otherwise let it +propagate. Never use try-catch. Carve-outs that don't follow that pattern: +`ctx.noorm.utils.testConnection()` returns `{ ok, error? }` by design (failure as data, not an +exception); `ctx.transaction(...)` callbacks must throw to trigger Kysely's rollback. + ```typescript +import { attempt } from '@logosdx/utils' import { createContext, RequireTestError, ProtectedConfigError, LockAcquireError, -} from 'noorm/sdk' +} from '@noormdev/sdk' -try { - const ctx = await createContext({ config: 'prod', requireTest: true }) -} catch (err) { - if (err instanceof RequireTestError) { - console.error('Cannot use production config in tests') - } +// attempt() used deliberately — inspect the named error and react. +const [ctx, err] = await attempt(() => createContext({ config: 'prod', requireTest: true })) +if (err instanceof RequireTestError) { + console.error('Cannot use production config in tests') } -try { - await ctx.noorm.db.truncate() -} catch (err) { - if (err instanceof ProtectedConfigError) { - console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give') - } +const [, truncateErr] = await attempt(() => ctx.noorm.db.truncate()) +if (truncateErr instanceof ProtectedConfigError) { + console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give') } -try { - await ctx.noorm.lock.acquire() -} catch (err) { - if (err instanceof LockAcquireError) { - console.error(`Lock held by ${err.holder}`) - } +const [, lockErr] = await attempt(() => ctx.noorm.lock.acquire()) +if (lockErr instanceof LockAcquireError) { + console.error(`Lock held by ${lockErr.holder}`) } ``` @@ -991,19 +989,19 @@ Every `noorm` subcommand runs as a non-interactive CLI by default — there is n ```bash # Build schema -noorm --config dev run build +noorm run build --config dev # Fast-forward changes -noorm --config dev change ff +noorm change ff --config dev # Apply single change -noorm --config dev change run 2024-01-15-add-users +noorm change run 2024-01-15-add-users --config dev # Truncate database -noorm --config test db truncate +noorm db truncate --config test # JSON output for scripting -noorm --json --config dev change ff | jq '.status' +noorm change ff --config dev --json | jq '.status' ``` @@ -1057,7 +1055,7 @@ jobs: - run: npm ci - name: Apply changes run: | - npx noorm --config ${{ vars.DB_CONFIG }} change ff + npx noorm change ff --config ${{ vars.DB_CONFIG }} env: DB_HOST: ${{ secrets.DB_HOST }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} diff --git a/docs/dev/secrets.md b/docs/dev/secrets.md index 5ef14447..970578ef 100644 --- a/docs/dev/secrets.md +++ b/docs/dev/secrets.md @@ -102,20 +102,30 @@ Masked previews show the secret length and first few characters (in verbose mode ### Setting Secrets -Local secret values are managed through the TUI (`noorm ui` → Settings → Secrets → `a`). The set screen: +Config-scoped local secret values are managed two ways: -1. Shows missing required secrets as suggestions -2. Accepts any key name (UPPER_SNAKE_CASE recommended) -3. Uses masked input for password/api_key types -4. Validates connection_string as URI -5. Warns before overwriting existing values +- **TUI** — `noorm ui` → Settings → Secrets → `a`. The set screen shows missing required + secrets as suggestions, accepts any key name (UPPER_SNAKE_CASE recommended), masks input + for password/api_key types, validates connection_string as a URI, and warns before + overwriting an existing value. +- **CLI** — `noorm secret set ` (`src/cli/secret/set.ts`) writes the same + encrypted record headlessly, scoped to the active or `--config`-named config. The value is + a positional argument, so unlike the TUI it does land in shell history — reach for the + vault instead (below) if that's a concern. -There is no `noorm secret set` headless command — values flow through the interactive form on purpose, so they never leak into shell history or process listings. +```bash +noorm secret set API_KEY "sk-live-..." +noorm secret set DB_PASSWORD "secret123" --config prod +``` ### Deleting Secrets -From the same Settings → Secrets screen, highlight the entry and press `d`. Required secrets cannot be deleted—only updated. When you try to delete a required secret, the TUI shows a warning toast indicating whether it's a **universal** or **stage-specific** secret: +Through the TUI, from the same Settings → Secrets screen, highlight the entry and press +`d`. Headlessly, `noorm secret rm --yes` removes a config-scoped local secret +(`--yes` is required to prevent accidental deletion). Required secrets cannot be +deleted—only updated. When you try to delete a required secret through the TUI, it shows a +warning toast indicating whether it's a **universal** or **stage-specific** secret: - `"DB_PASSWORD" is a universal secret and cannot be deleted` - `"API_KEY" is a stage secret and cannot be deleted` @@ -150,16 +160,19 @@ These screens edit `settings.yml` directly. The actual secret values are set on ## CI/CD Pipelines -The local secret store is intentionally TUI-only — there is no headless `noorm secret` command, and values must never be passed as raw arguments. For non-interactive pipelines, push secrets through the [vault](../guide/environments/vault.md) instead, which is fully scriptable: +`noorm secret set` is headless, but it's still the wrong tool for CI: it writes to the +per-developer local store on disk, which is exactly what a fresh CI runner doesn't have. For +non-interactive pipelines, push secrets through the [vault](../guide/environments/vault.md) +instead — team-shared, stored in the database, and reachable from a clean checkout: ```bash noorm vault set DB_PASSWORD "$DB_PASSWORD" echo "$API_KEY" | noorm vault set API_KEY # Pipe to avoid process listings -noorm --json vault list # Inspect what's stored +noorm vault list --json # Inspect what's stored noorm vault rm OLD_API_KEY ``` -The vault sits below local secrets in the resolution hierarchy (`$.secrets.KEY` checks local-config → global-local → vault), so individual developers can still override a vault value through the TUI without affecting the team. +The vault sits below local secrets in the resolution hierarchy (`$.secrets.KEY` checks local-config → global-local → vault), so individual developers can still override a vault value through the TUI or `noorm secret set` without affecting the team. ## Using Secrets in Templates @@ -274,7 +287,7 @@ const globalKeys = state.listGlobalSecrets() // ['API_KEY'] await state.deleteGlobalSecret('API_KEY') ``` -> **Note:** Global secret values (stored in state) are managed only via the StateManager API or the TUI's Settings → Secrets screen. There is no headless `noorm secret` command — values flow through the interactive form on purpose so they never reach shell history. Universal and per-stage secret *definitions* live in `settings.yml` and are edited from `noorm ui` → Settings → Secrets / Stages. +> **Note:** Global secret values (stored in state) are managed only via the StateManager API or the TUI's Settings → Secrets screen. `noorm secret set|list|rm` is config-scoped only — there is no headless command for the global store. Universal and per-stage secret *definitions* live in `settings.yml` and are edited from `noorm ui` → Settings → Secrets / Stages. See [State Management](./state.md) for complete StateManager documentation. diff --git a/docs/dev/template.md b/docs/dev/template.md index 2c1c639a..cdb1dd2d 100644 --- a/docs/dev/template.md +++ b/docs/dev/template.md @@ -162,11 +162,34 @@ The `$` object contains everything available in templates: | `$.` | Functions from inherited `$helpers.ts` files | | `$.` | Auto-loaded data from co-located files | | `$.config` | Active configuration object | -| `$.secrets` | Decrypted secrets for active config | +| `$.secrets` | Decrypted secrets for active config. Reading an unresolved key throws `MissingSecretError` naming the key and the tiers searched (config-local, global-local, vault) — see [Secret Access](#secret-access). | | `$.globalSecrets` | Decrypted global secrets | | `$.env` | Environment variables | +### Secret Access + +`$.secrets` is a `Proxy`, not a plain object (`src/core/template/context.ts`). Its `get` trap +throws on any key not present in the merged result; its `has` trap does not, so `in` is the +only safe way to check for an optional secret before reading it: + +```typescript +'API_KEY' in $.secrets // false — does not throw +$.secrets.API_KEY // throws MissingSecretError if unresolved +$.secrets.API_KEY ?? 'x' // still throws — `??` evaluates the left operand first +'API_KEY' in $.secrets ? $.secrets.API_KEY : 'x' // the correct optional-read form +``` + +`Object.keys`, object spread, and `JSON.stringify` never trigger `get` for an absent key — +they resolve through `ownKeys`/`getOwnPropertyDescriptor`, which the proxy leaves at the +default (forwarded to the underlying record) — so none of them throw. + +`sqlQuote` (`src/core/template/utils.ts`) throws `UndefinedSqlValueError` on `undefined` for +the same reason: an `undefined` reaching SQL rendering is always an upstream bug (a missing +secret, a bad lookup), never an intentional value. `null` is unaffected and still quotes to +`NULL`. + + ### Built-in Helpers | Helper | Description | diff --git a/docs/dev/transfer.md b/docs/dev/transfer.md index 97324e61..f37c7474 100644 --- a/docs/dev/transfer.md +++ b/docs/dev/transfer.md @@ -200,6 +200,8 @@ Foreign key checks are disabled on the destination before transfer and re-enable | MySQL | `SET FOREIGN_KEY_CHECKS = 0` (session-wide) | `SET FOREIGN_KEY_CHECKS = 1` | | MSSQL | `ALTER TABLE ... NOCHECK CONSTRAINT ALL` (per table) | `ALTER TABLE ... CHECK CONSTRAINT ALL` | +If the post-transfer re-enable fails, the transfer itself still completes, but `TransferResult.fkChecksRestored` is `false` and the CLI prints a warning. Re-enable FK checks manually before trusting referential integrity on the destination. + ## Cross-Dialect Transfers @@ -453,6 +455,14 @@ interface TransferResult { /** Total duration in milliseconds */ durationMs: number + /** + * Whether FK checks were re-enabled on the destination. + * false only when checks were disabled for this transfer and the + * re-enable attempt failed. status does not flip on a failed + * FK restore, so check this field after every transfer. + */ + fkChecksRestored: boolean + } interface TransferTableResult { @@ -569,7 +579,7 @@ noorm db transfer --to backup --dry-run noorm db transfer --to backup --truncate # JSON output for scripting -noorm --json db transfer --to backup +noorm db transfer --to backup --json # Export single table to .dt file noorm db transfer --export ./backup/users.dt --tables users diff --git a/docs/dev/vault.md b/docs/dev/vault.md index 2c93bc17..2e336fc4 100644 --- a/docs/dev/vault.md +++ b/docs/dev/vault.md @@ -261,8 +261,7 @@ The `vault:initialized` observer event fires only on first init — repeat calls Typical call-site pattern at the SDK boundary: ```typescript -const [vaultKey, err] = await ctx.noorm.vault.init(); -if (err) throw err; +const vaultKey = await ctx.noorm.vault.init(); if (vaultKey) { // First-time init — seed initial team secrets. diff --git a/docs/getting-started/building-your-sdk.md b/docs/getting-started/building-your-sdk.md index 2e58f716..e3aba39f 100644 --- a/docs/getting-started/building-your-sdk.md +++ b/docs/getting-started/building-your-sdk.md @@ -370,7 +370,7 @@ export class Client { */ async reset(): Promise { - await this.#ctx.reset(); + await this.#ctx.noorm.db.reset(); } @@ -379,7 +379,7 @@ export class Client { */ async truncate(): Promise { - await this.#ctx.truncate(); + await this.#ctx.noorm.db.truncate(); } diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index a616a088..929dd884 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -39,6 +39,21 @@ NOORM_INSTALL_DIR=~/my-tools curl -fsSL https://noorm.dev/install.sh | sh Download [noorm-windows-x64.exe](https://github.com/noormdev/noorm/releases) from the latest `@noormdev/cli` release, rename it to `noorm.exe`, and add it to your PATH. +### Integrity verification + +Every install path verifies the downloaded binary before it runs. The release publishes a `checksums.txt` alongside the binaries, and the installer (`install.sh`), the npm `postinstall` step, and `noorm update` each recompute the binary's SHA-256 and compare it against that file before making the binary executable. A mismatch aborts — a corrupted download or a tampered release asset never gets run. + +Verification is fail-closed: if `checksums.txt` can't be fetched (an offline mirror, a network block, an older release without it), the install stops rather than trusting an unverified binary. To override that one case — and only that case — set `NOORM_INSECURE=1`: + +```bash +NOORM_INSECURE=1 curl -fsSL https://noorm.dev/install.sh | sh + +# self-update equivalent +noorm update --insecure +``` + +`NOORM_INSECURE` skips verification only when the checksums can't be reached. A confirmed hash mismatch always fails, escape hatch or not — the flag can't wave through a binary that has actually been altered. + ### Verify ```bash diff --git a/docs/guide/automation/ci.md b/docs/guide/automation/ci.md index 7f1ba36e..e1d3dc31 100644 --- a/docs/guide/automation/ci.md +++ b/docs/guide/automation/ci.md @@ -295,7 +295,7 @@ noorm lock acquire trap "noorm lock release" EXIT noorm change ff -noorm --json db explore +noorm db explore --json ``` diff --git a/docs/guide/changes/forward-revert.md b/docs/guide/changes/forward-revert.md index 1a63a84c..3474898d 100644 --- a/docs/guide/changes/forward-revert.md +++ b/docs/guide/changes/forward-revert.md @@ -18,7 +18,7 @@ Apply every pending change in chronological order: **Headless:** ```bash noorm change ff -noorm --json change ff +noorm change ff --json ``` Fast-forward is the workhorse for deployments. It finds all unapplied changes, sorts them by date, and executes each one in sequence. If any change fails, execution stops immediately. @@ -233,7 +233,7 @@ noorm change revert 2024-02-15-broken-change After running `db teardown`, all changes are marked as `stale`. The next fast-forward re-applies everything: ```bash -noorm -y db teardown +noorm db teardown -y noorm run build # Rebuild base schema noorm change ff # Re-apply all changes ``` @@ -272,7 +272,7 @@ noorm run build noorm change ff # Verify the result -noorm --json db explore +noorm db explore --json ``` The build step handles the initial schema. The fast-forward applies any changes that have been merged since the last deploy. diff --git a/docs/guide/changes/history.md b/docs/guide/changes/history.md index bb43ef3b..a4cdd2ce 100644 --- a/docs/guide/changes/history.md +++ b/docs/guide/changes/history.md @@ -123,7 +123,7 @@ Execution History: 20 records With JSON output for parsing: ```bash -noorm --json change history +noorm change history --json ``` ```json @@ -286,7 +286,7 @@ Now you know the issue---there's duplicate data that needs cleaning before the c Compare with successful runs of the same change on other environments. Did something change between runs? Did the data differ? ```bash -noorm --json change history --count 100 | jq '.[] | select(.name == "2025-01-16-add-payments")' +noorm change history --count 100 --json | jq '.[] | select(.name == "2025-01-16-add-payments")' ``` diff --git a/docs/guide/changes/overview.md b/docs/guide/changes/overview.md index e71903f9..a77289a1 100644 --- a/docs/guide/changes/overview.md +++ b/docs/guide/changes/overview.md @@ -90,16 +90,39 @@ The three-digit prefix guarantees execution order. Names after the underscore ar ## Creating a Change -Change creation is wizard-driven. Run `noorm ui`, press `g` from home to enter the changes screen, then `a` to add a new one and enter a name like `add-email-verification`. noorm scaffolds the folder structure for you: +Scaffold a change headlessly with `noorm change add`: + +```bash +noorm change add add-email-verification +noorm change add # prompts for a description (TTY only) +``` + +Or from the TUI: run `noorm ui`, press `g` from home to enter the changes screen, then `a` +and enter a name like `add-email-verification`. Either path scaffolds the same layout: ``` changes/2025-01-15-add-email-verification/ ├── change/ +│ └── 001_add-email-verification.sql ├── revert/ +│ └── 001_add-email-verification.sql └── changelog.md ``` -Now add your SQL files to `change/`. If you want rollback support, add corresponding scripts to `revert/`. Once the directory exists you can apply, revert, and inspect the change from the headless CLI (`noorm change ff`, `noorm change run`, `noorm change revert`, `noorm change history`). +Unlike an empty folder pair, `001_add-email-verification.sql` is a real, runnable stub — one +comment line naming what belongs there ("Add the SQL statements that apply this change" in +`change/`, "...undo this change" in `revert/`). Replace the comment with your SQL. A comment +alone isn't enough to run: `change run` rejects a changeset whose files contain only +comments or template placeholders, with a message that tells you to edit the files — not the +misleading "change not found" a genuinely empty folder pair used to produce. + +For a multi-statement change, add more files by hand following the same `NNN_description` +convention (see [Naming Conventions](#naming-conventions) above) — `002_`, `003_`, and so on. +Files run in filename order (a plain string sort, so keep sequence numbers zero-padded to +three digits or the sort stops being numeric past `009_`). + +Once the directory exists you can apply, revert, and inspect the change from the headless +CLI (`noorm change ff`, `noorm change run`, `noorm change revert`, `noorm change history`). ## Write Idempotent DDL @@ -208,7 +231,7 @@ From the headless CLI, `noorm change list` prints every known change with its st ```bash noorm change list # Human-friendly table -noorm --json change list # Same data as JSON +noorm change list --json # Same data as JSON ``` ```json diff --git a/docs/guide/database/explore.md b/docs/guide/database/explore.md index 260d5525..3d6dcdae 100644 --- a/docs/guide/database/explore.md +++ b/docs/guide/database/explore.md @@ -52,7 +52,7 @@ The same subcommands run non-interactively — add `--json` to get machine-reada noorm db explore # Overview noorm db explore tables # List tables noorm db explore tables detail users # Table detail -noorm --json db explore > schema.json # JSON output +noorm db explore --json > schema.json # JSON output ``` @@ -342,7 +342,7 @@ Use `--json` flag for machine-readable output. ### Overview ```bash -noorm --json db explore +noorm db explore --json ``` ```json @@ -361,7 +361,7 @@ noorm --json db explore ### Table List ```bash -noorm --json db explore tables +noorm db explore tables --json ``` ```json @@ -376,7 +376,7 @@ noorm --json db explore tables ### Table Detail ```bash -noorm --json db explore tables detail users +noorm db explore tables detail users --json ``` ```json @@ -402,7 +402,7 @@ noorm --json db explore tables detail users ### View Detail ```bash -noorm --json db explore views detail active_users +noorm db explore views detail active_users --json ``` ```json @@ -422,7 +422,7 @@ noorm --json db explore views detail active_users ### Function Detail ```bash -noorm --json db explore functions detail calculate_total +noorm db explore functions detail calculate_total --json ``` ```json diff --git a/docs/guide/database/teardown.md b/docs/guide/database/teardown.md index cb79940b..5a9abcef 100644 --- a/docs/guide/database/teardown.md +++ b/docs/guide/database/teardown.md @@ -28,7 +28,7 @@ Both operations permanently destroy data. `truncate` wipes all rows. `teardown` Truncate removes all data while preserving table structure. Fast reset for test cycles. ```bash -noorm -y db truncate +noorm db truncate -y ``` ### What Gets Truncated @@ -49,7 +49,7 @@ noorm -y db truncate Teardown removes all database objects. Clean slate for full rebuilds. ```bash -noorm -y db teardown +noorm db teardown -y ``` ::: warning Access Roles @@ -175,7 +175,7 @@ The SDK provides teardown methods with test-oriented safety guards. ### Basic Test Setup ```typescript -import { createContext, RequireTestError } from 'noorm/sdk' +import { createContext, RequireTestError } from '@noormdev/sdk' describe('user service', () => { @@ -192,8 +192,8 @@ describe('user service', () => { beforeEach(async () => { // Fast reset between tests - await ctx.truncate() - await ctx.runFile('./seeds/test-data.sql') + await ctx.noorm.db.truncate() + await ctx.noorm.run.file('./seeds/test-data.sql') }) afterAll(async () => { @@ -231,11 +231,11 @@ beforeAll(async () => { await ctx.connect() // Full teardown + rebuild - await ctx.reset() + await ctx.noorm.db.reset() }) ``` -The `reset()` method combines `teardown()` and `build({ force: true })` for complete schema reconstruction. +The `reset()` method combines `ctx.noorm.db.teardown()` and `ctx.noorm.run.build({ force: true })` for complete schema reconstruction. ## Scripted Usage @@ -246,19 +246,19 @@ For CI/CD pipelines, drive the same teardown commands non-interactively. Always ```bash # Truncate all data -noorm -y db truncate +noorm db truncate -y # Full teardown -noorm -y db teardown +noorm db teardown -y # Preview what would be dropped -noorm --dry-run db teardown +noorm db teardown --dry-run ``` ### JSON Output ```bash -noorm --json db teardown +noorm db teardown --json ``` ```json @@ -316,7 +316,7 @@ test: set -e # Reset to clean state before test run -noorm -y db teardown --config test +noorm db teardown --config test -y noorm run build --config test noorm change ff --config test @@ -352,8 +352,8 @@ SQLite uses DELETE instead of TRUNCATE because SQLite does not support TRUNCATE. ```bash # Safe teardown pattern -noorm --dry-run db teardown # Preview first -noorm -y db teardown # Execute after review +noorm db teardown --dry-run # Preview first +noorm db teardown -y # Execute after review ``` diff --git a/docs/guide/database/transfer.md b/docs/guide/database/transfer.md index 2aa18c39..ff5f64f2 100644 --- a/docs/guide/database/transfer.md +++ b/docs/guide/database/transfer.md @@ -119,7 +119,7 @@ noorm db transfer --to backup --no-identity ### JSON Output ```bash -noorm --json db transfer --to backup +noorm db transfer --to backup --json ``` Transfer result: @@ -223,7 +223,7 @@ Only transfers the specified tables. FK dependencies between selected tables are ### CI/CD test data setup ```bash -noorm --json db transfer staging --to ci-test --truncate --on-conflict fail +noorm db transfer staging --to ci-test --truncate --on-conflict fail --json ``` Clean transfer for test environments. JSON output for pipeline integration. Fails fast if anything goes wrong. diff --git a/docs/guide/environments/configs.md b/docs/guide/environments/configs.md index 41cecc66..48baacfd 100644 --- a/docs/guide/environments/configs.md +++ b/docs/guide/environments/configs.md @@ -51,7 +51,7 @@ After completing the wizard, noorm validates the connection. If it succeeds, you ## Creating a Config Non-Interactively -`noorm config add`, `edit`, and `rm` all launch the TUI wizard — they exist to guide credential entry interactively. For CI/CD pipelines and scripts that need to skip the wizard entirely, build the config from **environment variables** instead: +`noorm config add` and `edit` launch the TUI wizard — they exist to guide credential entry interactively. Removal is the exception: `noorm config rm --yes` deletes headlessly, no wizard involved (see the [CLI Reference](/headless#config-rm-name)). For CI/CD pipelines and scripts that need to skip the config wizard entirely, build the config from **environment variables** instead: ```bash export NOORM_CONNECTION_DIALECT=postgres @@ -192,7 +192,6 @@ Environment variables override stored config values. This is how you inject secr |----------|---------| | `NOORM_CONFIG` | Which config to use | | `NOORM_YES` | Skip confirmations (set to `1`) | -| `NOORM_JSON` | JSON output mode (set to `1`) | **Example: Override host for CI runner** diff --git a/docs/guide/environments/secrets.md b/docs/guide/environments/secrets.md index 5c19dfc5..ee6a7b3d 100644 --- a/docs/guide/environments/secrets.md +++ b/docs/guide/environments/secrets.md @@ -151,7 +151,7 @@ Types are hints for the CLI. All secrets are stored identically (encrypted strin ## Secrets in CI/CD -The local secret store exists for per-developer overrides — it's deliberately TUI-only so that raw values never leak into shell history. For non-interactive environments, push values to the [vault](/guide/environments/vault) instead, which is scriptable end-to-end: +The local secret store exists for per-developer overrides — `noorm secret set` is headless, but it writes to `.noorm/state/state.enc` on disk, which a fresh CI runner doesn't have. For non-interactive environments, push values to the [vault](/guide/environments/vault) instead, which is scriptable end-to-end and reachable from a clean checkout: ```bash # Write a secret to the vault @@ -161,7 +161,7 @@ noorm vault set DB_PASSWORD "$DB_PASSWORD" echo "$DB_PASSWORD" | noorm vault set DB_PASSWORD # List vault secrets as JSON -noorm --json vault list +noorm vault list --json # Remove a vault secret noorm vault rm OLD_API_KEY diff --git a/docs/guide/environments/vault.md b/docs/guide/environments/vault.md index 29226595..eb9b34d8 100644 --- a/docs/guide/environments/vault.md +++ b/docs/guide/environments/vault.md @@ -196,7 +196,7 @@ noorm vault init noorm vault set API_KEY "$API_KEY" # List with JSON output -noorm --json vault list +noorm vault list --json # Copy secrets between environments noorm vault cp --all staging production diff --git a/docs/guide/sql-files/templates.md b/docs/guide/sql-files/templates.md index 8cd6420c..d7343a4a 100644 --- a/docs/guide/sql-files/templates.md +++ b/docs/guide/sql-files/templates.md @@ -353,6 +353,30 @@ INSERT INTO app_config (key, value) VALUES Secrets resolve through a three-tier hierarchy: config-specific local secrets override global local secrets, which override team-shared [vault](/guide/environments/vault) secrets. This means you can store production credentials in the vault and override them locally for development without affecting teammates. See the [secret resolution hierarchy](/guide/environments/vault#secret-resolution-hierarchy) for details. +A key that isn't set in any of the three tiers has no correct SQL rendering, so reading it +throws instead of quietly resolving to `undefined`: + +```sql +-- Throws: Secret "STRIPE_KEY" not found (searched: config-local, global-local, vault) +{%~ $.secrets.STRIPE_KEY %} +``` + +This is deliberate — a missing secret used to render as the literal six-character string +`undefined`, which is valid SQL and easy to miss in a review. `$.secrets.KEY ?? fallback` +can't paper over a missing key either, because `??` still evaluates the throwing read on its +left side. To probe for a secret that's genuinely optional, check for it first with `in`, +which never throws: + +```sql +{% const hasKey = 'STRIPE_KEY' in $.secrets; %} +{%~ hasKey ? $.quote($.secrets.STRIPE_KEY) : 'NULL' %} +``` + +`in` before read is how a template says "this secret is allowed to be missing" on purpose, +rather than by accident. See +[`examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl`](https://github.com/noormdev/noorm/blob/master/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl) +for a full example that forces a feature flag off when its backing secret isn't configured. + ### Generating Multiple Similar Objects diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 7e30d85e..74f73fb0 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -6,19 +6,23 @@ for `noorm`. Each entry is a search target plus a one-paragraph fix and a link to the deeper doc. -## `noorm --json sql query "SELECT 1"` doesn't produce JSON +## `noorm --config prod run build` fails or targets the wrong directory -`--json` is a per-subcommand flag, not a global. It must appear *after* -the subcommand name. citty (the parser) sees `--json` before the -subcommand as a root flag that doesn't exist, then fails silently on -output. Move the flag: +`--config` (long form) is per-subcommand only — it has no global meaning. Neither do +`--dry-run`, `--json`, or `--yes`: the only flag with a root-level meaning is `-c`/`--cwd`. +Placed before the subcommand, `--config` (and everything else) is rejected outright rather +than silently ignored: + + # rejected: --config has no global form + noorm --config prod run build # works - noorm sql query "SELECT 1" --json - noorm change ff --json - noorm config list --json + noorm run build --config prod + noorm run build -c prod -See [CLI flag conventions](../cli/flags.md) for the full rule. +`-c` is the sharper trap: `noorm -c prod run build` is *not* the same command — before the +subcommand, `-c` means `--cwd`, not `--config`. See [CLI flag conventions](../cli/flags.md) +for the full rule. ## `noorm help db create` errors with "Unknown command help" diff --git a/docs/headless.md b/docs/headless.md index fbee29b4..ff67f2c5 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -5,7 +5,7 @@ Every `noorm` command runs as a non-interactive CLI by default. The Ink/React TU ```bash noorm run build # CLI: build schema headlessly -noorm --json change ff # CLI: fast-forward with JSON output +noorm change ff --json # CLI: fast-forward with JSON output noorm ui # TUI: launch the interactive terminal UI ``` @@ -27,7 +27,11 @@ For shell completion, run `noorm complete` and follow the printed instructions f ## Global Flags -These flags must appear **before** the subcommand (like `git -C`). After the subcommand they revert to their per-command meaning. +A flag goes on the command that uses it. `-c`/`--cwd ` is the one exception: it's +recognized **before** the subcommand instead, because it's consumed before dispatch — it sets +the working directory everything else (config discovery, project root) resolves against. An +unrecognized or misplaced flag typed before the subcommand exits non-zero naming the correct +form, rather than being silently dropped. | Flag | Short | Description | |------|-------|-------------| @@ -39,8 +43,14 @@ noorm -c packages/db run build # Initialize a nested project from elsewhere noorm -c packages/db init + +# every other flag belongs on the subcommand +noorm run build --dry-run ``` +`--cwd`/`-c` only has this global meaning *before* the subcommand — after it, `-c` is the +per-command `--config` alias instead (see the note below). + ## Common Flags @@ -55,9 +65,7 @@ These options are reused across most commands. Run ` --help` to see exa | `--dry-run` | — | Preview without executing | | `--help` | `-h` | Show command help (citty native) | -> **Note:** `-c` is overloaded by position. `noorm -c run` is the global cwd flag; `noorm run -c ` is the per-command config alias. The first non-flag token (the subcommand) is the boundary. - -> **`--json` placement:** `--json` is per-subcommand, not global — it must appear **after** the subcommand name (`noorm change ff --json`, not `noorm --json change ff`). See [CLI flag conventions](./cli/flags.md) for the full reasoning. For per-command help, see [Discovering command help](./cli/help.md). +> **Note:** `-c` is overloaded by position. `noorm -c run` is the global cwd flag; `noorm run -c ` is the per-command config alias. The first non-flag token (the subcommand) is the boundary. Every flag other than `-c`/`--cwd` — `--config`, `--force`, `--json`, `--dry-run`, `--yes`, etc. — has no global meaning; it only works after the subcommand. See [CLI flag conventions](./cli/flags.md) for the full reasoning. For per-command help, see [Discovering command help](./cli/help.md). ## Configuration @@ -70,7 +78,7 @@ These options are reused across most commands. Run ` --help` to see exa noorm run build # Use specific config -noorm --config staging run build +noorm run build --config staging # Or via environment export NOORM_CONFIG=staging @@ -127,7 +135,6 @@ NOORM_PATHS_CHANGES # Changes directory (default: ./changes) ```bash NOORM_CONFIG # Config name to use NOORM_YES # Skip confirmations (1 or true) -NOORM_JSON # Output JSON (1 or true) NOORM_DEBUG # Enable debug logging ``` @@ -142,7 +149,6 @@ All `NOORM_*` environment variables are processed through a nesting convention t |----------|---------| | `NOORM_CONFIG` | Select which stored config to use | | `NOORM_YES` | Skip confirmations (`1` or `true`) | -| `NOORM_JSON` | Force JSON output (`1` or `true`) | These are excluded from config nesting and consumed directly by the CLI. @@ -180,6 +186,7 @@ Each config carries a per-channel access grant: `access: { user, mcp }`. The `us | `db create`, `db reset` (truncate/teardown/reset) | deny | confirm | allow | | `db drop` | deny | deny | confirm | | `config rm` | deny | confirm | confirm | +| `change rm` | deny | confirm | confirm | Raw SQL (`noorm sql`) is gated by what the statement actually does, not by a flag — a multi-statement input is classified by its highest class (a `SELECT` plus a `DROP` classifies as DDL). Unparseable or unrecognized statements classify as DDL, fail closed. @@ -232,7 +239,7 @@ Set the active configuration. ```bash noorm config use dev -noorm --json config use production +noorm config use production --json ``` **JSON output:** @@ -247,7 +254,7 @@ noorm --json config use production Create a new configuration. -> Wizard-only. Launches the TUI wizard. To configure non-interactively, use `NOORM_*` environment variables or `config import`. +> Wizard-only. Launches the TUI wizard. To configure non-interactively, use `NOORM_*` environment variables or `config import`. Exits 1 (message on stderr) — there is no headless equivalent. ```bash noorm config add @@ -258,23 +265,34 @@ noorm config add Edit an existing configuration. -> Wizard-only. Launches the TUI wizard. Use `NOORM_*` env vars or `config export`/`config import` to modify non-interactively. +> Wizard-only. Launches the TUI wizard. Use `NOORM_*` env vars or `config export`/`config import` to modify non-interactively. Exits 1 (message on stderr) — there is no headless equivalent. ```bash noorm config edit dev ``` -#### `config rm [name]` +#### `config rm ` Remove a configuration. -> Wizard-only. Launches the TUI wizard so you can confirm the removal interactively. - ```bash -noorm config rm staging +noorm config rm staging --yes +noorm config rm staging --yes --json ``` +**JSON output:** +```json +{ + "name": "staging", + "deleted": true +} +``` + +::: danger Destructive +Requires `--yes` (or `NOORM_YES=1`) — there is no TTY confirmation prompt, so the flag is mandatory even at an interactive terminal. An unknown config name fails with exit 1. Gated by the config's `config:rm` access (see [Access Roles](#access-roles) above); a config linked to a locked stage cannot be removed until the stage is unlocked. +::: + #### `config cp ` @@ -356,7 +374,7 @@ Create a new cryptographic identity. Generates an X25519 keypair and stores it a ```bash noorm identity init --name "Alice" --email "alice@example.com" noorm identity init --name "Alice" --email "alice@example.com" --force -noorm --json identity init --name "Alice" --email "alice@example.com" +noorm identity init --name "Alice" --email "alice@example.com" --json ``` | Flag | Description | @@ -395,7 +413,7 @@ Print your public key so teammates can add you to encrypted vaults. ```bash noorm identity export -noorm --json identity export +noorm identity export --json ``` @@ -405,7 +423,7 @@ List all known users discovered from database syncs (the audit trail of who has ```bash noorm identity list -noorm --json identity list +noorm identity list --json ``` @@ -627,8 +645,8 @@ Execute all SQL files in the schema directory. Uses checksums to skip unchanged ```bash noorm run build -noorm --force run build # Skip checksums, run everything -noorm --dry-run run build # Preview without executing +noorm run build --force # Skip checksums, run everything +noorm run build --dry-run # Preview without executing ``` **JSON output:** @@ -650,7 +668,7 @@ Execute a single SQL or `.sql.tmpl` file. ```bash noorm run file sql/01_tables/001_users.sql noorm run file seed.sql.tmpl -noorm --json run file sql/init.sql +noorm run file sql/init.sql --json ``` **JSON output:** @@ -670,7 +688,7 @@ Execute all SQL files in a directory in alphabetical order. ```bash noorm run dir sql/01_tables/ noorm run dir seeds/ -noorm --json run dir migrations/ +noorm run dir migrations/ --json ``` @@ -704,7 +722,7 @@ Inspect the template context for a `.sql.tmpl` file without executing. Shows dat ```bash noorm run inspect sql/users/001_create.sql.tmpl -noorm --json run inspect sql/core/Crons.sql.tmpl +noorm run inspect sql/core/Crons.sql.tmpl --json ``` **JSON output:** @@ -731,8 +749,8 @@ Render a `.sql.tmpl` file and output the resulting SQL. Does **not** execute aga ```bash noorm run preview sql/schema.sql.tmpl noorm run preview sql/schema.sql.tmpl > rendered.sql -noorm --json run preview sql/seed.sql.tmpl -noorm --config staging run preview sql/migrations/002.sql.tmpl +noorm run preview sql/seed.sql.tmpl --json +noorm run preview sql/migrations/002.sql.tmpl --config staging ``` **JSON output:** @@ -788,8 +806,8 @@ Fast-forward: apply all pending changes in order. ```bash noorm change ff -noorm --dry-run change ff -noorm --force change ff +noorm change ff --dry-run +noorm change ff --force ``` **JSON output:** @@ -812,7 +830,7 @@ Apply the next pending change only. ```bash noorm change next -noorm --json change next +noorm change next --json ``` @@ -823,7 +841,7 @@ Apply a specific change by name. Omit the name on a TTY to pick interactively. ```bash noorm change run # interactive picker (TTY) noorm change run 2024-02-01-notifications -noorm --json change run 2024-02-01-notifications +noorm change run 2024-02-01-notifications --json ``` **JSON output:** @@ -847,7 +865,7 @@ Revert a previously applied change by running its rollback SQL. Omit the name on ```bash noorm change revert # interactive picker (TTY) noorm change revert 2024-02-01-notifications -noorm --json change revert 002_users +noorm change revert 002_users --json ``` @@ -858,7 +876,7 @@ Revert the given change **and every change applied after it**, in reverse order. ```bash noorm change rewind # interactive picker (TTY) noorm change rewind 2024-02-01-notifications -noorm --json change rewind 001_init +noorm change rewind 001_init --json ``` @@ -872,7 +890,12 @@ noorm change add add-users-table noorm change add "notification queue" --json ``` -Creates `changes/-/` with `change.sql` and `revert.sql` stubs. +Creates `changes/-/` with `change/` and `revert/` directories, each +holding a `001_.sql` stub (a single comment naming what belongs there — "Add the SQL +statements that apply this change" / "...undo this change"), plus `changelog.md`. The stub +comments alone don't count as content: running the change before editing them fails with an +actionable message ("Files are empty or contain only template placeholders"), not "change not +found". #### `change edit [name]` @@ -888,7 +911,7 @@ EDITOR=vim noorm change edit 001_init #### `change rm [name]` -Remove a change directory from disk. Does **not** touch database state. On a TTY, omit the name to pick interactively; `--yes` is only required on non-TTY. +Remove a change directory from disk. Does **not** touch database state. Gated by the config's `change:rm` access (see [Access Roles](#access-roles) above): `viewer` is denied; `operator` and `admin` confirm via `--yes` or `NOORM_YES=1`. On a TTY, omit the name to pick interactively. ```bash noorm change rm # picker + confirm (TTY) @@ -902,7 +925,7 @@ View execution history with timestamps, direction, and duration. ```bash noorm change history -noorm --json change history +noorm change history --json ``` **JSON output:** @@ -930,7 +953,7 @@ Per-file execution detail for a single change. Omit the name on a TTY to pick in ```bash noorm change history-detail # interactive picker (TTY) noorm change history-detail 001_init -noorm --json change history-detail 002_users +noorm change history-detail 002_users --json ``` @@ -989,7 +1012,7 @@ noorm db explore procedures # List all procedures noorm db explore indexes # List all indexes noorm db explore types # List custom types noorm db explore fks # List foreign keys -noorm --json db explore # JSON overview +noorm db explore --json # JSON overview ``` **JSON output (overview):** @@ -1009,7 +1032,7 @@ noorm --json db explore # JSON overview Wipe all data from application tables. Schema and noorm tracking tables are preserved. ```bash -noorm -y db truncate +noorm db truncate -y ``` @@ -1018,7 +1041,7 @@ noorm -y db truncate Drop all database objects. ```bash -noorm -y db teardown +noorm db teardown -y ``` ::: warning Access Roles @@ -1081,7 +1104,7 @@ Check if database is locked. ```bash noorm lock status -noorm --json lock status +noorm lock status --json ``` **JSON output:** @@ -1103,7 +1126,7 @@ Acquire an exclusive lock. ```bash noorm lock acquire -noorm --json lock acquire +noorm lock acquire --json ``` **CI/CD pattern with cleanup:** @@ -1152,7 +1175,7 @@ Initialize the vault for the current database. ```bash noorm vault init -noorm --json vault init +noorm vault init --json ``` @@ -1162,7 +1185,7 @@ Store an encrypted secret in the vault. ```bash noorm vault set API_KEY "sk-live-abc123" -noorm --json vault set API_KEY "sk-live-abc123" +noorm vault set API_KEY "sk-live-abc123" --json ``` Upserts: creates if new, updates if the key exists. @@ -1174,7 +1197,7 @@ List all secrets in the vault (keys and metadata only, never values). ```bash noorm vault list -noorm --json vault list +noorm vault list --json ``` **JSON output:** @@ -1198,7 +1221,7 @@ Remove a secret from the vault. ```bash noorm vault rm OLD_API_KEY -noorm --json vault rm OLD_API_KEY +noorm vault rm OLD_API_KEY --json ``` @@ -1228,7 +1251,7 @@ Grant vault access to team members who don't have it yet. Encrypts the vault key ```bash noorm vault propagate -noorm --json vault propagate +noorm vault propagate --json ``` **JSON output:** @@ -1251,7 +1274,7 @@ Execute a raw SQL query. noorm sql "SELECT * FROM users LIMIT 10" noorm sql -f query.sql noorm -c prod sql "SELECT count(*) FROM orders" -noorm --json sql "SELECT id, name FROM users" +noorm sql "SELECT id, name FROM users" --json ``` | Flag | Description | @@ -1293,7 +1316,7 @@ Show SQL execution history. ```bash noorm sql history noorm sql history -n 20 -noorm --json sql history +noorm sql history --json noorm -c prod sql history ``` @@ -1350,7 +1373,7 @@ Project and database status overview. ```bash noorm info -noorm --json info +noorm info --json ``` **JSON output:** @@ -1387,7 +1410,7 @@ CLI version and diagnostic information. ```bash noorm version -noorm --json version +noorm version --json ``` @@ -1397,7 +1420,7 @@ Check for and install noorm updates. ```bash noorm update -noorm --json update +noorm update --json ``` @@ -1577,15 +1600,38 @@ noorm ci init --name prod noorm lock acquire trap "noorm lock release" EXIT noorm change ff -noorm --json db explore +noorm db explore --json +``` + + +## Output Streams + +A command's **result** — the human summary, or the `--json` payload — always goes to +**stdout**. Everything else — progress lines, warnings, the logger's event stream — goes to +**stderr**, in both text and `--json` mode. This holds regardless of whether the command +succeeds or fails: a setup failure (bad config, no connection) prints nothing to stdout in +text mode — the diagnostic goes to stderr — and a single JSON error document to stdout in +`--json` mode, e.g. `{"success":false,"error":"..."}`. + +That means `--json` output is always exactly one parseable JSON document on stdout, with no +log noise ahead of or after it — no `tail -1` needed: + +```bash +noorm change history --json | jq '.[0].status' ``` +A command that finds nothing to report still writes a result — never silence. `noorm change +list` on a project with no changesets prints `No changes found.` on stdout in text mode and +`[]` on stdout in `--json` mode; either way, a CI step can tell "ran and found nothing" from +"didn't run" by checking the exit code and the (always-present) stdout line, rather than +guessing from empty output. + ## Scripting with JSON ```bash # Check for pending changes -pending=$(noorm --json change list | jq '[.[] | select(.status == "pending")] | length') +pending=$(noorm change list --json | jq '[.[] | select(.status == "pending")] | length') if [ "$pending" -gt 0 ]; then echo "Found $pending pending changes" noorm change ff @@ -1594,13 +1640,13 @@ fi ```bash # Get table count -tables=$(noorm --json db explore | jq '.tables') +tables=$(noorm db explore --json | jq '.tables') echo "Database has $tables tables" ``` ```bash # Verify vault access -pending=$(noorm --json vault list | jq '.status.usersWithoutAccess') +pending=$(noorm vault list --json | jq '.status.usersWithoutAccess') if [ "$pending" -gt 0 ]; then noorm vault propagate fi diff --git a/docs/index.md b/docs/index.md index 57695968..3275c445 100644 --- a/docs/index.md +++ b/docs/index.md @@ -119,7 +119,7 @@ Or use the CLI directly. Commands run headlessly and emit structured JSON you ca ```bash noorm run build # Build the schema from SQL files noorm change ff # Apply all pending changes -noorm --json db explore # Inspect the database as JSON +noorm db explore --json # Inspect the database as JSON noorm vault set API_KEY ... # Push a team secret to the encrypted vault ``` diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index 5a219068..c36b7ef7 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -925,7 +925,7 @@ console.log(result.sql); #### transfer.to(destConfig, options?) -Transfer data from this context's database to a destination config. Both contexts must be connected. +Transfer data from this context's database to a destination config. Both contexts must be connected. Throws on failure. ```typescript const source = await createContext({ config: 'staging' }); @@ -933,15 +933,13 @@ const dest = await createContext({ config: 'dev' }); await source.connect(); await dest.connect(); -const [result, err] = await source.noorm.transfer.to(dest.noorm.config, { +const result = await source.noorm.transfer.to(dest.noorm.config, { tables: ['users', 'posts'], onConflict: 'skip', batchSize: 5000, }); -if (result) { - console.log(`Transferred ${result.totalRows} rows (${result.status})`); -} +console.log(`Transferred ${result.totalRows} rows (${result.status})`); await source.disconnect(); await dest.disconnect(); @@ -964,15 +962,13 @@ await dest.disconnect(); #### transfer.plan(destConfig, options?) -Generate a transfer plan without executing. Inspects both databases and returns table ordering, row estimates, and warnings. +Generate a transfer plan without executing. Inspects both databases and returns table ordering, row estimates, and warnings. Throws on failure. ```typescript -const [plan, err] = await source.noorm.transfer.plan(dest.noorm.config); -if (plan) { - console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`); - for (const warning of plan.warnings) { - console.warn(warning); - } +const plan = await source.noorm.transfer.plan(dest.noorm.config); +console.log(`${plan.estimatedRows} rows across ${plan.tables.length} tables`); +for (const warning of plan.warnings) { + console.warn(warning); } ``` @@ -982,16 +978,14 @@ if (plan) { #### dt.exportTable(tableName, filepath, options?) -Export a table to a .dt file. The file extension determines the format: `.dt` (plain), `.dtz` (gzipped), `.dtzx` (encrypted). +Export a table to a .dt file. The file extension determines the format: `.dt` (plain), `.dtz` (gzipped), `.dtzx` (encrypted). Throws on failure. ```typescript -const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.dtz'); -if (result) { - console.log(`Exported ${result.rowsWritten} rows (${result.bytesWritten} bytes)`); -} +const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz'); +console.log(`Exported ${result.rowsWritten} rows (${result.bytesWritten} bytes)`); // Encrypted export -const [encrypted, encErr] = await ctx.noorm.dt.exportTable('users', './exports/users.dtzx', { +const encrypted = await ctx.noorm.dt.exportTable('users', './exports/users.dtzx', { passphrase: 'my-secret', }); ``` @@ -1007,15 +1001,13 @@ const [encrypted, encErr] = await ctx.noorm.dt.exportTable('users', './exports/u #### dt.importFile(filepath, options?) -Import a .dt file into the connected database. +Import a .dt file into the connected database. Throws on failure. ```typescript -const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', { +const result = await ctx.noorm.dt.importFile('./exports/users.dtz', { onConflict: 'skip', }); -if (result) { - console.log(`Imported ${result.rowsImported} rows, skipped ${result.rowsSkipped}`); -} +console.log(`Imported ${result.rowsImported} rows, skipped ${result.rowsSkipped}`); ``` **Options (`ImportOptions`):** @@ -1064,19 +1056,10 @@ Database-stored encrypted secrets shared across team members. Unlike config-scop Initialize the vault for this database. Creates the vault key and stores it encrypted for the current identity. -Idempotent. Calling `init()` a second time against an already-initialized vault returns `[null, null]` — no state change, no error. Callers can `init()` defensively at startup without special-casing an error string. - -The three return shapes: - -| Shape | Meaning | -|-------|---------| -| `[Buffer, null]` | First-time init succeeded. The buffer is the vault key. | -| `[null, null]` | Vault already initialized. No work done. Use `vault.get` / `vault.set` with the user's private key. | -| `[null, Error]` | Actual failure (DB error, encryption error). | +Idempotent. Calling `init()` a second time against an already-initialized vault returns `null` — no state change, no error. Callers can `init()` defensively at startup without special-casing an error string. Real failures (DB errors, encryption errors) throw. ```typescript -const [vaultKey, err] = await ctx.noorm.vault.init(); -if (err) throw err; +const vaultKey = await ctx.noorm.vault.init(); if (vaultKey) { // First-time init — seed initial team secrets, etc. @@ -1088,12 +1071,12 @@ else { The `vault:initialized` observer event fires only on first init, never on repeat calls. Cross-reference with `vault.status()` if you need to distinguish "just initialized" from "was already there" alongside other status fields. -**Returns:** `Promise<[Buffer | null, Error | null]>` — the vault key buffer on first init, `null` if already initialized, or an `Error` on failure. +**Returns:** `Promise` — the vault key buffer on first init, `null` if already initialized. Throws on failure. #### vault.status() -Get vault status for the current identity. Useful alongside `vault.init()` when callers need to know whether a vault existed before they called `init()`: a `[null, null]` from `init()` plus `status.isInitialized === true` confirms idempotent no-op. +Get vault status for the current identity. Useful alongside `vault.init()` when callers need to know whether a vault existed before they called `init()`: a `null` from `init()` plus `status.isInitialized === true` confirms idempotent no-op. ```typescript const status = await ctx.noorm.vault.status(); @@ -1105,16 +1088,13 @@ console.log(`Initialized: ${status.isInitialized}, Has access: ${status.hasAcces #### vault.set(key, value, privateKey) -Set a vault secret. Requires the caller's private key for vault key decryption. +Set a vault secret. Requires the caller's private key for vault key decryption. Throws on failure. ```typescript -const [, err] = await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey); -if (err) { - console.error('Failed to set secret:', err.message); -} +await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey); ``` -**Returns:** `Promise<[void, Error | null]>` +**Returns:** `Promise` — throws on failure. #### vault.get(key, privateKey) @@ -1156,16 +1136,16 @@ console.log('Available secrets:', keys.join(', ')); #### vault.delete(key) -Delete a vault secret. +Delete a vault secret. Throws on failure. ```typescript -const [deleted, err] = await ctx.noorm.vault.delete('OLD_KEY'); +const deleted = await ctx.noorm.vault.delete('OLD_KEY'); if (deleted) { console.log('Secret removed'); } ``` -**Returns:** `Promise<[boolean, Error | null]>` +**Returns:** `Promise` — throws on failure. #### vault.exists(key) @@ -1193,20 +1173,18 @@ console.log(`Propagated to ${result.propagatedTo.length} new users`); #### vault.copy(destConfig, keys, privateKey, options?) -Copy vault secrets to another config's database. Useful for seeding a new environment with secrets from an existing one. +Copy vault secrets to another config's database. Useful for seeding a new environment with secrets from an existing one. Throws on failure. ```typescript -const [result, err] = await ctx.noorm.vault.copy( +const result = await ctx.noorm.vault.copy( destConfig, ['API_KEY', 'DB_TOKEN'], privateKey, ); -if (result) { - console.log(`Copied ${result.copied} secrets`); -} +console.log(`Copied ${result.copied} secrets`); ``` -**Returns:** `Promise<[VaultCopyResult | null, Error | null]>` +**Returns:** `Promise` — throws on failure. ### ctx.noorm.utils — Utilities @@ -1235,14 +1213,16 @@ const checksum = await ctx.noorm.utils.checksum('sql/001_users.sql'); ### Event Subscriptions -Subscribe to core events via the observer: +Subscribe to core events via the process-global `noormObserver` bus: ```typescript -ctx.noorm.observer.on('file:after', (event) => { +import { noormObserver } from '@noormdev/sdk'; + +noormObserver.on('file:after', (event) => { console.log(`Executed ${event.filepath} in ${event.durationMs}ms`); }); -ctx.noorm.observer.on('change:complete', (event) => { +noormObserver.on('change:complete', (event) => { console.log(`Change ${event.name}: ${event.status}`); }); ``` @@ -1287,7 +1267,20 @@ const ctx = await createContext(); ## Error Handling +SDK methods throw named, `instanceof`-matchable errors and let them propagate — a call either +returns its value or throws. There are no `[value, error]` tuples on the `ctx.noorm.*` surface. +Wrap a call in `attempt()` (from `@logosdx/utils`) only when you're going to do something with the +error: translate it, recover, observe, or knowingly ignore it. If you'd just re-throw it unchanged, +skip `attempt` and let it propagate. Never wrap an SDK call in try-catch. + +Carve-outs that don't follow that plain throw-and-`attempt` pattern: +`ctx.noorm.utils.testConnection()` returns `{ ok, error? }` by design — a probe reporting failure as +data, not an exception. `ctx.transaction(...)` callbacks must throw to trigger Kysely's rollback — +throwing is the rollback signal, so don't swallow it inside the callback. Raw `ctx.kysely` queries +throw whatever the driver throws. + ```typescript +import { attempt } from '@logosdx/utils'; import { createContext, tvp, @@ -1296,28 +1289,20 @@ import { LockAcquireError, } from '@noormdev/sdk'; -try { - const ctx = await createContext({ config: 'prod', requireTest: true }); -} catch (err) { - if (err instanceof RequireTestError) { - console.error('Cannot use production config in tests'); - } +// attempt() is used deliberately here — the caller inspects the named error and reacts to it. +const [ctx, err] = await attempt(() => createContext({ config: 'prod', requireTest: true })); +if (err instanceof RequireTestError) { + console.error('Cannot use production config in tests'); } -try { - await ctx.noorm.db.truncate(); -} catch (err) { - if (err instanceof ProtectedConfigError) { - console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give'); - } +const [, truncateErr] = await attempt(() => ctx.noorm.db.truncate()); +if (truncateErr instanceof ProtectedConfigError) { + console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give'); } -try { - await ctx.noorm.lock.acquire(); -} catch (err) { - if (err instanceof LockAcquireError) { - console.error(`Lock held by ${err.holder}`); - } +const [, lockErr] = await attempt(() => ctx.noorm.lock.acquire()); +if (lockErr instanceof LockAcquireError) { + console.error(`Lock held by ${lockErr.holder}`); } ``` diff --git a/docs/spec/config-access-roles.md b/docs/spec/config-access-roles.md index b7ac700d..33a29f77 100644 --- a/docs/spec/config-access-roles.md +++ b/docs/spec/config-access-roles.md @@ -34,7 +34,7 @@ Replace `Config.protected: boolean` with config-scoped, channel-keyed roles enfo type Permission = | 'explore' | 'sql:read' | 'sql:write' | 'sql:ddl' - | 'change:run' | 'change:ff' | 'change:revert' + | 'change:run' | 'change:ff' | 'change:revert' | 'change:rm' | 'run:build' | 'run:file' | 'run:dir' | 'db:create' | 'db:reset' | 'db:destroy' | 'config:rm' @@ -54,6 +54,7 @@ Matrix (cells: `allow` / `confirm` / `deny`), hard-coded in `src/core/policy/`: | db:reset | deny | confirm | allow | | db:destroy | deny | deny | confirm | | config:rm | deny | confirm | confirm | +| change:rm | deny | confirm | confirm | type PolicyTarget = { name: string; access: ConfigAccess } // Config satisfies PolicyTarget structurally once CP2 adds `access`. @@ -116,7 +117,7 @@ New state migration in `src/core/version/state/migrations/` (registered in `src/ ## Checkpoints -Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fresh-process `bun test --serial` groups — a single whole-suite `bun test` cross-contaminates and does not reflect CI; DB containers from repo-root `docker-compose.yml` must be up), `bun run typecheck`, `bun run typecheck:tests`, `bun run lint`. Commit per green checkpoint. +Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fresh-process `bun test --serial` groups — a single whole-suite `bun test` cross-contaminates and does not reflect CI; DB containers from repo-root `docker-compose.test.yml` must be up), `bun run typecheck`, `bun run typecheck:tests`, `bun run lint`. Commit per green checkpoint. | CP | Scope | Key files | Done when | |---|---|---|---| @@ -151,3 +152,4 @@ Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fre - 2026-07-07 — CP2: `Config.access` optional until CP6 (CP4/CP5-owned constructors); fail-closed rule added for absent access on the mcp channel. Stage `protected: true` override-block in `checkConfigCompleteness` replaced by the ceiling clamp (the old "stored wins" behavior was the bug the clamp fixes). - 2026-07-07 — CP4: added `db:reset` permission (viewer deny / operator confirm / admin allow) for data-destructive-but-not-drop operations; the initial CP4 mapping of truncate/teardown/reset/importFile to `db:destroy` violated the migration section's behavior-preservation promise for open configs. - 2026-07-08 — CP8 (post challenge-swarm): classifier now catches CTE-wrapped DML and a destructive-function denylist (viewer write-bypass was confirmed critical). User-channel enforcement moved to the core seam so run/change/transfer/sql-terminal are gated for SDK+TUI+CLI (the earlier "same checkPolicy" claim was only partly delivered). Correctness + hygiene fixes per the swarm. Downgrade guard, state.enc durability/atomicity, and denial observability explicitly deferred (alpha; pre-existing; separate workstreams). +- 2026-07-13 — `change:rm` added (viewer deny / operator confirm / admin confirm — admin gets confirm not allow because deleting an applied change also deletes its DB tracking row), refs `docs/spec/v1-44-change-rm-gate.md`. diff --git a/docs/spec/v1-45-rewind-tiebreak.md b/docs/spec/v1-45-rewind-tiebreak.md new file mode 100644 index 00000000..a33629de --- /dev/null +++ b/docs/spec/v1-45-rewind-tiebreak.md @@ -0,0 +1,61 @@ +# Spec: v1-45 rewind — apply-order tiebreak + +Ticket: `tickets/v1/45-rewind-tiebreak-order.md` (realm repo). Discovered 2026-07-13: un-skipping the two ticket-34-gated rewind tests (the SQLite date crash is fixed) exposed an independent ordering bug. Branch: `v1/45-rewind-tiebreak` off `next` @ `b971f2f`. Reviewers diff against `b971f2f`. **v1-blocker.** + +## Goal + +`ChangeManager.rewind()` (`src/core/change/manager.ts` ~369) sorts applied changes by `appliedAt` descending with no tiebreaker. Ties are routine — `change ff` applies several changes in one process within the same clock tick, and SQLite datetime is second-precision — and on a tie the sort preserves `list()` insertion order (oldest-first). Observed failures (reproduced by un-skipping the two tests): + +- `rewind(2)`: reverts oldest-first within the tie group (`result.changes[0]` was the *older* change) — dependency-violating for DDL reverts. +- `rewind(name)`: `findIndex` against the mis-sorted list truncates the slice — reverted only the named change, leaving the newer change applied on top of a reverted base. + +Fix: the history table's autoincrement `id` (`src/core/change/types.ts:449/492` — `ChangeHistoryRecord.id`) is the true apply-order key. Investigate whether the objects `list()` returns carry it (they're hydrated in `src/core/change/history.ts` ~200-260); if not, plumb it through the list surface (additive optional field), then sort by `appliedAt` desc with `id` desc as tiebreak — or by `id` alone if every applied entry reliably has one (implementer verifies which; document the choice in the diff). Non-tied ordering must be provably unchanged. + +## Checkpoints + +| # | Checkpoint | Files | Agent | Verifies | +|---|---|---|---|---| +| 1 | Tiebreak fix + un-skip | `src/core/change/manager.ts`, possibly `src/core/change/history.ts` + types, `tests/core/change/manager.test.ts` | atomic-implementer (mode: feature) | The two `it.skip` rewind tests (~lines 261, 299 — comments reference this ticket) un-skipped and green; new tie-specific test: two changes with identical `appliedAt` revert id-descending; existing manager tests untouched and green; `bun run typecheck` clean. TDD: run the un-skipped tests RED first (they fail on `b971f2f` — verify), then fix. | + +## Non-goals + +- Changing what revert executes per change; revert-file ordering within a change; `change revert` (single) semantics. +- History-table schema changes — `id` already exists. + +## Acceptance criteria (from ticket) + +- `rewind(2)` reverts newest-first; `rewind(name)` reverts everything down to and including the named change; tie-specific test green; non-tied behavior unchanged. + +## Risks + +| Risk | Likelihood | Mitigation | +|---|---|---| +| `list()` items genuinely lack the history id and plumbing it touches many call sites | medium | The field is additive/optional on the status type; only rewind consumes it. If the blast radius exceeds ~3 files, stop and record in STATE.md. | +| Multiple history rows per change (re-applies) make id ambiguous | medium | Use the id of the row that produced `appliedAt` (the latest successful apply) — the hydration site in history.ts already selects that row for appliedAt; take id from the same record. | + +## Change log + +- 2026-07-13 — initial spec, authored by orchestrator pre-implementation. + +## Implementation log + +### shipped — 2026-07-14 + +Built across 1 iteration of /subagent-implementation. Commits (chronological): + +- `a3c4eb6` — spec added +- `8f770f8` — CP-1 tiebreak fix: appliedHistoryId plumbed through ChangeStatus/ChangeListItem (types.ts), sourced from the history row's autoincrement id in history.ts's getStatus()/getAllStatuses(), consumed as id-descending tiebreak in manager.ts rewind()'s sort (appliedAt stays primary key) +- `0df96a2` — followup F-1 deferred + +**Out-of-scope work performed during this build:** + +- none — blast radius held to exactly the 3 files predicted (types.ts, history.ts, manager.ts) plus the test file. + +**Unforeseens — surprises that emerged during implementation:** + +- Ticket/spec text claimed "two" ticket-34-gated it.skip tests in tests/core/change/manager.test.ts (~261, ~299); only one existed (line 254 pre-edit) — the ticket-34 SQLite date-hydration fix had already landed on this branch's base (b971f2f) and the second skip it originally referenced lived in a different file (tests/cli/run/change-rewind.test.ts), outside this ticket's scoped test surface. Verified via baseline run (10 pass, 1 skip, 0 fail on b971f2f) before touching code. Resolved by un-skipping the one existing test and adding the new tie-specific test the checkpoint called for; both satisfy the ticket's acceptance criteria. +- `ChangeListItem` is also constructed in `src/tui/utils/change-loader.ts` (three call sites) without setting `appliedHistoryId` — left the field optional/additive rather than touching that file, keeping blast radius at 3 files per the spec's risk budget. TUI callers unaffected since they don't consume the field. + +**Deferred items still open:** + +- `v1-45-rewind-tiebreak-f1` (project-level followup, risk): the new tie-specific test (and the pre-existing sibling at ~line 250) force the appliedAt tie via wall-clock timing coincidence rather than a deterministically forced equal executed_at — rare risk of false confidence on a run that straddles a second boundary. Not blocking; deferred rather than fixed in this iteration since it's a pre-existing test-technique pattern, not a regression introduced here. diff --git a/docs/spec/v1-49-54-cli-field-defects.md b/docs/spec/v1-49-54-cli-field-defects.md new file mode 100644 index 00000000..9df04cf4 --- /dev/null +++ b/docs/spec/v1-49-54-cli-field-defects.md @@ -0,0 +1,374 @@ +# Spec — v1/49-54 field-reported CLI defects + +Design: `docs/design/v1-49-54-cli-field-defects.md` +Issues: noormdev/noorm #49, #50, #51, #52, #53, #54 +Branch: `v1/49-54-cli-field-bugs` off `next` + +## Baseline + +**Verify the way CI does — four separate `bun test` runs, never one whole-tree run.** +`.github/workflows/ci.yml` splits them deliberately: *"Split test runs to prevent +cross-contamination from mock.module."* + + bun test --serial $(find tests/utils tests/core tests/sdk -name '*.test.ts' | grep -v tests/core/transfer | sort | tr '\n' ' ') + bun test --serial tests/core/transfer + bun test --serial tests/cli + bun test --serial tests/integration + +A single `bun test --serial` over the whole tree reports ~18 failures on `next` that are +**not** product defects. `tests/cli/app-context.test.tsx:50` replaces the entire +`src/core/index.js` barrel via `mock.module`; any module first loaded during that window +permanently captures the stubbed `getStateManager`, and the `afterAll` restore cannot rebind +what another module already closed over. Every later file that reaches a real `StateManager` +then fails with `getAllSecrets is not a function` or `setConfig is not a function`. The +affected tests all pass in isolation. Do not chase them, and do not "fix" a product file to +satisfy one. + +Preconditions for a meaningful run: + +- `bun run build` first. CLI tests spawn `dist/cli/index.js`; without a build, 204 tests fail + on `MODULE_NOT_FOUND` and the signal is worthless. +- All three test DBs up: postgres :15432, mysql :13306, mssql :11433 (`tests/sample.env`). +- The `noorm_test_dest` databases created and the postgres fixtures deployed, exactly as the + CI workflow's setup steps do. Without them `tests/core/transfer` fails 8 tests on + `relation "todo_items" does not exist` — an environment gap, not a regression. + +Green means 0 failures across all four runs. + +## Contract + +| CP | Deliverable | Acceptance | Files | +|----|-------------|-----------|-------| +| 1 | Root-level flag placement | `noorm run build --dry-run` and `noorm config list --json` work. Any flag other than `-c`/`--cwd` placed before the subcommand is rejected, naming the correct form. `-c`/`--cwd` is the sole flag with a root-level meaning. Never exit 0 silently. | `src/cli/index.ts` | +| 2 | `run build` and `db teardown` honour `--dry-run` | `noorm run build --dry-run` and `noorm db teardown --dry-run` both execute zero statements and list what would run. | `src/cli/run/build.ts`, `src/cli/db/teardown.ts`, `src/sdk/types.ts`, `src/sdk/namespaces/run.ts`, `src/sdk/namespaces/db.ts` | +| 3 | `db create` / `db drop` honour `NOORM_CONNECTION_*` | With a persisted config active and env vars naming a different database, both target the env database. | `src/cli/db/create.ts`, `src/cli/db/drop.ts` | +| 4 | Results on stdout, diagnostics on stderr | `noorm change list` prints its table to stdout; empty set prints an explicit empty-state line. `noorm change history --json \| jq .` parses with no `tail -1`. | `src/core/logger/logger.ts`, `src/cli/_utils.ts`, `src/cli/change/list.ts`, `src/cli/change/history.ts`, `src/cli/lock/status.ts`, `src/cli/db/explore.ts` | +| 5 | Unresolved secret fails loudly | Rendering `$.secrets.MISSING` throws naming the key. `sqlQuote(undefined)` throws. `'undefined'` never reaches SQL. `KEY in $.secrets` still probes without throwing. | `src/core/template/context.ts`, `src/core/template/utils.ts` | +| 6 | Vault tier reaches `$.secrets` | A secret set only via `noorm vault set` resolves in a template. Precedence config-local > global-local > vault. No reachable vault degrades to the local tiers without throwing. | `src/sdk/namespaces/run.ts`, `src/sdk/namespaces/changes.ts`, `src/sdk/namespaces/templates.ts`, `src/cli/run/preview.ts`, `src/cli/run/inspect.ts`, `src/tui/utils/run-context.ts`, `src/tui/screens/run/RunInspectScreen.tsx` | +| 7 | `change add` scaffolds runnable stubs | `noorm change add x` then `noorm change run x` reports empty-SQL, not "change not found". | `src/core/change/scaffold.ts` | +| 8 | Docs state what the code does | No doc claims a command or form that does not exist. `noorm secret` has a CLI reference page. | `docs/dev/secrets.md`, `docs/headless.md`, `docs/cli/secret.md`, `docs/cli/run.md`, `docs/guide/changes/overview.md` | +| 9 | #54 — invariant + instrumentation | Build asserts each discovered file is unique and executes once. Duplicate `(operationId, filepath)` fails loudly instead of silently updating N rows. Connection target is logged. | `src/core/runner/runner.ts`, `src/core/runner/tracker.ts`, `src/core/connection/factory.ts` | +| 10 | `run build` honours checksums | A second build over unchanged files skips them. `--force` still re-runs. The docs at `docs/headless.md:636` and `docs/dev/runner.md:13` become true. | `src/core/runner/tracker.ts` | + +## Checkpoint detail + +### CP1 — root-level flag placement + +`citty` forwards only `rawArgs.slice(subCommandArgIndex + 1)` +(`node_modules/citty/dist/index.mjs:217`), so a flag before the subcommand name is +discarded. The root command declares no `args` (`src/cli/index.ts:44-71`). + +A flag goes on the command that uses it. `-c`/`--cwd` is the sole exception, handled by +`extractGlobalCwd` (`src/cli/index.ts`), which strips `-c `/`--cwd `/`--cwd=` +from argv before dispatch — it must run before dispatch because it sets the working directory +everything else (project discovery, config resolution) resolves against, and `-c` already +means `--config` after the subcommand. `--dry-run`, `--json`, `--yes` are **not** hoisted; +they are per-subcommand, exactly like `--config` and `--force` already were. + +Rule 1 of the design still applies: a flag that cannot be honoured is an error. Any flag +other than `-c`/`--cwd` seen before the subcommand exits non-zero with a message naming the +correct invocation. Silent acceptance is the defect this checkpoint fixes — do not replace +one silence with another. + +Do not declare these flags at every command level; citty will not forward a parent's parsed +value to a child, so that approach adds declaration sites without fixing the drop. + +### CP2 — dry-run + +`sharedArgs.dryRun` exists (`src/cli/_utils.ts:52`). The core gate +(`src/core/runner/runner.ts:608`) already works and is exercised by `run file`, `run dir`, +`run files`, `run exec`, `change run`. Only the wiring is missing: + +- `src/cli/run/build.ts:13-17` — `args` omits `dryRun`. +- `src/sdk/types.ts:82-88` — `BuildOptions` has no `dryRun` field. +- `src/sdk/namespaces/run.ts:199,219` — both hardcode `{ force: options?.force }`. +- `src/cli/db/teardown.ts:13-18` — same omission; `ctx.noorm.db.teardown()` takes no options + at all, while `docs/guide/database/teardown.md:255` documents the flag. + +Copy the working chain from `run file` (`src/cli/run/file.ts:20,30`). Add no new branch. + +### CP3 — connection resolution + +`src/cli/db/create.ts:46` and `src/cli/db/drop.ts:50` call +`stateManager.getConfig(configName)` — a raw record read (`src/core/state/manager.ts:349`) +with no env merge — then connect with the result. + +Route both through `resolveConfig` (`src/core/config/resolver.ts:211`), which merges +`DEFAULTS <- stage <- stored <- env <- flags`. Introduce no new precedence logic; the merge +is already covered by `tests/core/config/resolver.test.ts:234-260`. + +These two commands are the complete set — every other connecting command already reaches +`resolveConfig` via `withContext`/`createContext`. Commands that read a stored config by +name for a non-connection purpose (`db transfer` destination, `vault cp`, `config *`) are +correct as-is and must not be changed. + +### CP4 — output streams + +`Logger` holds two writers (`src/core/logger/logger.ts:143`): `console` → stdout, +`diagnostics` → stderr. `#writeConsole` (`:497-543`) currently routes `--json` NDJSON to +stdout and human text to stderr. Both are backwards. + +Required behaviour, in both modes: + +- A command's **result** goes to stdout. +- The logger's **event stream** goes to stderr. + +So `noorm change history --json` writes exactly one parseable JSON document to stdout. + +`change list` (`:30-46`, `:57-61`) and `change history` (`:32-43`, `:54-58`) call +`outputResult` only under `if (args.json)`. Build the text and call `outputResult` +unconditionally, matching `src/cli/config/list.ts:40,59-63`, which already does this right. + +An empty result set prints an explicit empty-state line — issue #52's core complaint is that +a CI step cannot distinguish "no changesets" from "no output". + +`src/cli/lock/status.ts:25-41` and `src/cli/db/explore.ts:29-45` carry the same defect and +are in scope. + +### CP5 — secrets fail loudly + +Two independent silences turn a missing secret into valid SQL: + +- `src/core/template/context.ts:70` — `secrets: options.secrets ?? {}` is a plain object, so + a missing key reads as `undefined`. +- `src/core/template/utils.ts:85-95` — `sqlQuote` guards `null` but not `undefined`; + `String(undefined)` → `"undefined"` → `'undefined'`. + +Make `$.secrets` a Proxy whose `get` throws on an unknown key, naming the key and the tiers +searched. Make `sqlQuote` throw on `undefined` — defence in depth, since any other path +reaching it with `undefined` is equally a bug. + +A `has` trap must keep `'KEY' in $.secrets` working, and `Object.keys`, spread, and +`JSON.stringify` of the context must not throw. `$.secrets.KEY ?? fallback` does **not** +survive and is not meant to: `??` evaluates its left operand, which is the throwing read. +Optional probing is written `KEY in $.secrets ? $.secrets.KEY : default`. Requiring the +explicit guard is the point — `?? default` silently accepts a missing secret, which is the +coercion that produced `'undefined'` in the first place. + +The error message names only tiers that actually feed `$.secrets`. That is **config-local +only**: every render-path caller populates it from `stateManager.getAllSecrets(configName)` +(`src/core/state/manager.ts:479`), and global-local secrets live in a separate +`$.globalSecrets` context property that is never merged in. Do not name global-local or +vault. Add a test asserting the message does not overclaim. + +### CP6 — connect the vault tier + +`resolveSecret` and `buildSecretsContext` (`src/core/vault/resolve.ts:45,149`) implement the +documented three-tier precedence. `docs/dev/vault.md:169` names `buildSecretsContext()` as +*the* template-render path. Neither has a production caller — verified by grep; the only +hits are docstrings, `docs/`, and `packages/*/CHANGELOG.md`. + +The render path instead calls `stateManager.getAllSecrets(configName)` +(`src/core/state/manager.ts:479`) — config-local only. **Vault secrets reach no template, +for any identity.** The field report's "silently yielded nothing for this identity" +understates it. + +Replace `getAllSecrets` with `buildSecretsContext` at the render-context builders. +`buildSecretsContext` is async and the current builders are sync — notably +`RunNamespace.#createRunContext` (`src/sdk/namespaces/run.ts:247-259`). The callers are +already async; make the builders async and await. + +Call sites: `src/sdk/namespaces/run.ts:255`, `src/sdk/namespaces/changes.ts:461`, +`src/sdk/namespaces/templates.ts:47`, `src/cli/run/preview.ts:60`, +`src/cli/run/inspect.ts:100`, `src/tui/utils/run-context.ts:62`, +`src/tui/screens/run/RunInspectScreen.tsx:362,427`. + +The inspect/preview surfaces must resolve the same tiers as the apply path. A preview that +shows different secrets than the apply uses is the same class of silent divergence this +whole spec exists to remove. + +The counting sites (`src/cli/run/inspect.ts:163,220`) report a secret count; keep them +consistent with whatever the render path now resolves. + +`src/tui/screens/config/ConfigExportScreen.tsx:152` exports the config-local store +deliberately and is **out of scope** — do not change it. + +If the React screens make the async change genuinely invasive, stop and report rather than +leaving the apply path and the inspect path disagreeing. + +### CP7 — `change add` stubs + +`createChange` (`src/core/change/scaffold.ts:88-149`) creates the dated directory, empty +`change/` and `revert/` subdirectories, and `changelog.md`. Both directories stay empty. + +The resulting changeset is worse than inert. `parseChange` throws `ChangeValidationError` +when both folders are empty (`src/core/change/parser.ts:115-121`); `#loadChange` +(`src/core/change/manager.ts:605-630`) catches it, finds no DB history, and re-throws +`ChangeNotFoundError` (`:624`). The user is told the change does not exist, when it does. + +Scaffold `change/001_.sql` and `revert/001_.sql` containing a comment naming +what belongs in each. Match the loader's expectations exactly: +`SEQUENCE_REGEX = /^(\d{3})_(.+)$/` (`src/core/change/parser.ts:49`), extensions from +`SQL_EXTENSIONS` (`:40`), ordering by `localeCompare` on filename (`:434`). + +A stub containing only comments must still surface as an actionable message, not as a +success. `executeChange` already rejects template-only/empty content +(`src/core/change/executor.ts:148-157`) — verify the stub lands on that path and reads +clearly. + +Update `tests/core/change/scaffold.test.ts:51-65`, which currently asserts +`changeFiles`/`revertFiles` start empty. + +### CP8 — docs + +Each item below is a statement that is false today. + +- `docs/dev/secrets.md:113` and `:151-153` — claim there is no headless `noorm secret set` + and direct users to `noorm vault set` for config-scoped local secrets. + `src/cli/secret/set.ts:12-65` is fully headless (positional `key`/`value`, no TTY gate), + and `docs/guide/environments/secrets.md:27-40` documents it correctly. The dev doc + contradicts both the code and its own user guide. This is the misdirection named in #50. +- `docs/headless.md:885` — says `change add` creates `change.sql` and `revert.sql`. It + creates `change/` and `revert/` *directories*. After CP7, document the stub files. +- `docs/headless.md:55,641` — documents `--dry-run` before the subcommand. `--dry-run` is + per-subcommand only; correct after CP1 to the leaf-placed form. +- `docs/cli/` has no `secret.md`, while `noorm vault` has both a dev and a guide page. + Add one covering `secret set|list|rm`, cross-linked to the vault page, stating plainly + which tier is which — the tier confusion is what cost the reporter the time. +- `docs/guide/changes/overview.md:91-102` attributes changeset scaffolding to the TUI wizard + and never names `noorm change add`. Document the CLI command and the on-disk layout it + produces, including the `NNN_` convention and multi-file ordering (#53's explicit ask). + +Prose follows the `atomic-writing` skill. Do not invent behaviour — every statement must be +checked against the code as it stands after CP1-CP7. + +### CP9 — #54: double execution ruled out + +**Finding: no double-execution mechanism exists.** Verified at every layer. + +- Kysely never retries a query — `MssqlConnection.executeQuery` calls `execSql` once + (`node_modules/kysely/dist/cjs/dialect/mssql/mssql-driver.js:130`). +- tedious never resubmits a `Request`; its only retry + (`node_modules/tedious/lib/connection.js:2320`) is reached solely from the LOGIN7 catch + block, before any request is sent. +- No connection reset on release — `src/core/connection/dialects/mssql.ts:133-149` sets + neither `resetConnectionsOnRelease` nor `tedious.resetConnectionOnRelease`, and `reset` + never replays a prior batch regardless. +- Discovery cannot duplicate: one dirent per push (`src/core/runner/runner.ts:1355-1393`), + and `filterFilesByPaths` is `Array.prototype.filter` (`src/core/shared/files.ts:61`). +- The execution loop is sequential `for` + `await` with no retry wrapper (`:700-742`). +- The v1/17 per-file retry landed in `src/core/change/executor.ts`, which `runner.ts` does + not import. + +Do not implement a fix for double execution. Deliver the invariants that make any +recurrence self-diagnosing, and the real defect CP10 names. + +1. Assert discovery uniqueness before execution — `files.length === new Set(files).size` — + and fail loudly naming duplicates. Uniqueness is currently emergent, not enforced. Also + assert `results.length === files.length` after the loop. +2. `updateFileExecution()` (`src/core/runner/tracker.ts:497-518`) uses + `WHERE change_id = ? AND filepath = ?` and tolerates any row count. Fail when + `numUpdatedRows !== 1`, not merely when `=== 0`. **Why:** more than one match is exactly + what would mask an upstream duplicate from `needsRun`. +3. `needsRun()`'s DB-error path (`:131-141`) returns `needsRun: true, reason: 'new'` on a + failed SELECT. Distinguish "no record" from "could not read"; a transient error must not + read as a new file. +4. On file failure, report prior successful executions of that filepath — one extra SELECT + on the failure path only. This is the single line that would have answered #54 on sight. +5. Emit the resolved connection target on every connecting command. `connection:open` + currently carries `{configName, dialect}` only (`src/core/connection/factory.ts:182`); + add host, port, and database. This permanently removes the "which database was I + actually talking to" ambiguity that #51 creates. + +Add a regression test asserting a build executes each discovered file exactly once. + +### CP10 — `run build` never skips anything + +The defect #54 actually surfaced. Higher severity than any of the six filed issues, present +since the initial release, and contradicted by two shipped doc pages. + +`executeFiles` inserts a `pending` execution row for every discovered file *before* the loop +(`src/core/runner/runner.ts:677` → `src/core/runner/tracker.ts:443-482`). `needsRun` then +selects the newest row for that filepath by `executions.id desc` +(`src/core/runner/tracker.ts:126`) — which is always the pending row this very run just +inserted — and `:163-167` returns `{ needsRun: true, reason: 'new' }` for `pending`. + +**Checksum-based skipping is structurally unreachable for `run build`, `run dir`, and +`run files`.** `run file` is unaffected: `executeSingleFile` inserts after execution +(`runner.ts:1087`). + +Already known in-repo and never filed — `tests/cli/run/build.test.ts:147` calls it +"a latent runner bug" in a comment. + +Both shipped docs promise the opposite: + +- `docs/headless.md:636` — "Uses checksums to skip unchanged files." +- `docs/dev/runner.md:13` — "Files that haven't changed are skipped." + +Consequence, and the reason #54 looked like double execution: every `run build` re-executes +every file, so a second build against a populated database is *guaranteed* to fail at the +first non-idempotent DDL statement. For the reporter's schema that is `CREATE TYPE mls_id` — +the first statement of the second phase, exactly as reported. + +Fix: exclude the current operation's own rows from the `needsRun` lookup — `change_id <> +operationId`, or `executions.id <` the first id inserted by this run. + +**Do not "scope `needsRun` to the current operation".** An earlier draft of this spec said +that; it is backwards and would cement the defect while appearing to fix it. The pending row +that must be ignored *is* the current operation's. + +Acceptance: a second `run build` over unchanged files skips them and reports them as +skipped; `--force` still re-runs everything; a changed file still re-runs. Verify against a +live database, not only a mock — the defect lives in a SQL query's ordering. + +This changes behaviour that some tests may silently depend on. If a previously-passing test +breaks because it assumed files always re-run, that is a real finding: report it rather than +weakening the fix. + +## Test requirements + +TDD: failing test first, then implementation. Every checkpoint needs a test that fails for +the reported reason before the fix. + +- CP1 — a flag works after the subcommand; the same flag placed before the subcommand exits + non-zero naming the correct form. `-c`/`--cwd` is the exception and still works before the + subcommand. +- CP2 — dry-run build against a live DB creates zero objects; sibling non-dry-run creates them. +- CP3 — env vars + a different active config; assert the env database is targeted. +- CP4 — capture stdout and stderr separately. Assert result on stdout, empty-state line + present, and `--json` stdout parses as a single JSON document. +- CP5 — missing key throws naming the key; `?? fallback` and `in` still work; + `sqlQuote(undefined)` throws. +- CP6 — a vault-only secret resolves in a template; precedence order holds. +- CP7 — `change add` then `change run` gives the empty-SQL message, not "change not found". +- CP9 — a build executes each file exactly once; a duplicated input fails loudly. + +Integration tests requiring MSSQL use `TEST_MSSQL_*` from `tests/sample.env` (port 11433). + +## Non-goals + +- Changing the `change/` + `revert/` directory convention (#53 rules it out). +- Redesigning vault identity or key distribution. CP6 connects the existing resolver. +- Fixing the `mock.module` barrel leak in `tests/cli/app-context.test.tsx`. It is + pre-existing, CI already works around it by splitting runs, and a correct fix is test + architecture work rather than part of six CLI defects. Worth its own issue. +- The `max-len` lint error at `tests/sdk/destructive-ops.test.ts:75` (203 chars). Present on + `next`, untouched by this branch. +- `src/tui/screens/config/ConfigExportScreen.tsx` — exports the local store by design. + +## Change log + +- 2026-07-25 — initial spec from the six issues + six investigation traces and one + empirical CLI probe confirming the flag-position drop. CP9 pending strategist RCA. +- 2026-07-25 — CP9 rewritten after RCA. Double execution ruled out at every layer + (kysely, tedious, tarn, discovery, filter, loop). **Corrected a defect in the previous + CP9 body:** it directed scoping `needsRun` *to* the current operation, which is backwards + and would have cemented the bug. Added CP10 for the dead skip path — the defect #54 + actually surfaced, and the reason a second build fails on the first non-idempotent DDL. +- 2026-07-25 — Baseline section rewritten. It previously recorded "3649 pass / 52 skip / + 18 fail" from a single whole-tree run and called the 18 test-double gaps. Both were wrong: + CI never runs the tree in one process, and the failures are `mock.module` barrel + contamination from `tests/cli/app-context.test.tsx`. Under CI's four-run split the branch + is 3719 pass / 52 skip / 0 fail. +- 2026-07-25 — CP5 corrected on two counts, both found during implementation. + The body claimed `$.secrets.KEY ?? fallback` would keep working; it cannot, because `??` + evaluates the throwing read. Optional probing is the `in` guard, and that is the intended + contract rather than a concession. The body also contradicted CP6 on which tiers feed + `$.secrets` — it is config-local only. +- 2026-07-25 — CP1 reversed. `--dry-run`, `--json`, `--yes` are no longer hoisted to any + position; only `-c`/`--cwd` is a root-level flag, and every other flag — including these + three — is per-subcommand and errors before the subcommand, matching `--config`/`--force`. + **Superseded:** CP1 previously hoisted `--dry-run`/`--json`/`--yes` out of `rawArgs` before + citty dispatch (the design doc's D1) so they worked in either position. That created an + asymmetry with `--config`/`--force`, which were never hoisted, for no reason beyond + convenience. The standard is now uniform: a flag goes on the command that uses it. diff --git a/docs/wiki/CLAUDE.md b/docs/wiki/CLAUDE.md index 13f4e3d0..433de0f7 100644 --- a/docs/wiki/CLAUDE.md +++ b/docs/wiki/CLAUDE.md @@ -7,15 +7,14 @@ description: Authoritative steering for the signals/wiki inferrer when operating the inferrer reads this and treats it as authoritative> ## Framework -# NestJS monorepo (not plain Express) +# Bun workspace monorepo — TypeScript, Ink/React TUI, Citty CLI, Kysely SQL layer ## Domains -# - src/billing/ and src/payments/ are one domain ("payments") -# - src/internal-tools/ is scratch code — not a real domain +# - docs/wiki/index.md's Domains table is already correct; no override needed here ## Build -# - Build: pnpm turbo build -# - Test: pnpm test:ci (not pnpm test — that runs watch mode) +# - Build: bun run build +# - Test: bun run test (CI splits into 4 serial groups — see .claude/rules and docs/wiki/index.md) ## Ignore for domains # - vendor/ diff --git a/docs/wiki/core-change.md b/docs/wiki/core-change.md index c488df2c..480bc591 100644 --- a/docs/wiki/core-change.md +++ b/docs/wiki/core-change.md @@ -15,11 +15,11 @@ Change directories hold a `manifest.json` and SQL files. Each change has a descr - [`src/core/change/scaffold.ts`](../../src/core/change/scaffold.ts) — create/add/remove/rename/reorder change files on disk - [`src/core/change/parser.ts`](../../src/core/change/parser.ts) — `parseChange`, `discoverChanges`, `resolveManifest`, `validateChange`, `parseSequence`, `parseDescription` - [`src/core/change/executor.ts`](../../src/core/change/executor.ts) — `executeChange`, `revertChange`; applies SQL via the runner, records results. Each gates via `assertPolicy` (`core/policy`) against `ChangeContext.access`/`channel` before running (`change:run`/`change:revert` permissions) -- [`src/core/change/history.ts`](../../src/core/change/history.ts) — `ChangeHistory`; queries `__noorm_change__` and `__noorm_executions__` for per-change and per-file history +- [`src/core/change/history.ts`](../../src/core/change/history.ts) — `ChangeHistory`; queries `__noorm_change__` and `__noorm_executions__` for per-change and per-file history; selects the change row's `id` and surfaces it as `appliedHistoryId` on `ChangeStatus` - [`src/core/change/tracker.ts`](../../src/core/change/tracker.ts) — `ChangeTracker`; `canRevert` logic, orphaned-change detection -- [`src/core/change/manager.ts`](../../src/core/change/manager.ts) — `ChangeManager`; high-level facade: `list`, `run`, `revert`, `ff` (fast-forward) +- [`src/core/change/manager.ts`](../../src/core/change/manager.ts) — `ChangeManager`; high-level facade: `list`, `run`, `revert`, `ff` (fast-forward), `rewind` (revert back to a target change, ordering applied changes by `appliedAt` descending then `appliedHistoryId` descending) - [`src/core/change/validation.ts`](../../src/core/change/validation.ts) — `validateChangeContent`; structural content checks -- [`src/core/change/types.ts`](../../src/core/change/types.ts) — all change types, error classes (`ChangeValidationError`, `ChangeNotFoundError`, etc.) +- [`src/core/change/types.ts`](../../src/core/change/types.ts) — all change types, error classes (`ChangeValidationError`, `ChangeNotFoundError`, etc.); `ChangeStatus`/`ChangeListItem` carry an optional `appliedHistoryId: number | null` ## Docs @@ -45,3 +45,4 @@ Change directories hold a `manifest.json` and SQL files. Each change has a descr - `parseSequence` extracts a numeric prefix from filename for ordering. - `DEFAULT_CHANGE_OPTIONS` and `DEFAULT_BATCH_OPTIONS` define timeout and retry defaults. - Error classes extend `Error` with a `code` field; callers check `code` to distinguish failure modes. +- `ChangeManager.rewind()` sorts applied changes by `appliedAt` descending, tiebreaking on `appliedHistoryId` (the history row's autoincrement id) descending when two changes share the same second-precision `appliedAt` — e.g. entries applied within the same `change ff` batch. diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 7abe9127..2d85e4a5 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -4,14 +4,14 @@ type: Index --- repo -f4d4ca36e98785afdb428172b90890d2f69ca66e +33101f0bf56f53ee3568f149bd7f99afd795ead2 1 # Project signals ## Framework & runtime -- **Language:** TypeScript (80% LOC, 872 files), Bun runtime (>=1.2), Node >=22.13 +- **Language:** TypeScript (80% LOC, 887 files), Bun runtime (>=1.2), Node >=22.13 - **SQL layer:** Kysely query builder + executor; dialect-aware across PostgreSQL, MySQL, MSSQL, SQLite - **TUI:** Ink 6 + React 19 ([`src/tui/`](../../src/tui)); Citty for CLI arg parsing ([`src/cli/`](../../src/cli)) - **Event bus:** `@logosdx/observer` (`ObserverEngine`); module-scope singleton in [`src/core/observer.ts`](../../src/core/observer.ts) @@ -42,8 +42,8 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte | Language | LOC | Files | % | |----------|-----|-------|---| -| TypeScript | 204246 | 886 | 80% | -| Markdown | 43000 | 195 | 17% | +| TypeScript | 204655 | 887 | 80% | +| Markdown | 43261 | 198 | 17% | | YAML | 1114 | 16 | 1% | | JavaScript | 1005 | 22 | 1% | | HTML | 955 | 26 | 2% | @@ -54,7 +54,7 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte - **CI:** GitHub Actions (`ubuntu-24.04`), Bun 1.3.11 pinned; 4 test groups + 3 example jobs per push to master/main - **DB services (CI):** Postgres 17 on 15432, MySQL 8.0 on 13306, MSSQL 2022 on 11433 -- **DB services (local):** [`docker-compose.yml`](../../docker-compose.yml) at repo root (same ports) +- **DB services (local):** [`docker-compose.test.yml`](../../docker-compose.test.yml) at repo root (same ports) - **Publish:** Changesets-driven (`changeset publish`) via [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml); packages: `@noormdev/cli` and `@noormdev/sdk` - **Binary release:** `bun build --compile` → GitHub Releases via [`.github/workflows/release-binary.yml`](../../.github/workflows/release-binary.yml) - **Docs:** VitePress, deployed via [`.github/workflows/docs.yml`](../../.github/workflows/docs.yml) @@ -71,10 +71,10 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte | core-policy | [`src/core/policy/`](../../src/core/policy), [`tests/core/policy/`](../../tests/core/policy) | Access-control policy: role×permission matrix, SQL statement classifier, legacy `protected`→`access` migration | [`docs/wiki/core-policy.md`](core-policy.md) | | sdk | [`src/sdk/`](../../src/sdk), [`src/core/dt/`](../../src/core/dt), [`packages/sdk/`](../../packages/sdk), [`tests/sdk/`](../../tests/sdk), [`tests/integration/sdk/`](../../tests/integration/sdk) | Programmatic API (`createContext`) + DT binary serialization format | [`docs/wiki/sdk.md`](sdk.md) | | cli | [`src/cli/`](../../src/cli), [`packages/cli/`](../../packages/cli), [`skills/noorm/`](../../skills/noorm), [`tests/cli/`](../../tests/cli) | Citty CLI with 17 command groups, headless mode, binary distribution | [`docs/wiki/cli.md`](cli.md) | -| tui | [`src/tui/`](../../src/tui), [`src/hooks/`](../../src/hooks), [`.claude/rules/tui-development.md`](../../.claude/rules/tui-development.md), [`tests/cli/components/`](../../tests/cli/components), [`tests/cli/hooks/`](../../tests/cli/hooks), [`tests/cli/screens/`](../../tests/cli/screens) | Ink/React TUI with focus manager, keyboard routing, per-domain screens | [`docs/wiki/tui.md`](tui.md) | +| tui | [`src/tui/`](../../src/tui), [`.claude/rules/tui-development.md`](../../.claude/rules/tui-development.md), [`tests/cli/components/`](../../tests/cli/components), [`tests/cli/hooks/`](../../tests/cli/hooks), [`tests/cli/screens/`](../../tests/cli/screens) | Ink/React TUI with focus manager, keyboard routing, per-domain screens | [`docs/wiki/tui.md`](tui.md) | | mcp-rpc | [`src/mcp/`](../../src/mcp), [`src/rpc/`](../../src/rpc), [`src/cli/mcp/`](../../src/cli/mcp), [`tests/core/mcp/`](../../tests/core/mcp), [`tests/core/rpc/`](../../tests/core/rpc) | MCP server over stdio wrapping flat RPC command registry, permission-gated dispatch | [`docs/wiki/mcp-rpc.md`](mcp-rpc.md) | | worker-bridge | [`src/core/worker-bridge/`](../../src/core/worker-bridge), [`src/workers/`](../../src/workers), [`tests/core/worker-bridge/`](../../tests/core/worker-bridge), [`tests/workers/`](../../tests/workers) | Hub-and-spoke worker threads for DT serialization and DB connection worker | [`docs/wiki/worker-bridge.md`](worker-bridge.md) | -| infra | [`.github/`](../../.github), [`scripts/`](../../scripts), [`examples/`](../../examples), [`docs/`](..), `tsup.*.config.ts`, [`docker-compose.yml`](../../docker-compose.yml), [`bunfig.toml`](../../bunfig.toml) | CI, build pipeline, binary release, example projects, VitePress docs | [`docs/wiki/infra.md`](infra.md) | +| infra | [`.github/`](../../.github), [`scripts/`](../../scripts), [`examples/`](../../examples), [`docs/`](..), `tsup.*.config.ts`, [`docker-compose.test.yml`](../../docker-compose.test.yml), [`bunfig.toml`](../../bunfig.toml) | CI, build pipeline, binary release, example projects, VitePress docs | [`docs/wiki/infra.md`](infra.md) | ## Cross-cutting diff --git a/docs/wiki/infra.md b/docs/wiki/infra.md index 5a742067..067e2351 100644 --- a/docs/wiki/infra.md +++ b/docs/wiki/infra.md @@ -19,7 +19,6 @@ Build pipeline, CI, binary release, package publishing, and reference examples. - [`scripts/build.mjs`](../../scripts/build.mjs) — builds both `@noormdev/cli` and `@noormdev/sdk` packages via tsup - [`scripts/build-binary.mjs`](../../scripts/build-binary.mjs) — `bun build --compile` to produce standalone binary - [`scripts/Dockerfile`](../../scripts/Dockerfile) — Docker image for binary builds -- [`scripts/install.sh`](../../scripts/install.sh) — shell installer for binary distribution - [`scripts/ralph-wiggum.sh`](../../scripts/ralph-wiggum.sh) — release automation helper - [`tsup.cli.config.ts`](../../tsup.cli.config.ts) — tsup config for CLI package build - [`tsup.sdk.config.ts`](../../tsup.sdk.config.ts) — tsup config for SDK package build @@ -27,7 +26,7 @@ Build pipeline, CI, binary release, package publishing, and reference examples. - [`tsconfig.sdk-types.json`](../../tsconfig.sdk-types.json) — SDK type extraction config - [`tsconfig.test.json`](../../tsconfig.test.json) — test TypeScript config - [`bunfig.toml`](../../bunfig.toml) — Bun runtime config -- [`docker-compose.yml`](../../docker-compose.yml) — local dev databases: PostgreSQL (15432), MySQL (13306), MSSQL (11433) +- [`docker-compose.test.yml`](../../docker-compose.test.yml) — local dev databases: PostgreSQL (15432), MySQL (13306), MSSQL (11433) ## Docs diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index f4d4ca36..33101f0b 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -7,7 +7,7 @@ │ └── opentui/ (2) │ ├── references/ (0 files, 8 dirs) │ └── SKILL.md (a62967f, 195L, 7253ch, 7427B) -├── .changeset/ (71) +├── .changeset/ (73) │ ├── README.md (bf33c79, 8L, 510ch, 510B) │ ├── binary-release-automation.md (b25adb3, 6L, 110ch, 110B) │ ├── bold-wolves-call.md (a1927b2, 10L, 442ch, 442B) @@ -55,7 +55,7 @@ │ ├── mssql-teardown-sdk.md (37fb6c1, 8L, 917ch, 929B) │ ├── noorm-ci-namespace.md (0e98b5d, 28L, 2748ch, 2762B) │ ├── noorm-init-sql-repl.md (19e9978, 8L, 373ch, 377B) -│ ├── pre.json (9860d10, 80L, 2219ch, 2219B) +│ ├── pre.json (48b4181, 83L, 2304ch, 2304B) │ ├── quiet-pandas-sleep.md (a17d93c, 7L, 220ch, 220B) │ ├── rebuild-fix.md (174afad, 5L, 69ch, 69B) │ ├── reset-ignores-preserve-tables-cli.md (5946a54, 13L, 594ch, 596B) @@ -72,6 +72,8 @@ │ ├── tty-yes-flag-cli.md (a93a4e3, 8L, 723ch, 723B) │ ├── tvp-support.md (48e6ffe, 8L, 328ch, 330B) │ ├── typed-tuples-sdk.md (c803a7b, 6L, 216ch, 218B) +│ ├── update-progress-stall.md (ab5f66a, 5L, 642ch, 644B) +│ ├── update-resumable-download.md (4b63e28, 5L, 586ch, 588B) │ ├── vault-init-idempotent-sdk.md (93cbe93, 8L, 545ch, 547B) │ ├── version-command.md (379be83, 5L, 138ch, 138B) │ ├── version-debug.md (268b067, 5L, 96ch, 96B) @@ -83,8 +85,8 @@ │ ├── rules/ (4) │ │ ├── documentation.md (69cdfde, 30L, 837ch, 837B) │ │ ├── testing.md (3c3b98d, 58L, 1070ch, 1070B) -│ │ ├── tui-development.md (e73a146, 163L, 4197ch, 4199B) -│ │ └── typescript.md (56de6aa, 312L, 7494ch, 7522B) +│ │ ├── tui-development.md (68c920f, 159L, 4031ch, 4033B) +│ │ └── typescript.md (1515159, 308L, 8136ch, 8164B) │ └── skills/ (2) │ ├── noorm-design/ (7) │ │ ├── assets/ (5 files, 1 dir) @@ -98,9 +100,9 @@ ├── .github/ (1) │ └── workflows/ (4) │ ├── ci.yml (1f9faff, 445L, 18826ch, 19874B) -│ ├── docs.yml (4c7bee9, 48L, 1371ch, 1371B) -│ ├── publish.yml (7f0ea77, 85L, 2128ch, 2128B) -│ └── release-binary.yml (5123f48, 38L, 1053ch, 1053B) +│ ├── docs.yml (1d7b2ac, 51L, 1456ch, 1456B) +│ ├── publish.yml (b0bca54, 93L, 2354ch, 2354B) +│ └── release-binary.yml (56d023f, 46L, 1333ch, 1333B) ├── docs/ (15) │ ├── .vitepress/ (2) │ │ ├── theme/ (5) @@ -113,7 +115,7 @@ │ ├── cli/ (9) │ │ ├── flags.md (9b7f8eb, 94L, 3136ch, 3152B) │ │ ├── help.md (bbb20fe, 47L, 1421ch, 1429B) -│ │ ├── identity.md (00a7538, 79L, 3191ch, 3209B) +│ │ ├── identity.md (1578c85, 79L, 3191ch, 3209B) │ │ ├── init.md (ca7879c, 70L, 2873ch, 2889B) │ │ ├── run.md (654e92f, 123L, 3831ch, 3845B) │ │ ├── settings-edit.md (56b510a, 21L, 588ch, 590B) @@ -123,58 +125,57 @@ │ ├── design/ (2) │ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) │ │ └── config-access-roles.md (bd73baa, 116L, 6566ch, 6670B) -│ ├── dev/ (26) -│ │ ├── README.md (40f97b8, 201L, 6544ch, 8050B) +│ ├── dev/ (25) │ │ ├── change.md (1ebd1fc, 509L, 15457ch, 15519B) -│ │ ├── ci.md (2bd9b8a, 205L, 6796ch, 6804B) +│ │ ├── ci.md (e602257, 205L, 6796ch, 6804B) │ │ ├── config-sharing.md (61852f9, 269L, 8593ch, 8611B) -│ │ ├── config.md (8e00bcc, 437L, 12957ch, 12993B) +│ │ ├── config.md (82eff09, 436L, 12921ch, 12957B) │ │ ├── datamodel.md (de196f7, 1040L, 29861ch, 29989B) │ │ ├── explore.md (6895cb3, 325L, 8889ch, 8959B) -│ │ ├── headless.md (d1846fa, 756L, 17719ch, 17855B) +│ │ ├── headless.md (232109b, 758L, 18161ch, 18301B) │ │ ├── identity.md (4a4ea8e, 350L, 12135ch, 12159B) │ │ ├── index.md (3b3ccb3, 42L, 1430ch, 1430B) │ │ ├── ink-cheatsheet.md (a188494, 1427L, 28669ch, 28689B) │ │ ├── ink-testing-library-cheatsheet.md (67fa6e5, 737L, 14708ch, 14708B) │ │ ├── lock.md (1325257, 330L, 8577ch, 8607B) │ │ ├── logger.md (b8754a5, 521L, 15363ch, 16867B) -│ │ ├── project-discovery.md (ba6c27b, 128L, 4178ch, 4184B) +│ │ ├── project-discovery.md (c1fd8f0, 128L, 4327ch, 4335B) │ │ ├── runner.md (ec9317d, 516L, 18416ch, 18438B) -│ │ ├── sdk.md (3efc2f5, 1094L, 26569ch, 26615B) -│ │ ├── secrets.md (2312d48, 297L, 10122ch, 10192B) +│ │ ├── sdk.md (2e6ed89, 1092L, 27225ch, 27275B) +│ │ ├── secrets.md (00a6e82, 297L, 10122ch, 10192B) │ │ ├── settings.md (b87e542, 746L, 18539ch, 18551B) │ │ ├── sql-terminal.md (381dcaa, 321L, 8991ch, 10355B) │ │ ├── state.md (840d27d, 362L, 9331ch, 9361B) │ │ ├── teardown.md (fc58de8, 359L, 11952ch, 11984B) │ │ ├── template.md (e6310f7, 460L, 11515ch, 11603B) -│ │ ├── transfer.md (47b8c7b, 663L, 22886ch, 23134B) -│ │ ├── vault.md (3095975, 521L, 16055ch, 17667B) +│ │ ├── transfer.md (9bd3975, 673L, 23433ch, 23681B) +│ │ ├── vault.md (3a6045a, 520L, 16028ch, 17640B) │ │ └── version.md (c0f51c6, 644L, 17504ch, 17516B) │ ├── getting-started/ (4) -│ │ ├── building-your-sdk.md (8389a71, 752L, 16897ch, 17383B) +│ │ ├── building-your-sdk.md (be6b3e1, 752L, 16915ch, 17401B) │ │ ├── concepts.md (470bf64, 347L, 11180ch, 11540B) │ │ ├── first-build.md (6ea690f, 332L, 8338ch, 8434B) -│ │ └── installation.md (447f8bb, 114L, 2936ch, 2998B) +│ │ └── installation.md (d63e5d0, 129L, 3969ch, 4039B) │ ├── guide/ (6) │ │ ├── automation/ (3) -│ │ │ ├── ci.md (04e95d2, 345L, 11915ch, 11951B) +│ │ │ ├── ci.md (83fe635, 345L, 11915ch, 11951B) │ │ │ ├── mcp.md (9543edb, 127L, 4891ch, 5175B) │ │ │ └── non-interactive.md (556d953, 121L, 4392ch, 4406B) │ │ ├── changes/ (3) -│ │ │ ├── forward-revert.md (1754625, 285L, 8097ch, 8101B) -│ │ │ ├── history.md (91ab04d, 320L, 9917ch, 9917B) -│ │ │ └── overview.md (909a111, 348L, 13623ch, 13945B) +│ │ │ ├── forward-revert.md (e339875, 285L, 8097ch, 8101B) +│ │ │ ├── history.md (bbcbca7, 320L, 9917ch, 9917B) +│ │ │ └── overview.md (5eb6602, 348L, 13623ch, 13945B) │ │ ├── database/ (5) │ │ │ ├── create.md (ec7375f, 142L, 4947ch, 4971B) -│ │ │ ├── explore.md (929a4ee, 481L, 17845ch, 22315B) -│ │ │ ├── teardown.md (4ed72b7, 364L, 10271ch, 10285B) +│ │ │ ├── explore.md (aed5d6e, 481L, 17845ch, 22315B) +│ │ │ ├── teardown.md (5c1954d, 364L, 10327ch, 10341B) │ │ │ ├── terminal.md (a88f37a, 225L, 7075ch, 9051B) -│ │ │ └── transfer.md (a6608ba, 369L, 10184ch, 10210B) +│ │ │ └── transfer.md (415bbf3, 369L, 10184ch, 10210B) │ │ ├── environments/ (4) -│ │ │ ├── configs.md (847237a, 347L, 11140ch, 11168B) -│ │ │ ├── secrets.md (bb92c69, 200L, 6828ch, 6840B) +│ │ │ ├── configs.md (6b4f3b1, 346L, 11236ch, 11264B) +│ │ │ ├── secrets.md (fef816e, 200L, 6828ch, 6840B) │ │ │ ├── stages.md (76e78ed, 258L, 7545ch, 7551B) -│ │ │ └── vault.md (bd35325, 257L, 7681ch, 8229B) +│ │ │ └── vault.md (19c44c0, 257L, 7681ch, 8229B) │ │ ├── sql-files/ (3) │ │ │ ├── execution.md (4a53b9a, 249L, 8166ch, 8308B) │ │ │ ├── organization.md (1d17d3d, 327L, 7430ch, 7892B) @@ -198,16 +199,17 @@ │ │ │ └── logo.svg (8d46c28, 6L, 2529ch, 2529B) │ │ └── install.sh (0cc90a2, 116L, 2925ch, 2925B) │ ├── reference/ (1) -│ │ └── sdk.md (59c676d, 1433L, 41839ch, 42006B) -│ ├── spec/ (2) +│ │ └── sdk.md (d4a45c6, 1418L, 42444ch, 42625B) +│ ├── spec/ (3) │ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) -│ │ └── config-access-roles.md (c153076, 153L, 17852ch, 17952B) +│ │ ├── config-access-roles.md (d6c54a8, 155L, 18136ch, 18240B) +│ │ └── v1-45-rewind-tiebreak.md (0e35550, 61L, 5465ch, 5507B) │ ├── superpowers/ (1) │ │ └── specs/ (1) │ │ └── 2026-04-19-cli-ci-identity-design.md (6c9cc80, 938L, 32762ch, 32918B) │ ├── bun.lockb (34225b2, 190L, 125711ch, 126677B) -│ ├── headless.md (d10c758, 1612L, 38848ch, 38884B) -│ ├── index.md (4d92e08, 178L, 6324ch, 6362B) +│ ├── headless.md (3b951ee, 1622L, 39413ch, 39455B) +│ ├── index.md (23a1967, 178L, 6324ch, 6362B) │ ├── package.json (b4778f0, 24L, 657ch, 657B) │ └── tui.md (99b066a, 407L, 13994ch, 18090B) ├── examples/ (3) @@ -250,20 +252,20 @@ │ │ │ └── sql/ (11 files, 0 dirs) │ │ ├── .gitignore (ccb61cd, 40L, 446ch, 446B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (ad9092a, 20L, 413ch, 413B) +│ │ ├── CHANGELOG.md (8ba2d71, 39L, 655ch, 655B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) -│ │ ├── README.md (3b60a6a, 121L, 6801ch, 6837B) +│ │ ├── README.md (c228f5b, 121L, 6833ch, 6869B) │ │ ├── REPORT.md (4f3efcd, 161L, 12957ch, 13004B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) │ │ ├── mssql-problems.md (5083524, 328L, 23834ch, 23934B) -│ │ ├── package.json (435022e, 23L, 631ch, 631B) +│ │ ├── package.json (cf5a387, 23L, 631ch, 631B) │ │ └── tsconfig.json (7be3bae, 27L, 736ch, 736B) -│ ├── llm-memory-db-pg/ (16) +│ ├── llm-memory-db-pg/ (17) │ │ ├── .cursor/ (1) │ │ │ └── rules/ (1 file, 0 dirs) │ │ ├── .noorm/ (2) │ │ │ ├── .gitignore (14188a3, 1L, 7ch, 7B) -│ │ │ └── settings.yml (9311372, 49L, 1117ch, 1117B) +│ │ │ └── settings.yml (015d7e5, 50L, 1111ch, 1111B) │ │ ├── changes/ (2) │ │ │ ├── 2026-05-10-add-memory-tag-color/ (1 file, 2 dirs) │ │ │ └── .gitkeep (e3b0c44, 0L, 0ch, 0B) @@ -300,13 +302,14 @@ │ │ │ └── mcp-discovery.test.ts (02d4035, 765L, 24559ch, 24603B) │ │ ├── .gitignore (a94396a, 36L, 397ch, 397B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (96e019a, 20L, 410ch, 410B) +│ │ ├── CHANGELOG.md (9927bb5, 39L, 652ch, 652B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) -│ │ ├── README.md (18a81f1, 169L, 9519ch, 9823B) +│ │ ├── README.md (b97f95b, 205L, 10359ch, 10665B) │ │ ├── REPORT-PHASE-1.md (59b7d41, 103L, 9610ch, 9670B) -│ │ ├── REPORT.md (f22f51f, 140L, 17057ch, 17127B) +│ │ ├── REPORT.md (98ee180, 140L, 17043ch, 17113B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) -│ │ ├── package.json (e2a5af9, 23L, 670ch, 670B) +│ │ ├── package.json (56ab1f1, 23L, 670ch, 670B) +│ │ ├── postgres-problems.md (1cbb5b5, 200L, 13674ch, 13799B) │ │ └── tsconfig.json (4dc04b1, 30L, 735ch, 735B) │ └── todo-db/ (10) │ ├── .noorm/ (1) @@ -342,35 +345,37 @@ │ │ ├── views/ (2 files, 0 dirs) │ │ └── preload.ts (0d97cd6, 25L, 658ch, 660B) │ ├── .gitignore (81531bd, 5L, 57ch, 57B) -│ ├── CHANGELOG.md (1baf6d6, 26L, 470ch, 470B) +│ ├── CHANGELOG.md (4e31aec, 45L, 712ch, 712B) │ ├── bunfig.toml (e10e7cb, 5L, 89ch, 89B) -│ ├── package.json (a96f8c6, 22L, 581ch, 581B) +│ ├── package.json (1a9a77d, 22L, 581ch, 581B) │ └── tsconfig.json (efeae48, 17L, 484ch, 484B) ├── packages/ (2) -│ ├── cli/ (4) +│ ├── cli/ (5) │ │ ├── scripts/ (1) -│ │ │ └── postinstall.js (4ddeede, 155L, 4208ch, 4210B) -│ │ ├── CHANGELOG.md (490a0a0, 681L, 36171ch, 36347B) +│ │ │ └── postinstall.js (67c8ecf, 348L, 10155ch, 10157B) +│ │ ├── CHANGELOG.md (c98de3e, 710L, 41104ch, 41310B) +│ │ ├── LICENSE (42eaf96, 21L, 1070ch, 1070B) │ │ ├── noorm.js (e3d76e8, 41L, 1041ch, 1043B) -│ │ └── package.json (605e00c, 31L, 540ch, 540B) -│ └── sdk/ (2) -│ ├── CHANGELOG.md (84c9272, 527L, 24058ch, 24198B) -│ └── package.json (91878a9, 60L, 1093ch, 1093B) +│ │ └── package.json (f0a76ab, 31L, 540ch, 540B) +│ └── sdk/ (3) +│ ├── CHANGELOG.md (8d2ebd5, 548L, 27765ch, 27931B) +│ ├── LICENSE (42eaf96, 21L, 1070ch, 1070B) +│ └── package.json (cb0a089, 61L, 1115ch, 1115B) ├── scripts/ (5) │ ├── Dockerfile (5fe0d7a, 51L, 1595ch, 1595B) │ ├── build-binary.mjs (f598b0c, 37L, 1284ch, 1288B) │ ├── build.mjs (303daea, 40L, 1519ch, 1519B) -│ ├── install.sh (2fb9002, 175L, 3660ch, 3664B) +│ ├── check-json-placement.sh (5ef39f2, 34L, 1410ch, 1416B) │ └── ralph-wiggum.sh (7182e0a, 319L, 8973ch, 8973B) ├── skills/ (1) │ └── noorm/ (2) │ ├── references/ (4) -│ │ ├── cli.md (19df9f6, 1010L, 29280ch, 29322B) +│ │ ├── cli.md (4e1c259, 1009L, 29310ch, 29354B) │ │ ├── config.md (7d36099, 272L, 9415ch, 10115B) -│ │ ├── sdk.md (d8c194a, 646L, 21778ch, 21863B) +│ │ ├── sdk.md (8a62667, 650L, 22064ch, 22163B) │ │ └── templates.md (20dbd54, 386L, 11061ch, 11161B) -│ └── SKILL.md (d49c384, 65L, 3837ch, 3845B) -├── src/ (8) +│ └── SKILL.md (90e425a, 82L, 4768ch, 4784B) +├── src/ (7) │ ├── cli/ (20) │ │ ├── change/ (13) │ │ │ ├── _prompt.ts (a32b90c, 140L, 3465ch, 3467B) @@ -383,8 +388,8 @@ │ │ │ ├── list.ts (a3b191d, 74L, 1777ch, 1781B) │ │ │ ├── next.ts (55f3589, 110L, 3017ch, 3019B) │ │ │ ├── revert.ts (af5236e, 133L, 3749ch, 3751B) -│ │ │ ├── rewind.ts (e47452b, 132L, 3682ch, 3688B) -│ │ │ ├── rm.ts (b986149, 137L, 3829ch, 3831B) +│ │ │ ├── rewind.ts (beaeaba, 132L, 3684ch, 3690B) +│ │ │ ├── rm.ts (d07fd8f, 157L, 4945ch, 4947B) │ │ │ └── run.ts (5a0a0de, 133L, 3744ch, 3746B) │ │ ├── ci/ (4) │ │ │ ├── identity/ (3 files, 0 dirs) @@ -392,18 +397,18 @@ │ │ │ ├── init.ts (015ad40, 217L, 6764ch, 6772B) │ │ │ └── secrets.ts (5b51d3a, 231L, 6176ch, 6180B) │ │ ├── config/ (10) -│ │ │ ├── add.ts (cce6bfa, 27L, 732ch, 736B) +│ │ │ ├── add.ts (0e20b1d, 27L, 732ch, 736B) │ │ │ ├── cp.ts (cc57e45, 81L, 2209ch, 2211B) -│ │ │ ├── edit.ts (76f315f, 34L, 888ch, 892B) -│ │ │ ├── export.ts (508008b, 81L, 2213ch, 2215B) +│ │ │ ├── edit.ts (00f6992, 34L, 888ch, 892B) +│ │ │ ├── export.ts (ef4b07a, 86L, 2441ch, 2443B) │ │ │ ├── import.ts (01f8e59, 105L, 3068ch, 3070B) │ │ │ ├── index.ts (54b770a, 22L, 611ch, 613B) -│ │ │ ├── list.ts (b93a381, 74L, 2064ch, 2068B) -│ │ │ ├── rm.ts (a5c6a97, 34L, 865ch, 869B) +│ │ │ ├── list.ts (cc81ab9, 74L, 1994ch, 1998B) +│ │ │ ├── rm.ts (acf46db, 105L, 3098ch, 3098B) │ │ │ ├── use.ts (f307d0e, 79L, 2169ch, 2173B) -│ │ │ └── validate.ts (d832331, 130L, 3623ch, 3625B) +│ │ │ └── validate.ts (018893b, 75L, 2086ch, 2088B) │ │ ├── db/ (16) -│ │ │ ├── create.ts (9d3ae5b, 107L, 2993ch, 2995B) +│ │ │ ├── create.ts (b5426cf, 107L, 3023ch, 3025B) │ │ │ ├── drop.ts (7625328, 103L, 2880ch, 2882B) │ │ │ ├── explore-fks.ts (3a624f7, 79L, 1936ch, 1940B) │ │ │ ├── explore-functions.ts (cd47d2a, 132L, 3143ch, 3147B) @@ -415,9 +420,9 @@ │ │ │ ├── explore-views.ts (d002b8d, 128L, 3069ch, 3071B) │ │ │ ├── explore.ts (f62b609, 82L, 2179ch, 2181B) │ │ │ ├── index.ts (d0fc774, 28L, 610ch, 612B) -│ │ │ ├── reset.ts (c1abc6f, 59L, 1396ch, 1398B) +│ │ │ ├── reset.ts (b8343b5, 59L, 1414ch, 1416B) │ │ │ ├── teardown.ts (292a1a3, 77L, 2094ch, 2096B) -│ │ │ ├── transfer.ts (f45629b, 594L, 16502ch, 16506B) +│ │ │ ├── transfer.ts (88e617b, 669L, 19239ch, 19251B) │ │ │ └── truncate.ts (f272b7b, 63L, 1386ch, 1388B) │ │ ├── dev/ (3) │ │ │ ├── index.ts (a926fdd, 18L, 420ch, 422B) @@ -464,7 +469,7 @@ │ │ │ ├── history.ts (7c2048c, 139L, 3970ch, 3980B) │ │ │ ├── index.ts (427fc50, 38L, 1298ch, 1300B) │ │ │ ├── query.ts (84227f9, 115L, 2969ch, 2971B) -│ │ │ └── repl.ts (4888c5d, 131L, 3817ch, 3819B) +│ │ │ └── repl.ts (f0fe0a7, 137L, 4076ch, 4080B) │ │ ├── vault/ (7) │ │ │ ├── cp.ts (2adf7aa, 195L, 5472ch, 5474B) │ │ │ ├── index.ts (c96493f, 16L, 443ch, 445B) @@ -473,59 +478,61 @@ │ │ │ ├── propagate.ts (b3b523d, 113L, 2877ch, 2879B) │ │ │ ├── rm.ts (d13caa5, 91L, 2339ch, 2341B) │ │ │ └── set.ts (e2d219e, 91L, 2372ch, 2374B) -│ │ ├── _utils.ts (6e7f28c, 454L, 11208ch, 11208B) -│ │ ├── index.ts (aacc1ea, 318L, 9027ch, 9031B) +│ │ ├── _utils.ts (522f462, 478L, 12116ch, 12120B) +│ │ ├── index.ts (937328c, 343L, 11213ch, 11217B) │ │ ├── info.ts (63f9546, 351L, 10547ch, 10551B) │ │ ├── init.ts (f11a60d, 176L, 5250ch, 5252B) -│ │ ├── ui.ts (c22c772, 65L, 1675ch, 1679B) -│ │ ├── update.ts (f53462e, 110L, 2930ch, 2934B) +│ │ ├── ui.ts (28a7a9c, 70L, 1869ch, 1875B) +│ │ ├── update.ts (a124ee5, 202L, 5826ch, 5834B) │ │ └── version.ts (ca59073, 273L, 6880ch, 6890B) │ ├── core/ (30) │ │ ├── change/ (9) -│ │ │ ├── executor.ts (caa792c, 1118L, 29801ch, 31271B) -│ │ │ ├── history.ts (f0f083c, 1081L, 29641ch, 31253B) +│ │ │ ├── executor.ts (51410bd, 1297L, 35479ch, 36963B) +│ │ │ ├── history.ts (aea7694, 1251L, 36075ch, 37937B) │ │ │ ├── index.ts (edd765a, 128L, 3031ch, 4739B) -│ │ │ ├── manager.ts (3f21ad7, 616L, 15844ch, 18156B) +│ │ │ ├── manager.ts (47ed983, 632L, 16631ch, 18943B) │ │ │ ├── parser.ts (917e642, 570L, 13337ch, 14801B) -│ │ │ ├── scaffold.ts (0f2d03f, 649L, 15170ch, 17122B) +│ │ │ ├── scaffold.ts (a3b3c6c, 625L, 14745ch, 16697B) │ │ │ ├── tracker.ts (da3742c, 245L, 7183ch, 7671B) -│ │ │ ├── types.ts (ca31b61, 681L, 15684ch, 18370B) +│ │ │ ├── types.ts (b863c8d, 697L, 16318ch, 19008B) │ │ │ └── validation.ts (ca6f086, 90L, 2247ch, 2247B) -│ │ ├── config/ (4) -│ │ │ ├── index.ts (1e9a284, 124L, 3364ch, 3364B) -│ │ │ ├── resolver.ts (351ba35, 450L, 12107ch, 12111B) -│ │ │ ├── schema.ts (e9af3c9, 308L, 8239ch, 8733B) -│ │ │ └── types.ts (9516dc0, 143L, 3478ch, 3726B) -│ │ ├── connection/ (5) +│ │ ├── config/ (5) +│ │ │ ├── index.ts (f1bc7b6, 126L, 3419ch, 3419B) +│ │ │ ├── resolver.ts (23dc477, 505L, 13561ch, 13569B) +│ │ │ ├── schema.ts (224a2af, 344L, 9433ch, 9927B) +│ │ │ ├── types.ts (9516dc0, 143L, 3478ch, 3726B) +│ │ │ └── validate.ts (7ebdb26, 89L, 2464ch, 2464B) +│ │ ├── connection/ (6) │ │ │ ├── dialects/ (7 files, 0 dirs) +│ │ │ ├── defaults.ts (42ecd2d, 27L, 685ch, 685B) │ │ │ ├── factory.ts (7c5708c, 253L, 7370ch, 7370B) -│ │ │ ├── index.ts (29dcf6f, 8L, 268ch, 268B) -│ │ │ ├── manager.ts (8e53eb4, 360L, 8404ch, 8404B) +│ │ │ ├── index.ts (28dfede, 9L, 327ch, 327B) +│ │ │ ├── manager.ts (8152aa7, 348L, 8120ch, 8120B) │ │ │ └── types.ts (873d17d, 75L, 1457ch, 1457B) │ │ ├── db/ (5) │ │ │ ├── dialects/ (5 files, 0 dirs) │ │ │ ├── dual.ts (baaddd4, 174L, 4914ch, 4914B) │ │ │ ├── index.ts (04bf6c4, 53L, 1283ch, 1283B) -│ │ │ ├── operations.ts (52dc2c3, 296L, 7247ch, 7249B) -│ │ │ └── types.ts (31a89d7, 87L, 1985ch, 1985B) +│ │ │ ├── operations.ts (81c12ed, 309L, 7998ch, 8002B) +│ │ │ └── types.ts (9375e79, 95L, 2330ch, 2330B) │ │ ├── debug/ (2) │ │ │ ├── index.ts (6e232b4, 21L, 385ch, 385B) │ │ │ └── operations.ts (05257cd, 446L, 11657ch, 12391B) │ │ ├── dt/ (16) │ │ │ ├── dialects/ (4 files, 0 dirs) │ │ │ ├── constants.ts (8158c4b, 75L, 1797ch, 1797B) -│ │ │ ├── crypto.ts (d74cf8c, 107L, 3220ch, 3222B) +│ │ │ ├── crypto.ts (7586f37, 123L, 3783ch, 3787B) │ │ │ ├── deserialize.ts (1e84e10, 366L, 9011ch, 9017B) │ │ │ ├── events.ts (360f2c4, 151L, 3984ch, 3984B) -│ │ │ ├── index.ts (bcd5dc6, 1169L, 30231ch, 30253B) +│ │ │ ├── index.ts (4a6e732, 1016L, 26666ch, 26686B) │ │ │ ├── modify.ts (bad43c7, 678L, 17964ch, 17994B) │ │ │ ├── paths.ts (76dade7, 103L, 2874ch, 2886B) -│ │ │ ├── reader.ts (430fd8d, 208L, 5233ch, 5237B) -│ │ │ ├── schema.ts (e3a68b5, 385L, 9650ch, 9652B) +│ │ │ ├── reader.ts (98b263b, 212L, 5505ch, 5509B) +│ │ │ ├── schema.ts (94fb91b, 384L, 9629ch, 9631B) │ │ │ ├── serialize.ts (4c8ab00, 253L, 6001ch, 6011B) │ │ │ ├── streamer.ts (453f365, 395L, 9296ch, 9296B) -│ │ │ ├── type-map.ts (4cf9141, 142L, 3882ch, 3886B) -│ │ │ ├── types.ts (52a9b23, 428L, 10149ch, 10149B) +│ │ │ ├── type-map.ts (3d74dd0, 139L, 3762ch, 3766B) +│ │ │ ├── types.ts (e11c4e4, 413L, 9366ch, 9366B) │ │ │ ├── version.ts (6a13dc2, 216L, 5118ch, 5128B) │ │ │ └── writer.ts (3525333, 233L, 5667ch, 5667B) │ │ ├── explore/ (4) @@ -540,38 +547,39 @@ │ │ │ ├── hash.ts (751360b, 97L, 2350ch, 2350B) │ │ │ ├── index.ts (70bbcd4, 219L, 5484ch, 5484B) │ │ │ ├── resolver.ts (67fae4f, 229L, 5068ch, 5068B) -│ │ │ ├── storage.ts (29ef16f, 500L, 11805ch, 11805B) +│ │ │ ├── storage.ts (cbe771c, 525L, 12605ch, 12607B) │ │ │ ├── sync.ts (cd659cf, 438L, 11504ch, 11504B) │ │ │ └── types.ts (1bb32e8, 207L, 5085ch, 5085B) │ │ ├── lifecycle/ (4) │ │ │ ├── handlers.ts (9847a66, 228L, 5414ch, 5414B) │ │ │ ├── index.ts (2b435e9, 37L, 938ch, 938B) -│ │ │ ├── manager.ts (6910dac, 604L, 13368ch, 13368B) +│ │ │ ├── manager.ts (3ce681d, 573L, 12745ch, 12745B) │ │ │ └── types.ts (acdd08c, 155L, 3586ch, 3586B) │ │ ├── lock/ (4) │ │ │ ├── errors.ts (33152dd, 134L, 3411ch, 3411B) │ │ │ ├── index.ts (57fe77e, 46L, 1049ch, 1049B) -│ │ │ ├── manager.ts (3e13178, 607L, 16367ch, 17343B) +│ │ │ ├── manager.ts (4915e50, 597L, 16103ch, 16835B) │ │ │ └── types.ts (57e8f2b, 119L, 2831ch, 2831B) -│ │ ├── logger/ (11) +│ │ ├── logger/ (12) │ │ │ ├── classifier.ts (4fa4422, 179L, 3747ch, 3747B) │ │ │ ├── color.ts (1dd10fa, 192L, 4033ch, 4045B) │ │ │ ├── formatter.ts (07ecfee, 450L, 12942ch, 12944B) -│ │ │ ├── index.ts (7d627c2, 66L, 1513ch, 1513B) +│ │ │ ├── index.ts (9e238ba, 69L, 1613ch, 1613B) │ │ │ ├── init.ts (fe47e04, 178L, 4739ch, 4739B) -│ │ │ ├── logger.ts (15c3907, 801L, 18791ch, 19523B) +│ │ │ ├── logger.ts (e7f6847, 800L, 18820ch, 19552B) │ │ │ ├── queue.ts (b5b3918, 298L, 6507ch, 7483B) │ │ │ ├── reader.ts (50e90ff, 150L, 3536ch, 3536B) │ │ │ ├── redact.ts (4314060, 389L, 9109ch, 10329B) │ │ │ ├── rotation.ts (3579fb5, 250L, 5628ch, 5628B) +│ │ │ ├── timestamp.ts (e2c9452, 58L, 2205ch, 2212B) │ │ │ └── types.ts (5036c8b, 127L, 2773ch, 2773B) │ │ ├── policy/ (6) -│ │ │ ├── check.ts (74b7467, 169L, 5317ch, 5331B) +│ │ │ ├── check.ts (44c682a, 205L, 6648ch, 6666B) │ │ │ ├── classify.ts (1d30fc5, 821L, 19394ch, 19426B) -│ │ │ ├── index.ts (dbf341d, 20L, 618ch, 618B) +│ │ │ ├── index.ts (919c2d8, 20L, 655ch, 655B) │ │ │ ├── legacy-access.ts (f2b9b23, 35L, 1362ch, 1366B) -│ │ │ ├── matrix.ts (b5378bb, 27L, 1324ch, 1327B) -│ │ │ └── types.ts (fca41bb, 74L, 2275ch, 2281B) +│ │ │ ├── matrix.ts (143d78b, 28L, 1400ch, 1403B) +│ │ │ └── types.ts (5b2506c, 74L, 2289ch, 2295B) │ │ ├── runner/ (6) │ │ │ ├── checksum.ts (d21f46d, 99L, 2650ch, 2650B) │ │ │ ├── index.ts (5156823, 55L, 1020ch, 1020B) @@ -585,13 +593,13 @@ │ │ │ ├── index.ts (f096e30, 78L, 1597ch, 1597B) │ │ │ ├── manager.ts (6b5a0dd, 1034L, 22970ch, 25656B) │ │ │ ├── rules.ts (1b5776e, 288L, 6904ch, 6904B) -│ │ │ ├── schema.ts (3447bca, 340L, 9392ch, 11470B) +│ │ │ ├── schema.ts (9df771c, 335L, 9305ch, 11383B) │ │ │ └── types.ts (fda4f91, 310L, 7363ch, 7367B) │ │ ├── shared/ (5) │ │ │ ├── dialect-quoting.ts (8f028eb, 66L, 1860ch, 1866B) │ │ │ ├── errors.ts (9cb1445, 221L, 6038ch, 6528B) -│ │ │ ├── files.ts (d9f4a27, 98L, 3001ch, 3001B) -│ │ │ ├── index.ts (d39b0e1, 62L, 1331ch, 1331B) +│ │ │ ├── files.ts (af32191, 83L, 2567ch, 2567B) +│ │ │ ├── index.ts (b6f4b23, 62L, 1312ch, 1312B) │ │ │ └── tables.ts (06b23dd, 487L, 12754ch, 14716B) │ │ ├── sql-terminal/ (4) │ │ │ ├── executor.ts (9c75cbc, 164L, 4951ch, 4955B) @@ -600,15 +608,15 @@ │ │ │ └── types.ts (cb0a264, 123L, 2519ch, 2519B) │ │ ├── state/ (6) │ │ │ ├── encryption/ (2 files, 0 dirs) -│ │ │ ├── index.ts (ad7758c, 70L, 1373ch, 1373B) -│ │ │ ├── manager.ts (22356f6, 778L, 19894ch, 21362B) +│ │ │ ├── index.ts (5f90995, 71L, 1445ch, 1445B) +│ │ │ ├── manager.ts (459c2f5, 838L, 21789ch, 23257B) │ │ │ ├── migrations.ts (2d1f7fc, 88L, 2913ch, 2913B) │ │ │ ├── types.ts (638ec9f, 92L, 2562ch, 2564B) │ │ │ └── version.ts (11a6a0b, 26L, 649ch, 649B) │ │ ├── teardown/ (4) │ │ │ ├── dialects/ (5 files, 0 dirs) │ │ │ ├── index.ts (74cbb84, 27L, 549ch, 549B) -│ │ │ ├── operations.ts (378e92c, 560L, 15529ch, 15561B) +│ │ │ ├── operations.ts (514d1e6, 611L, 17692ch, 17730B) │ │ │ └── types.ts (df8debf, 282L, 7405ch, 7407B) │ │ ├── template/ (7) │ │ │ ├── loaders/ (7 files, 0 dirs) @@ -617,23 +625,24 @@ │ │ │ ├── helpers.ts (2c3adab, 182L, 4612ch, 4640B) │ │ │ ├── index.ts (a611347, 69L, 1570ch, 1570B) │ │ │ ├── types.ts (eeb0f2a, 214L, 4307ch, 4307B) -│ │ │ └── utils.ts (953ff32, 153L, 3408ch, 3438B) +│ │ │ └── utils.ts (b4c399e, 129L, 2822ch, 2852B) │ │ ├── transfer/ (7) │ │ │ ├── dialects/ (5 files, 0 dirs) -│ │ │ ├── events.ts (c8d31f3, 68L, 1652ch, 1652B) -│ │ │ ├── executor.ts (66a317b, 1099L, 26390ch, 26392B) -│ │ │ ├── index.ts (a144bca, 233L, 6236ch, 6238B) +│ │ │ ├── events.ts (a6a7783, 69L, 1687ch, 1687B) +│ │ │ ├── executor.ts (ab388d9, 1109L, 26787ch, 26791B) +│ │ │ ├── index.ts (58e8d72, 235L, 6324ch, 6326B) │ │ │ ├── planner.ts (916e6d7, 660L, 17836ch, 17838B) -│ │ │ ├── same-server.ts (365863e, 123L, 3024ch, 3024B) -│ │ │ └── types.ts (9fa32bd, 177L, 4138ch, 4138B) -│ │ ├── update/ (7) +│ │ │ ├── same-server.ts (1e8aaa7, 114L, 2906ch, 2906B) +│ │ │ └── types.ts (bdbbcbf, 188L, 4630ch, 4632B) +│ │ ├── update/ (8) │ │ │ ├── checker.ts (445cd79, 348L, 8642ch, 8642B) +│ │ │ ├── checksum.ts (4d40218, 159L, 4902ch, 4918B) │ │ │ ├── global-settings.ts (ced5668, 317L, 7229ch, 7229B) │ │ │ ├── index.ts (1985bc9, 69L, 1446ch, 1446B) -│ │ │ ├── install-mode.ts (3f9823e, 116L, 2653ch, 2661B) -│ │ │ ├── registry.ts (d57d1b8, 182L, 4728ch, 4728B) -│ │ │ ├── types.ts (c65c26b, 127L, 4012ch, 4012B) -│ │ │ └── updater.ts (6d359a6, 279L, 7509ch, 7513B) +│ │ │ ├── install-mode.ts (3331488, 167L, 4051ch, 4067B) +│ │ │ ├── registry.ts (b789b99, 177L, 4593ch, 4593B) +│ │ │ ├── types.ts (de1581f, 129L, 4183ch, 4183B) +│ │ │ └── updater.ts (0aac442, 523L, 15551ch, 15581B) │ │ ├── vault/ (8) │ │ │ ├── copy.ts (647dcc5, 226L, 6214ch, 6214B) │ │ │ ├── events.ts (a5a6714, 58L, 1255ch, 1255B) @@ -641,7 +650,7 @@ │ │ │ ├── key.ts (e9e19ea, 263L, 6776ch, 6776B) │ │ │ ├── propagate.ts (b458963, 251L, 6873ch, 6873B) │ │ │ ├── resolve.ts (27db674, 195L, 5094ch, 5094B) -│ │ │ ├── storage.ts (8d3fb1c, 585L, 15883ch, 15889B) +│ │ │ ├── storage.ts (f4fb9cf, 593L, 15952ch, 15958B) │ │ │ └── types.ts (dff608b, 109L, 2465ch, 2465B) │ │ ├── version/ (5) │ │ │ ├── schema/ (1 file, 1 dir) @@ -650,36 +659,34 @@ │ │ │ ├── index.ts (8abf6c2, 302L, 7550ch, 8282B) │ │ │ └── types.ts (5fcc885, 253L, 6917ch, 8381B) │ │ ├── worker-bridge/ (6) -│ │ │ ├── bridge.ts (e4d45b1, 126L, 3245ch, 3247B) -│ │ │ ├── index.ts (15b198a, 13L, 360ch, 360B) +│ │ │ ├── bridge.ts (28c4868, 99L, 2619ch, 2621B) +│ │ │ ├── index.ts (edc4028, 12L, 315ch, 315B) │ │ │ ├── order-buffer.ts (83665ff, 45L, 723ch, 723B) │ │ │ ├── paths.ts (ba286e4, 49L, 1673ch, 1677B) -│ │ │ ├── pool.ts (b8d2706, 108L, 2852ch, 2852B) -│ │ │ └── types.ts (e699925, 53L, 1983ch, 1989B) -│ │ ├── environment.ts (e138bc2, 131L, 2420ch, 2420B) -│ │ ├── index.ts (7418a76, 418L, 8895ch, 8895B) -│ │ ├── observer.ts (f715df0, 254L, 9767ch, 9767B) +│ │ │ ├── pool.ts (aea709d, 86L, 2314ch, 2314B) +│ │ │ └── types.ts (c142541, 46L, 1770ch, 1776B) +│ │ ├── environment.ts (08ebb56, 141L, 3015ch, 3017B) +│ │ ├── index.ts (f600b25, 418L, 8876ch, 8876B) +│ │ ├── observer.ts (e59a0e5, 255L, 9794ch, 9794B) │ │ ├── project-init.ts (40ae219, 166L, 4688ch, 4690B) │ │ ├── project.ts (18af87e, 255L, 6533ch, 6533B) │ │ └── theme.ts (f8e8f4a, 545L, 12937ch, 14702B) -│ ├── hooks/ (1) -│ │ └── observer.ts (cc4883f, 76L, 1791ch, 1791B) │ ├── mcp/ (3) │ │ ├── index.ts (d3d7f27, 26L, 852ch, 854B) │ │ ├── init.ts (84b9cbf, 85L, 1953ch, 1953B) -│ │ └── server.ts (621db73, 231L, 7413ch, 7417B) +│ │ └── server.ts (7152803, 234L, 7541ch, 7545B) │ ├── rpc/ (5) │ │ ├── commands/ (7) │ │ │ ├── changes.ts (49d45ad, 94L, 2627ch, 2627B) -│ │ │ ├── config.ts (3ced138, 43L, 1379ch, 1379B) +│ │ │ ├── config.ts (e7d7b46, 38L, 1403ch, 1403B) │ │ │ ├── explore.ts (f24be95, 91L, 3239ch, 3239B) │ │ │ ├── index.ts (1d30237, 30L, 734ch, 734B) │ │ │ ├── query.ts (d55e353, 40L, 1456ch, 1458B) │ │ │ ├── run.ts (2e5fb40, 57L, 1667ch, 1667B) -│ │ │ └── session.ts (b747844, 53L, 1619ch, 1619B) +│ │ │ └── session.ts (cc8dde9, 126L, 4052ch, 4056B) │ │ ├── index.ts (32e718c, 21L, 630ch, 630B) │ │ ├── registry.ts (02a1571, 109L, 2511ch, 2511B) -│ │ ├── session.ts (67b8b8c, 196L, 5142ch, 5156B) +│ │ ├── session.ts (9af1ad3, 196L, 5146ch, 5160B) │ │ └── types.ts (5ce4cf7, 78L, 2041ch, 2043B) │ ├── sdk/ (11) │ │ ├── impersonate/ (4) @@ -688,27 +695,27 @@ │ │ │ ├── scope.ts (b139628, 103L, 3170ch, 3414B) │ │ │ └── types.ts (ace8416, 67L, 2106ch, 2594B) │ │ ├── namespaces/ (11) -│ │ │ ├── changes.ts (be87721, 487L, 12794ch, 14100B) -│ │ │ ├── db.ts (726a447, 376L, 10050ch, 11358B) -│ │ │ ├── dt.ts (09c4dc4, 106L, 2723ch, 3181B) +│ │ │ ├── changes.ts (25533e3, 479L, 12747ch, 14053B) +│ │ │ ├── db.ts (ad6ce07, 361L, 9747ch, 10843B) +│ │ │ ├── dt.ts (fca374d, 117L, 3169ch, 3627B) │ │ │ ├── index.ts (9ce85c2, 10L, 454ch, 454B) -│ │ │ ├── lock.ts (dcd30d9, 154L, 3875ch, 4333B) -│ │ │ ├── run.ts (585765d, 218L, 5973ch, 7069B) +│ │ │ ├── lock.ts (d20eaae, 149L, 3811ch, 4269B) +│ │ │ ├── run.ts (9f3c8c6, 261L, 7963ch, 9063B) │ │ │ ├── secrets.ts (860ea8b, 42L, 967ch, 1213B) │ │ │ ├── templates.ts (da4c186, 53L, 1492ch, 1738B) -│ │ │ ├── transfer.ts (8dd6b7c, 72L, 2056ch, 2304B) -│ │ │ ├── utils.ts (4f1abc0, 60L, 1491ch, 1737B) -│ │ │ └── vault.ts (5e75287, 364L, 9201ch, 10299B) +│ │ │ ├── transfer.ts (182f27c, 88L, 2562ch, 2810B) +│ │ │ ├── utils.ts (a8b8dec, 65L, 1759ch, 2005B) +│ │ │ └── vault.ts (436f71f, 407L, 10433ch, 11779B) │ │ ├── stubs/ (1) │ │ │ └── ansis.ts (16243d6, 19L, 488ch, 490B) -│ │ ├── context.ts (3e9d43b, 564L, 16926ch, 19032B) -│ │ ├── guards.ts (cb3eb1c, 128L, 3480ch, 3974B) -│ │ ├── index.ts (f1819b9, 265L, 8033ch, 8769B) -│ │ ├── noorm-ops.ts (7b2144a, 186L, 4074ch, 4744B) +│ │ ├── context.ts (12fd24e, 535L, 16331ch, 18437B) +│ │ ├── guards.ts (e27300d, 156L, 4421ch, 4919B) +│ │ ├── index.ts (cb2bbba, 300L, 9189ch, 9927B) +│ │ ├── noorm-ops.ts (9b88a4d, 178L, 3939ch, 4609B) │ │ ├── sql.ts (397c9c9, 728L, 19985ch, 21507B) -│ │ ├── state.ts (4de0bfb, 29L, 1057ch, 1301B) +│ │ ├── state.ts (2e875de, 54L, 1779ch, 2267B) │ │ ├── tvp.ts (05b7f0a, 168L, 4695ch, 5433B) -│ │ └── types.ts (892f38a, 169L, 4431ch, 5407B) +│ │ └── types.ts (8d8963e, 178L, 4777ch, 5755B) │ ├── tui/ (14) │ │ ├── components/ (10) │ │ │ ├── dialogs/ (6 files, 0 dirs) @@ -720,15 +727,15 @@ │ │ │ ├── secrets/ (6 files, 0 dirs) │ │ │ ├── status/ (3 files, 0 dirs) │ │ │ ├── terminal/ (3 files, 0 dirs) -│ │ │ └── index.ts (5d55936, 96L, 2557ch, 2557B) +│ │ │ └── index.ts (c5ec099, 95L, 2533ch, 2533B) │ │ ├── hooks/ (14) -│ │ │ ├── index.ts (b106954, 71L, 1436ch, 1436B) +│ │ │ ├── index.ts (4220ca8, 69L, 1387ch, 1387B) │ │ │ ├── useAsyncEffect.ts (9e1d1d5, 43L, 903ch, 903B) │ │ │ ├── useChangeProgress.ts (12b8c8d, 111L, 3230ch, 3230B) │ │ │ ├── useConnection.ts (516f590, 229L, 6526ch, 6526B) │ │ │ ├── useLoadGuard.ts (e4d449c, 51L, 1054ch, 1054B) │ │ │ ├── useLockStatus.ts (632d89f, 145L, 3655ch, 3655B) -│ │ │ ├── useObserver.ts (59f91e2, 200L, 5388ch, 5390B) +│ │ │ ├── useObserver.ts (e5db37d, 158L, 4195ch, 4197B) │ │ │ ├── useRunProgress.ts (8174572, 279L, 6494ch, 6494B) │ │ │ ├── useSecretSource.ts (2004e16, 74L, 1995ch, 1995B) │ │ │ ├── useSettingsOperation.ts (cdd5226, 104L, 2795ch, 2811B) @@ -754,18 +761,19 @@ │ │ │ ├── UpdateScreen.tsx (d71a562, 191L, 4892ch, 4892B) │ │ │ ├── home.tsx (7f5fdf6, 635L, 21702ch, 21712B) │ │ │ └── not-found.tsx (135b44e, 72L, 1958ch, 1958B) -│ │ ├── utils/ (12) +│ │ ├── utils/ (13) │ │ │ ├── change-context.ts (88cb5c7, 70L, 2165ch, 2165B) │ │ │ ├── change-loader.ts (4ad58de, 252L, 6631ch, 6633B) │ │ │ ├── clipboard.ts (93a7f8c, 95L, 1991ch, 1991B) -│ │ │ ├── config-validation.ts (b1a9ceb, 221L, 5883ch, 5893B) -│ │ │ ├── connection.ts (e2bc3b6, 79L, 1950ch, 1950B) +│ │ │ ├── config-validation.ts (0d56c80, 217L, 5890ch, 5898B) +│ │ │ ├── connection.ts (e20a2e0, 82L, 2065ch, 2065B) │ │ │ ├── date.ts (ff07926, 20L, 391ch, 391B) │ │ │ ├── error.ts (97c198b, 33L, 736ch, 736B) │ │ │ ├── identity.ts (a44606a, 32L, 870ch, 870B) -│ │ │ ├── index.ts (77c3309, 29L, 976ch, 976B) +│ │ │ ├── index.ts (4a375b7, 30L, 1038ch, 1038B) │ │ │ ├── paths.ts (ee0cf63, 53L, 1329ch, 1329B) │ │ │ ├── run-context.ts (207450d, 66L, 1951ch, 1951B) +│ │ │ ├── settings-validation.ts (b155b66, 49L, 1148ch, 1152B) │ │ │ └── string.ts (8c22fe4, 39L, 1182ch, 1182B) │ │ ├── app-context.tsx (061c366, 1198L, 29675ch, 30909B) │ │ ├── app.tsx (e0768a4, 401L, 11536ch, 11592B) @@ -780,9 +788,11 @@ │ ├── compute.ts (c7b828a, 46L, 1233ch, 1233B) │ └── connection.ts (128339f, 241L, 5955ch, 5957B) ├── tests/ (11) -│ ├── cli/ (25) +│ ├── cli/ (30) +│ │ ├── change/ (1) +│ │ │ └── rm.test.ts (dce3756, 167L, 5461ch, 5467B) │ │ ├── ci/ (4) -│ │ │ ├── identity-enroll.test.ts (daea467, 75L, 2166ch, 2168B) +│ │ │ ├── identity-enroll.test.ts (99d0cfb, 106L, 3324ch, 3332B) │ │ │ ├── identity-new.test.ts (2462d0f, 88L, 2587ch, 2587B) │ │ │ ├── init.test.ts (4834a97, 192L, 5286ch, 5286B) │ │ │ └── secrets.test.ts (53fa24b, 216L, 6180ch, 6180B) @@ -794,85 +804,107 @@ │ │ │ ├── layout.test.tsx (811eaec, 110L, 2675ch, 2683B) │ │ │ ├── lists.test.tsx (3807cc8, 220L, 6656ch, 6670B) │ │ │ └── status.test.tsx (345fc65, 191L, 5245ch, 5245B) -│ │ ├── config/ (2) +│ │ ├── config/ (6) +│ │ │ ├── add.test.ts (74a4ef8, 43L, 1116ch, 1126B) +│ │ │ ├── edit.test.ts (6dbeeea, 62L, 1800ch, 1812B) +│ │ │ ├── export.test.ts (cddd4ad, 129L, 4198ch, 4206B) │ │ │ ├── import.test.ts (8d92923, 169L, 5938ch, 5948B) -│ │ │ └── list.test.ts (745c3f4, 136L, 4211ch, 4215B) -│ │ ├── db/ (1) -│ │ │ └── drop.test.ts (b86f15d, 178L, 5982ch, 5992B) +│ │ │ ├── list.test.ts (745c3f4, 136L, 4211ch, 4215B) +│ │ │ └── rm.test.ts (53d70db, 201L, 6850ch, 6850B) +│ │ ├── db/ (4) +│ │ │ ├── create.test.ts (e6a0e70, 201L, 7006ch, 7018B) +│ │ │ ├── drop.test.ts (b86f15d, 178L, 5982ch, 5992B) +│ │ │ ├── reset.test.ts (75cac14, 257L, 8678ch, 8690B) +│ │ │ └── transfer.test.ts (3ad7c26, 155L, 5752ch, 5770B) │ │ ├── hooks/ (3) -│ │ │ ├── useObserver.test.tsx (aa57ff1, 530L, 13868ch, 13872B) +│ │ │ ├── useObserver.test.tsx (e827049, 392L, 10127ch, 10129B) │ │ │ ├── useTransferProgress.test.tsx (5267f2e, 322L, 10736ch, 10744B) │ │ │ └── useUpdateChecker.test.tsx (3073b2c, 283L, 8301ch, 8301B) -│ │ ├── run/ (9) +│ │ ├── run/ (10) │ │ │ ├── _setup.ts (35ce510, 167L, 4749ch, 4753B) │ │ │ ├── build.test.ts (a658b29, 125L, 3897ch, 3909B) │ │ │ ├── change-ff-dryrun.test.ts (963e4be, 197L, 5697ch, 5701B) │ │ │ ├── change-ff.test.ts (58aa688, 126L, 3522ch, 3526B) +│ │ │ ├── change-rewind.test.ts (41e0605, 109L, 3291ch, 3297B) │ │ │ ├── change-run.test.ts (049eec9, 111L, 2909ch, 2913B) │ │ │ ├── dir.test.ts (b5724ae, 108L, 3146ch, 3152B) │ │ │ ├── file.test.ts (bb58559, 115L, 3539ch, 3543B) │ │ │ ├── files.test.ts (b4ef236, 182L, 5193ch, 5201B) │ │ │ └── sql.test.ts (29d578f, 123L, 3370ch, 3372B) -│ │ ├── screens/ (1) +│ │ ├── screens/ (2) +│ │ │ ├── config/ (2 files, 0 dirs) │ │ │ └── init/ (4 files, 0 dirs) │ │ ├── app-context.test.tsx (e15ad92, 628L, 16709ch, 16711B) │ │ ├── app.test.tsx (3a6f3d3, 241L, 6170ch, 6181B) │ │ ├── change-edit.test.ts (8125b19, 103L, 2985ch, 2985B) │ │ ├── change-prompts.test.ts (9be33db, 107L, 2822ch, 2822B) -│ │ ├── citty-help.test.ts (a81fe18, 61L, 1570ch, 1570B) -│ │ ├── config-validation.test.ts (7abf580, 33L, 1092ch, 1092B) +│ │ ├── citty-args.ts (f0b91d5, 27L, 816ch, 816B) +│ │ ├── citty-help.test.ts (6b20ddc, 102L, 2787ch, 2787B) +│ │ ├── config-validation.test.ts (54338bb, 101L, 2761ch, 2761B) │ │ ├── debug-pid.test.tsx (d07de2e, 11L, 188ch, 188B) │ │ ├── env-bootstrap.test.ts (048bf9c, 75L, 2314ch, 2314B) │ │ ├── focus.test.tsx (77608ce, 662L, 15713ch, 15713B) │ │ ├── init.test.ts (abbfba0, 124L, 3924ch, 3926B) +│ │ ├── insecure-flag.test.ts (bdb8f17, 104L, 2560ch, 2562B) │ │ ├── keyboard.test.tsx (83d53c1, 573L, 14942ch, 14942B) +│ │ ├── lazy-startup.test.ts (f8758c9, 143L, 4488ch, 4488B) │ │ ├── router.test.tsx (952530c, 489L, 13626ch, 13626B) │ │ ├── screens.test.tsx (6edec68, 277L, 7530ch, 7530B) │ │ ├── settings-edit.test.ts (a5ebc82, 56L, 1416ch, 1416B) │ │ ├── settings-secret.test.ts (8a6f206, 53L, 1365ch, 1365B) +│ │ ├── settings-validation.test.ts (d64e9b7, 34L, 903ch, 903B) │ │ ├── sql-repl.test.ts (37b9c5a, 32L, 864ch, 864B) │ │ ├── types.test.ts (c9a86fb, 136L, 4786ch, 4786B) │ │ └── yes-flag.test.ts (768cd0f, 476L, 12490ch, 12492B) -│ ├── core/ (26) -│ │ ├── change/ (5) +│ ├── core/ (28) +│ │ ├── change/ (9) │ │ │ ├── fixtures/ (0 files, 3 dirs) +│ │ │ ├── executor-retry.test.ts (4ebda49, 287L, 11169ch, 11181B) │ │ │ ├── executor.test.ts (0364934, 388L, 14082ch, 14086B) +│ │ │ ├── history.test.ts (de402f0, 176L, 5072ch, 5076B) +│ │ │ ├── manager.test.ts (44983e1, 388L, 14201ch, 14205B) │ │ │ ├── parser.test.ts (3ce9b9e, 394L, 12279ch, 12279B) │ │ │ ├── scaffold.test.ts (c8b3e10, 364L, 10559ch, 10559B) +│ │ │ ├── tracker.test.ts (ab9c75c, 181L, 6273ch, 6273B) │ │ │ └── types.test.ts (6dac816, 150L, 4669ch, 4669B) -│ │ ├── config/ (4) +│ │ ├── config/ (5) │ │ │ ├── debug-process.test.ts (48b3b6d, 12L, 300ch, 300B) -│ │ │ ├── env.test.ts (7c8ba98, 406L, 10934ch, 10934B) +│ │ │ ├── env.test.ts (8677581, 496L, 13317ch, 13317B) │ │ │ ├── resolver.test.ts (f440ae4, 830L, 24768ch, 24770B) -│ │ │ └── schema.test.ts (fa74209, 402L, 11508ch, 11508B) -│ │ ├── connection/ (2) +│ │ │ ├── schema.test.ts (6380dec, 505L, 14744ch, 14745B) +│ │ │ └── validate.test.ts (6e59573, 72L, 2253ch, 2253B) +│ │ ├── connection/ (3) +│ │ │ ├── defaults.test.ts (9203348, 50L, 1640ch, 1642B) │ │ │ ├── factory.test.ts (d4ef387, 140L, 3916ch, 3916B) │ │ │ └── manager.test.ts (7620812, 192L, 5447ch, 5447B) +│ │ ├── db/ (1) +│ │ │ └── dialects/ (3 files, 0 dirs) │ │ ├── dt/ (13) -│ │ │ ├── crypto.test.ts (396a0f4, 162L, 4548ch, 4548B) +│ │ │ ├── crypto.test.ts (172197f, 241L, 7186ch, 7186B) │ │ │ ├── deserialize.test.ts (f86c304, 309L, 10375ch, 10375B) -│ │ │ ├── integration.test.ts (c419b58, 800L, 26994ch, 27020B) +│ │ │ ├── integration.test.ts (c949c93, 800L, 27005ch, 27031B) │ │ │ ├── modify.test.ts (aaebadd, 930L, 32501ch, 32515B) │ │ │ ├── paths.test.ts (34268ed, 135L, 3543ch, 3543B) -│ │ │ ├── reader.test.ts (88875d1, 261L, 6808ch, 6808B) +│ │ │ ├── reader.test.ts (fabb685, 285L, 7821ch, 7821B) │ │ │ ├── roundtrip.test.ts (95212dc, 418L, 13038ch, 13050B) │ │ │ ├── schema.test.ts (1add531, 107L, 3266ch, 3270B) │ │ │ ├── serialize.test.ts (6536fd0, 348L, 10713ch, 10715B) │ │ │ ├── streamer.test.ts (ce5296c, 311L, 10016ch, 10028B) │ │ │ ├── type-map.test.ts (0d0c9d7, 508L, 15680ch, 15680B) │ │ │ ├── worker-pipeline.test.ts (39f13d6, 323L, 10611ch, 10619B) -│ │ │ └── writer.test.ts (882ee19, 221L, 6611ch, 6611B) +│ │ │ └── writer.test.ts (c97dc9e, 221L, 6635ch, 6635B) │ │ ├── explore/ (2) │ │ │ ├── dialects/ (4 files, 0 dirs) │ │ │ └── operations.test.ts (64e9a5f, 589L, 16355ch, 16371B) -│ │ ├── identity/ (7) +│ │ ├── identity/ (8) │ │ │ ├── crypto.test.ts (8801b11, 311L, 8701ch, 8701B) │ │ │ ├── env.test.ts (a4f0c52, 146L, 4559ch, 4561B) │ │ │ ├── factory.test.ts (61019f8, 348L, 10115ch, 10115B) │ │ │ ├── hash.test.ts (4ccae4e, 226L, 5558ch, 5584B) │ │ │ ├── overrides.test.ts (1a4a1c5, 100L, 2374ch, 2374B) │ │ │ ├── resolver.test.ts (f034831, 442L, 12034ch, 12074B) -│ │ │ └── storage.test.ts (dd036b0, 171L, 4452ch, 4452B) +│ │ │ ├── storage-key-permission-guard.test.ts (fa15ec7, 85L, 2584ch, 2588B) +│ │ │ └── storage.test.ts (3de274e, 224L, 6004ch, 6004B) │ │ ├── lifecycle/ (3) │ │ │ ├── handlers.test.ts (172ef91, 326L, 8624ch, 8624B) │ │ │ ├── manager.test.ts (2f7ce32, 705L, 17663ch, 17663B) @@ -881,7 +913,7 @@ │ │ │ ├── errors.test.ts (14e1d59, 215L, 5889ch, 5889B) │ │ │ ├── manager.test.ts (37f122b, 581L, 16221ch, 16221B) │ │ │ └── types.test.ts (2bdf84e, 48L, 1238ch, 1238B) -│ │ ├── logger/ (8) +│ │ ├── logger/ (9) │ │ │ ├── classifier.test.ts (3eb2232, 188L, 5356ch, 5356B) │ │ │ ├── formatter.test.ts (701d119, 316L, 8877ch, 8877B) │ │ │ ├── logger.test.ts (00e1420, 512L, 13305ch, 13305B) @@ -889,20 +921,23 @@ │ │ │ ├── queue.test.ts (2e16bbf, 295L, 6804ch, 6804B) │ │ │ ├── reader.test.ts (f361ff0, 439L, 14445ch, 14445B) │ │ │ ├── redact.test.ts (026b645, 554L, 15482ch, 15482B) -│ │ │ └── rotation.test.ts (2aad64b, 360L, 9943ch, 9943B) +│ │ │ ├── rotation.test.ts (2aad64b, 360L, 9943ch, 9943B) +│ │ │ └── timestamp.test.ts (4026342, 72L, 2286ch, 2286B) │ │ ├── mcp/ (2) │ │ │ ├── init.test.ts (9cb2913, 70L, 2197ch, 2197B) -│ │ │ └── server.test.ts (3f621da, 558L, 15851ch, 16255B) -│ │ ├── policy/ (2) -│ │ │ ├── check.test.ts (19551f1, 270L, 8111ch, 8111B) -│ │ │ └── classify.test.ts (4006bc7, 446L, 13890ch, 13902B) -│ │ ├── rpc/ (7) +│ │ │ └── server.test.ts (800a66e, 571L, 16481ch, 16887B) +│ │ ├── policy/ (3) +│ │ │ ├── check.test.ts (c8ef6c0, 323L, 9827ch, 9827B) +│ │ │ ├── classify.test.ts (4006bc7, 446L, 13890ch, 13902B) +│ │ │ └── visibility.test.ts (2d5f6c6, 38L, 1052ch, 1052B) +│ │ ├── rpc/ (8) │ │ │ ├── commands.test.ts (a10d74f, 480L, 16121ch, 16125B) │ │ │ ├── list-configs.test.ts (5ba9331, 115L, 3592ch, 3598B) -│ │ │ ├── permissions.test.ts (e69d7c7, 57L, 1719ch, 1721B) -│ │ │ ├── registry-integration.test.ts (ce3964c, 230L, 6153ch, 6159B) +│ │ │ ├── permissions.test.ts (82cb5d4, 58L, 1739ch, 1741B) +│ │ │ ├── registry-integration.test.ts (a704a85, 231L, 6183ch, 6189B) │ │ │ ├── registry.test.ts (f674271, 109L, 3041ch, 3041B) │ │ │ ├── session-not-found.test.ts (f1df070, 76L, 2649ch, 2651B) +│ │ │ ├── session-status.test.ts (1e74c6a, 230L, 7438ch, 7440B) │ │ │ └── session.test.ts (2fe1518, 194L, 5712ch, 5712B) │ │ ├── runner/ (4) │ │ │ ├── fixtures/ (6 files, 0 dirs) @@ -923,10 +958,10 @@ │ │ │ └── history.test.ts (0db8ae6, 1013L, 34309ch, 34309B) │ │ ├── state/ (2) │ │ │ ├── encryption/ (1 file, 0 dirs) -│ │ │ └── manager.test.ts (6b17f57, 894L, 29892ch, 31846B) +│ │ │ └── manager.test.ts (93ec167, 1015L, 33746ch, 35700B) │ │ ├── teardown/ (2) │ │ │ ├── dialects/ (4 files, 0 dirs) -│ │ │ └── operations.test.ts (dfc1c9e, 699L, 23788ch, 25528B) +│ │ │ └── operations.test.ts (89b712f, 904L, 31368ch, 33364B) │ │ ├── template/ (6) │ │ │ ├── fixtures/ (0 files, 4 dirs) │ │ │ ├── engine.test.ts (f3f1e18, 388L, 11697ch, 11697B) @@ -934,19 +969,24 @@ │ │ │ ├── loaders.test.ts (115cc74, 257L, 6369ch, 6369B) │ │ │ ├── security.test.ts (82855d4, 237L, 7609ch, 7609B) │ │ │ └── utils.test.ts (d54a7a5, 165L, 3442ch, 3442B) -│ │ ├── transfer/ (6) +│ │ ├── transfer/ (7) │ │ │ ├── dialects/ (4 files, 0 dirs) │ │ │ ├── events.test.ts (ab6d02e, 364L, 10614ch, 10614B) │ │ │ ├── executor.test.ts (602b084, 420L, 13400ch, 13400B) +│ │ │ ├── fk-restore.test.ts (abd4f41, 162L, 4912ch, 5164B) │ │ │ ├── planner.test.ts (a81c2b6, 341L, 10240ch, 10240B) │ │ │ ├── policy-gate.test.ts (d7abf8f, 60L, 2425ch, 2429B) │ │ │ └── same-server.test.ts (cf2bf8d, 369L, 10550ch, 10550B) -│ │ ├── update/ (3) +│ │ ├── update/ (5) │ │ │ ├── checker.test.ts (4db209f, 191L, 5313ch, 5313B) +│ │ │ ├── checksum.test.ts (ef4d8bd, 264L, 7720ch, 7732B) │ │ │ ├── global-settings.test.ts (1190fee, 231L, 6358ch, 6358B) -│ │ │ └── registry.test.ts (6f2fad2, 214L, 5987ch, 5987B) -│ │ ├── vault/ (1) -│ │ │ └── idempotent-init.test.ts (a903fc3, 236L, 6218ch, 6218B) +│ │ │ ├── registry.test.ts (6f2fad2, 214L, 5987ch, 5987B) +│ │ │ └── updater.test.ts (a8edc3f, 324L, 10516ch, 10536B) +│ │ ├── vault/ (3) +│ │ │ ├── idempotent-init.test.ts (a903fc3, 236L, 6218ch, 6218B) +│ │ │ ├── key.test.ts (1b2034e, 188L, 5742ch, 5746B) +│ │ │ └── storage.test.ts (6ce7799, 317L, 10648ch, 10650B) │ │ ├── version/ (5) │ │ │ ├── manager.test.ts (8ff56f8, 319L, 11020ch, 11020B) │ │ │ ├── schema.test.ts (9fc95bd, 658L, 20341ch, 20341B) @@ -954,9 +994,10 @@ │ │ │ ├── state.test.ts (28be6b2, 474L, 13484ch, 13484B) │ │ │ └── types.test.ts (5f0f71f, 125L, 3021ch, 3021B) │ │ ├── worker-bridge/ (3) -│ │ │ ├── bridge.test.ts (a6225e4, 63L, 1725ch, 1725B) +│ │ │ ├── bridge.test.ts (4ee1750, 54L, 1440ch, 1440B) │ │ │ ├── order-buffer.test.ts (182cbed, 85L, 1988ch, 1992B) │ │ │ └── pool.test.ts (f2754a9, 64L, 1798ch, 1798B) +│ │ ├── environment.test.ts (958bc19, 142L, 3767ch, 3769B) │ │ ├── project-init.test.ts (48fa3fe, 75L, 2387ch, 2387B) │ │ └── project.test.ts (cd6cc41, 243L, 6575ch, 6575B) │ ├── fixtures/ (3) @@ -971,8 +1012,10 @@ │ │ │ └── sqlite/ (3 files, 0 dirs) │ │ └── workers/ (2) │ │ ├── adder.ts (3243e61, 15L, 443ch, 443B) -│ │ └── echo.ts (90dfb9f, 19L, 574ch, 574B) -│ ├── integration/ (10) +│ │ └── echo.ts (e2e3fe3, 12L, 413ch, 413B) +│ ├── integration/ (11) +│ │ ├── change/ (1) +│ │ │ └── postgres-transaction.test.ts (92fde1d, 285L, 9555ch, 9565B) │ │ ├── cli/ (3) │ │ │ ├── db.test.ts (3f4283c, 435L, 11442ch, 12906B) │ │ │ ├── lock.test.ts (52b726a, 302L, 8178ch, 9398B) @@ -991,17 +1034,20 @@ │ │ │ └── postgres.test.ts (1638202, 113L, 3593ch, 3595B) │ │ ├── runner/ (1) │ │ │ └── mssql-batches.test.ts (395ef9c, 268L, 8059ch, 8061B) -│ │ ├── sdk/ (3) -│ │ │ ├── db-reset.test.ts (a77c170, 117L, 3630ch, 3634B) +│ │ ├── sdk/ (6) +│ │ │ ├── db-reset.test.ts (99bb07e, 115L, 3607ch, 3611B) +│ │ │ ├── dt-namespace.test.ts (3dee923, 146L, 6011ch, 6513B) +│ │ │ ├── transfer-namespace.test.ts (cfe27cd, 180L, 6121ch, 6867B) │ │ │ ├── tvf.test.ts (730359b, 279L, 7502ch, 8234B) -│ │ │ └── tvp.test.ts (61c50ec, 599L, 17433ch, 19149B) +│ │ │ ├── tvp.test.ts (61c50ec, 599L, 17433ch, 19149B) +│ │ │ └── vault-namespace.test.ts (e7dffc5, 291L, 9810ch, 10312B) │ │ ├── sql-terminal/ (4) │ │ │ ├── mssql.test.ts (1c242a3, 776L, 23168ch, 23168B) │ │ │ ├── mysql.test.ts (19a7a01, 658L, 18773ch, 18773B) │ │ │ ├── postgres.test.ts (a32d0a8, 942L, 29279ch, 29279B) │ │ │ └── sqlite.test.ts (4ea272e, 768L, 22486ch, 22486B) │ │ ├── teardown/ (5) -│ │ │ ├── mssql.test.ts (baa46aa, 641L, 22999ch, 23253B) +│ │ │ ├── mssql.test.ts (c5dae52, 693L, 24835ch, 25335B) │ │ │ ├── mysql.test.ts (730f424, 400L, 12896ch, 12896B) │ │ │ ├── postgres.test.ts (399902f, 453L, 16737ch, 16981B) │ │ │ ├── sdk-preserve.test.ts (8d2711e, 177L, 5673ch, 6601B) @@ -1012,22 +1058,27 @@ │ │ │ └── postgres.test.ts (449c11f, 371L, 12407ch, 12409B) │ │ └── version/ (1) │ │ └── schema.test.ts (e1c16d7, 727L, 22493ch, 23469B) -│ ├── sdk/ (9) +│ ├── sdk/ (13) │ │ ├── impersonate/ (3) │ │ │ ├── dialect-strategy.test.ts (d630381, 146L, 3515ch, 4491B) │ │ │ ├── impersonate.test.ts (339dd61, 297L, 7627ch, 8603B) │ │ │ └── scope.test.ts (b9d359a, 122L, 3310ch, 3798B) -│ │ ├── bundle-smoke.test.ts (66dd1dd, 319L, 8912ch, 10866B) +│ │ ├── bundle-smoke.test.ts (e141dfa, 343L, 9619ch, 11817B) │ │ ├── context.test.ts (341f429, 597L, 18688ch, 19432B) -│ │ ├── db-namespace.test.ts (e7fb04c, 259L, 7807ch, 8727B) -│ │ ├── destructive-ops.test.ts (4f030f6, 471L, 15487ch, 17903B) -│ │ ├── guards.test.ts (01e310e, 318L, 9872ch, 9874B) +│ │ ├── db-namespace.test.ts (fe640c4, 290L, 8715ch, 9849B) +│ │ ├── destructive-ops.test.ts (9c2dc97, 642L, 21461ch, 24521B) +│ │ ├── dts-surface.test.ts (7e8403b, 74L, 2344ch, 3080B) +│ │ ├── guards.test.ts (d609680, 377L, 12339ch, 12341B) │ │ ├── lifecycle.test.ts (371af51, 126L, 3617ch, 4353B) -│ │ ├── noorm-ops.test.ts (bea2339, 321L, 8652ch, 9824B) -│ │ └── sql.test.ts (959c16c, 1035L, 32675ch, 34387B) -│ ├── utils/ (3) +│ │ ├── noorm-ops.test.ts (4cbb4ce, 338L, 9250ch, 10666B) +│ │ ├── run-build-filtering.test.ts (8444e13, 374L, 13147ch, 13663B) +│ │ ├── sql.test.ts (959c16c, 1035L, 32675ch, 34387B) +│ │ ├── transfer-dt-namespace.test.ts (8962723, 120L, 3859ch, 4349B) +│ │ └── vault-namespace.test.ts (4505539, 368L, 11446ch, 11452B) +│ ├── utils/ (4) +│ │ ├── db-guard.test.ts (677fa3e, 143L, 3999ch, 4001B) │ │ ├── db-splitter.test.ts (4db513c, 280L, 8506ch, 8506B) -│ │ ├── db.ts (a3d4ce9, 851L, 23982ch, 23992B) +│ │ ├── db.ts (757d071, 926L, 26530ch, 26546B) │ │ └── mssql-batches.test.ts (551221c, 270L, 7309ch, 7313B) │ ├── workers/ (2) │ │ ├── compute.test.ts (66855f3, 72L, 2003ch, 2003B) @@ -1040,17 +1091,17 @@ ├── .npmrc (60376c8, 1L, 36ch, 36B) ├── .prettierignore (e3b0c44, 0L, 0ch, 0B) ├── .signalsignore (b0287a5, 17L, 662ch, 674B) -├── CLAUDE.md (c380a2c, 201L, 8292ch, 8498B) +├── CLAUDE.md (2cf97c7, 203L, 8579ch, 8785B) ├── CNAME (f3bed50, 1L, 9ch, 9B) +├── LICENSE (42eaf96, 21L, 1070ch, 1070B) ├── README.md (6bbc797, 84L, 2482ch, 2502B) -├── TODO.md (6a63836, 375L, 21659ch, 21929B) -├── bun.lockb (feb08f9, 451L, 266552ch, 268632B) +├── TODO.md (afd3ad2, 284L, 17713ch, 17967B) +├── bun.lockb (ca7f592, 449L, 263701ch, 265728B) ├── bunfig.toml (dab752d, 5L, 89ch, 89B) -├── docker-compose.yml (d2d79cb, 59L, 1534ch, 1534B) +├── docker-compose.test.yml (d2d79cb, 59L, 1534ch, 1534B) ├── eslint.config.js (cd11fe6, 56L, 2111ch, 2111B) -├── install.sh (0cc90a2, 116L, 2925ch, 2925B) -├── package.json (2904b1a, 102L, 3036ch, 3036B) -├── postgres-problems.md (1cbb5b5, 200L, 13674ch, 13799B) +├── install.sh (5cb346d, 200L, 5892ch, 5892B) +├── package.json (7036c9a, 97L, 2870ch, 2870B) ├── tsconfig.json (640d95f, 21L, 616ch, 616B) ├── tsconfig.sdk-types.json (47a2c39, 13L, 303ch, 303B) ├── tsconfig.test.json (a5e75f7, 10L, 221ch, 221B) @@ -1060,22 +1111,22 @@ ## Manifests - docs/package.json: name=@noormdev/docs, scripts=[build, dev, preview] -- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck] -- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck] -- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck] -- package.json: name=@noormdev/main, version=0.0.1, scripts=[build, build:binary, build:packages, changeset, clean, dev, lint, lint:fix, prepublishOnly, release, start, start:init, test, test:coverage, test:watch, typecheck, typecheck:tests, version] -- packages/cli/package.json: name=@noormdev/cli, version=1.0.0-alpha.36, scripts=[postinstall] -- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.0-alpha.36 +- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.1-alpha.5, scripts=[test, test:watch, typecheck] +- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.1-alpha.5, scripts=[test, test:watch, typecheck] +- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.1-alpha.5, scripts=[test, test:watch, typecheck] +- package.json: name=@noormdev/main, version=0.0.1, scripts=[build, build:binary, build:packages, changeset, clean, dev, lint, lint:docs, lint:fix, prepublishOnly, release, start, test, test:coverage, test:watch, typecheck, typecheck:tests, version] +- packages/cli/package.json: name=@noormdev/cli, version=1.0.0-alpha.39, scripts=[postinstall] +- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.0-alpha.39 ## Languages -- TypeScript: 204246 LOC (80%), 886 files (75%) -- Markdown: 43000 LOC (17%), 195 files (16%) -- YAML: 1114 LOC (0%), 16 files (1%) -- JavaScript: 1005 LOC (0%), 22 files (1%) +- TypeScript: 213975 LOC (81%), 933 files (75%) +- Markdown: 42955 LOC (16%), 197 files (16%) +- JavaScript: 1198 LOC (0%), 22 files (1%) +- YAML: 1134 LOC (0%), 16 files (1%) - HTML: 955 LOC (0%), 26 files (2%) - CSS: 913 LOC (0%), 3 files (0%) -- Shell: 726 LOC (0%), 4 files (0%) -- JSON: 555 LOC (0%), 23 files (1%) +- Shell: 669 LOC (0%), 4 files (0%) +- JSON: 554 LOC (0%), 23 files (1%) - Vue: 181 LOC (0%), 3 files (0%) - TOML: 10 LOC (0%), 2 files (0%) diff --git a/docs/wiki/tui.md b/docs/wiki/tui.md index 744ea6ee..54c6a859 100644 --- a/docs/wiki/tui.md +++ b/docs/wiki/tui.md @@ -35,7 +35,6 @@ Ink/React-based terminal UI launched by `noorm ui`. Full-screen interactive inte - [`src/tui/screens/secret/`](../../src/tui/screens/secret) — 4 secret screens - [`src/tui/screens/settings/`](../../src/tui/screens/settings) — 17 settings screens - [`src/tui/screens/vault/`](../../src/tui/screens/vault) — 5 vault screens -- [`src/hooks/observer.ts`](../../src/hooks/observer.ts) — `useObserver` hook (non-tui hooks barrel) - [`src/tui/screens/UpdateScreen.tsx`](../../src/tui/screens/UpdateScreen.tsx) — update available prompt - [`src/tui/screens/MoreScreen.tsx`](../../src/tui/screens/MoreScreen.tsx) — extended help screen diff --git a/examples/llm-memory-db-mssql/.noorm/settings.yml b/examples/llm-memory-db-mssql/.noorm/settings.yml index 1005f706..f4ae761d 100644 --- a/examples/llm-memory-db-mssql/.noorm/settings.yml +++ b/examples/llm-memory-db-mssql/.noorm/settings.yml @@ -4,16 +4,16 @@ paths: build: include: - - sql/00_types - - sql/01_reference - - sql/02_tables - - sql/03_validators - - sql/04_subtypes - - sql/05_binary - - sql/06_seeds - - sql/07_functions - - sql/08_views - - sql/09_procedures + - 00_types + - 01_reference + - 02_tables + - 03_validators + - 04_subtypes + - 05_binary + - 06_seeds + - 07_functions + - 08_views + - 09_procedures exclude: [] stages: @@ -46,7 +46,7 @@ rules: match: isTest: true include: - - sql/06_seeds + - 06_seeds strict: enabled: false diff --git a/examples/llm-memory-db-mssql/README.md b/examples/llm-memory-db-mssql/README.md index a6bcc56f..64bdab9f 100644 --- a/examples/llm-memory-db-mssql/README.md +++ b/examples/llm-memory-db-mssql/README.md @@ -20,9 +20,9 @@ An end-to-end noorm example: an LLM-memory schema running on SQL Server 2022, ex - Docker (for the MSSQL 2022 container). - An existing noorm identity at `~/.noorm/identity.{key,pub,json}`. If you don't have one yet, run `noorm init` from an interactive terminal once (see `mssql-problems.md` gap #1 — `init` requires a TTY). -The MSSQL container is declared in `docker-compose.yml` at the monorepo root and listens on port `11433`. From the monorepo root: +The MSSQL container is declared in `docker-compose.test.yml` at the monorepo root and listens on port `11433`. From the monorepo root: - docker compose up -d + docker compose -f docker-compose.test.yml up -d ## Setup diff --git a/examples/llm-memory-db-mssql/tests/integration/observer.test.ts b/examples/llm-memory-db-mssql/tests/integration/observer.test.ts index 8ad9140f..819ba0ed 100644 --- a/examples/llm-memory-db-mssql/tests/integration/observer.test.ts +++ b/examples/llm-memory-db-mssql/tests/integration/observer.test.ts @@ -1,14 +1,15 @@ /** * Layer 3 — observer integration tests. * - * Asserts that ctx.noorm.observer surfaces the cross-cutting events - * emitted by the runner and template engine while the SDK executes - * real SQL against the test MSSQL database. + * Asserts that noormObserver (the process-global event bus) surfaces the + * cross-cutting events emitted by the runner and template engine while the + * SDK executes real SQL against the test MSSQL database. */ import { join } from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; import { attempt } from '@logosdx/utils'; +import { noormObserver } from '@noormdev/sdk'; import { bootstrap, resetApplicationData } from '../helpers/test-context'; @@ -32,7 +33,7 @@ describe('observer: file:* events during run.file', () => { const events: { event: string; filepath: string }[] = []; - const cleanup = ctx.noorm.observer.on(/^file:/, (payload) => { + const cleanup = noormObserver.on(/^file:/, (payload) => { const evt = payload.event; const data: unknown = payload.data; @@ -66,7 +67,7 @@ describe('observer: file:* events during run.file', () => { const captured: { status: string; durationMs: number; filepath: string }[] = []; - const cleanup = ctx.noorm.observer.on('file:after', (data) => { + const cleanup = noormObserver.on('file:after', (data) => { captured.push({ status: data.status, @@ -95,7 +96,7 @@ describe('observer: template:* events during .sql.tmpl render', () => { const renders: { filepath: string; durationMs: number }[] = []; - const cleanup = ctx.noorm.observer.on('template:render', (data) => { + const cleanup = noormObserver.on('template:render', (data) => { renders.push({ filepath: data.filepath, durationMs: data.durationMs }); @@ -119,7 +120,7 @@ describe('observer: regex pattern matching across run.file', () => { const events: string[] = []; - const cleanup = ctx.noorm.observer.on(/^file:/, (payload) => { + const cleanup = noormObserver.on(/^file:/, (payload) => { const evt = payload.event; if (typeof evt === 'string') events.push(evt); @@ -178,7 +179,7 @@ describe('observer: error path', () => { const captured: { filepath: string; status: string; error: string | undefined }[] = []; - const cleanup = ctx.noorm.observer.on('file:after', (data) => { + const cleanup = noormObserver.on('file:after', (data) => { captured.push({ filepath: data.filepath, diff --git a/examples/llm-memory-db-mssql/tests/integration/vault.test.ts b/examples/llm-memory-db-mssql/tests/integration/vault.test.ts index f9d2ddd7..1b079511 100644 --- a/examples/llm-memory-db-mssql/tests/integration/vault.test.ts +++ b/examples/llm-memory-db-mssql/tests/integration/vault.test.ts @@ -49,29 +49,17 @@ beforeAll(async () => { privateKey = (await Bun.file(KEY_PATH).text()).trim(); - // Initialize the vault for this database. init() is documented as - // returning [null, Error('Vault already initialized')] on a second - // call (see mssql-problems.md gap #11) — that's fine, treat it as - // idempotent. We wrap in attempt() because some failure paths - // (sync DER parse errors on exotic identity setups) throw rather - // than returning a tuple. - const [initResult, initThrew] = await attempt(() => ctx.noorm.vault.init()); - if (initThrew) throw initThrew; - - const [, initErr] = initResult; - if (initErr && !/already initialized/i.test(initErr.message)) { - - throw initErr; - - } + // Initialize the vault for this database. init() is idempotent — a + // second call returns null (not an error) because the vault already + // exists; real failures throw. If init() genuinely fails here (DB + // error, encryption error, exotic identity setup), letting it throw + // is correct — beforeAll failing surfaces a real regression instead + // of silently skipping. + await ctx.noorm.vault.init(); // Probe: round-trip set/delete to surface state.enc-vs-key mismatch // immediately rather than during the first assertion. - const [, probeErr] = await attempt( - () => ctx.noorm.vault.set('__vault_probe__', 'ok', privateKey), - ); - if (probeErr) throw probeErr; - + await ctx.noorm.vault.set('__vault_probe__', 'ok', privateKey); await ctx.noorm.vault.delete('__vault_probe__'); }, 15_000); @@ -101,12 +89,10 @@ beforeEach(async () => { describe('vault: init is idempotent', () => { - it.skipIf(!HAS_IDENTITY_KEY)('a second init() on an already-initialized vault returns the already-initialized error and leaves status intact', async () => { + it.skipIf(!HAS_IDENTITY_KEY)('a second init() on an already-initialized vault returns null and leaves status intact', async () => { - const [result, err] = await ctx.noorm.vault.init(); + const result = await ctx.noorm.vault.init(); expect(result).toBeNull(); - expect(err).toBeInstanceOf(Error); - expect(err?.message).toMatch(/already initialized/i); const status = await ctx.noorm.vault.status(); expect(status.isInitialized).toBe(true); @@ -120,8 +106,7 @@ describe('vault: set / get round-trip', () => { it.skipIf(!HAS_IDENTITY_KEY)('stores an encrypted value and decrypts it back via get()', async () => { - const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'mssql-secret-value', privateKey); - expect(setErr).toBeNull(); + await ctx.noorm.vault.set(TEST_KEY, 'mssql-secret-value', privateKey); const value = await ctx.noorm.vault.get(TEST_KEY, privateKey); expect(value).toBe('mssql-secret-value'); @@ -130,8 +115,7 @@ describe('vault: set / get round-trip', () => { it.skipIf(!HAS_IDENTITY_KEY)('list() reflects a key after set() and exists() agrees', async () => { - const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey); - expect(setErr).toBeNull(); + await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey); const keys = await ctx.noorm.vault.list(); expect(Array.isArray(keys)).toBe(true); @@ -146,13 +130,11 @@ describe('vault: set / get round-trip', () => { describe('vault: delete removes the secret', () => { - it.skipIf(!HAS_IDENTITY_KEY)('returns [true, null] and exists() reports false after delete', async () => { + it.skipIf(!HAS_IDENTITY_KEY)('returns true and exists() reports false after delete', async () => { - const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey); - expect(setErr).toBeNull(); + await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey); - const [deleted, delErr] = await ctx.noorm.vault.delete(TEST_KEY); - expect(delErr).toBeNull(); + const deleted = await ctx.noorm.vault.delete(TEST_KEY); expect(deleted).toBe(true); const stillThere = await ctx.noorm.vault.exists(TEST_KEY); diff --git a/examples/llm-memory-db-pg/.noorm/settings.yml b/examples/llm-memory-db-pg/.noorm/settings.yml index cd2e0410..23286708 100644 --- a/examples/llm-memory-db-pg/.noorm/settings.yml +++ b/examples/llm-memory-db-pg/.noorm/settings.yml @@ -1,23 +1,24 @@ build: include: - - sql/00_types - - sql/01_reference - - sql/02_tables - - sql/03_subtypes - - sql/04_binary - - sql/05_seeds - - sql/06_functions - - sql/07_views - - sql/08_procedures - - sql/09_triggers + - 00_types + - 01_reference + - 02_tables + - 03_subtypes + - 04_binary + - 05_seeds + - 06_functions + - 07_views + - 08_procedures + - 09_triggers paths: sql: ./sql changes: ./changes rules: - - match: + - description: Include test seeds + match: isTest: true include: - - sql/05_seeds + - 05_seeds stages: dev: description: Local development database diff --git a/examples/llm-memory-db-pg/README.md b/examples/llm-memory-db-pg/README.md index 8618fb13..9207cabf 100644 --- a/examples/llm-memory-db-pg/README.md +++ b/examples/llm-memory-db-pg/README.md @@ -48,12 +48,48 @@ The schema artifact this project implements lives at `tmp/llm-memory-db.pseudo` ## Setup -From inside `examples/llm-memory-db-pg/`: +The actual order matters — `noorm db create` requires an active named config, so configs must be imported before the database can be created. From inside `examples/llm-memory-db-pg/`, create `dev.json`: + +```json +{ + "name": "dev", + "connection": { + "dialect": "postgres", + "host": "localhost", + "port": 15432, + "database": "noorm_llm_dev", + "user": "noorm_test", + "password": "noorm_test" + }, + "isTest": false +} +``` + +Then create `test.json`: + +```json +{ + "name": "test", + "connection": { + "dialect": "postgres", + "host": "localhost", + "port": 15432, + "database": "noorm_llm_test", + "user": "noorm_test", + "password": "noorm_test" + }, + "isTest": true +} +``` + +These values match `.noorm/settings.yml`'s `stages.dev` and `stages.test` defaults. Then: ```bash bun install -noorm db create --name noorm_llm_dev -noorm db create --name noorm_llm_test +noorm config import dev.json +noorm config import test.json +noorm db create -c dev +noorm db create -c test noorm config use dev noorm run build noorm change ff @@ -158,7 +194,7 @@ examples/llm-memory-db-pg/ Phase 1 used the noorm CLI to bootstrap `.noorm/`, declare two stages (`dev` + `test`), build the schema from SQL files, apply an evolutionary change, generate the SDK shape, and write the test suite. Phase 2 used the noorm MCP server to verify schema integrity, grade test quality, and add a meta-test that exercises the MCP itself. -The full play-by-play is in `REPORT.md` (final deliverable) and `REPORT-PHASE-1.md` (intermediate report). Three real noorm SDK bugs surfaced during Phase 1 and were fixed at the source — see `../../postgres-problems.md` (monorepo root) for the full list of CLI/SDK/MCP issues encountered. +The full play-by-play is in `REPORT.md` (final deliverable) and `REPORT-PHASE-1.md` (intermediate report). Three real noorm SDK bugs surfaced during Phase 1 and were fixed at the source — see `postgres-problems.md` (in this directory) for the full list of CLI/SDK/MCP issues encountered. ## What this is NOT diff --git a/examples/llm-memory-db-pg/REPORT.md b/examples/llm-memory-db-pg/REPORT.md index 26804770..dd8d2117 100644 --- a/examples/llm-memory-db-pg/REPORT.md +++ b/examples/llm-memory-db-pg/REPORT.md @@ -3,7 +3,7 @@ This is the Phase 2 deliverable for the LLM memory + task-tracker example built end-to-end against PostgreSQL using noorm. It captures: what was built, the coverage stats, every noorm CLI / SDK / MCP issue surfaced along the way, and the judgment calls that shaped the final shape. -For Phase 1 details (schema authoring play-by-play, the three SDK bugs that were fixed mid-build, and the convention quirks that bit the early subagents) see `REPORT-PHASE-1.md` in this directory. The full external problem log lives at `../../postgres-problems.md` in the monorepo root. +For Phase 1 details (schema authoring play-by-play, the three SDK bugs that were fixed mid-build, and the convention quirks that bit the early subagents) see `REPORT-PHASE-1.md` in this directory. The full external problem log lives at `postgres-problems.md` in this directory. ## Final state @@ -55,7 +55,7 @@ All 38 tables, 18 views, 10 helper functions, and ALL 69 procs (`PROCEDURE` + sc - **Domain coverage**: every entity domain (Memory, Note, Tag, Artifact, Milestone, Task, Agent, Project, Audit) has both an SQL-layer test (asserting on the proc/view contract) AND a domain-layer test (asserting on the facade plumbing through Zod + camelCase mapping). - **Failure-mode coverage**: every state-machine rejection, every sentinel guard (Agent(0) / Project(0)), every exclusivity trigger pair, every cycle-detection guard, every FK guard, and every UNIQUE constraint has a paired failure test that asserts on a tight error pattern (regex match against the literal RAISE EXCEPTION message — tighter than `.rejects.toThrow()` alone, less brittle than coupling to driver-specific SQLSTATE codes). -- **Integration coverage**: `ctx.noorm.observer` (4 tests), `ctx.noorm.lock` (5 tests including real cross-identity contention), `ctx.noorm.vault` (4 tests including double-init failure), `ctx.impersonate` (5 tests including SQLSTATE-pinned rejection + post-revert control). +- **Integration coverage**: `noormObserver` (4 tests), `ctx.noorm.lock` (5 tests including real cross-identity contention), `ctx.noorm.vault` (4 tests including double-init failure), `ctx.impersonate` (5 tests including SQLSTATE-pinned rejection + post-revert control). - **MCP coverage**: 8 tests in `tests/mcp-discovery.test.ts` exercise the full JSON-RPC surface (initialize handshake, tools/list, tools/call for both `noorm_help` and `run_noorm_cmd`). diff --git a/postgres-problems.md b/examples/llm-memory-db-pg/postgres-problems.md similarity index 100% rename from postgres-problems.md rename to examples/llm-memory-db-pg/postgres-problems.md diff --git a/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts b/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts index a19c9dc9..ad2d1bf0 100644 --- a/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts +++ b/examples/llm-memory-db-pg/tests/integration/01_observer.test.ts @@ -2,6 +2,7 @@ import { mkdir, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { beforeAll, beforeEach, describe, it, expect } from 'bun:test'; +import { noormObserver } from '@noormdev/sdk'; import { bootstrap, truncateAll } from '../helpers/test-context'; @@ -19,13 +20,13 @@ beforeEach(async () => { }); -describe('ctx.noorm.observer file:after', () => { +describe('noormObserver file:after', () => { it('fires with filepath, status, and durationMs when a file is run', async () => { const events: Array<{ filepath: string; status: string; durationMs: number }> = []; - const cleanup = ctx.noorm.observer.on('file:after', (data) => { + const cleanup = noormObserver.on('file:after', (data) => { events.push({ filepath: data.filepath, @@ -48,13 +49,13 @@ describe('ctx.noorm.observer file:after', () => { }); -describe('ctx.noorm.observer change:complete', () => { +describe('noormObserver change:complete', () => { it('fires when a freshly created change is applied via the SDK', async () => { const events: Array<{ name: string; direction: string; status: string; durationMs: number }> = []; - const cleanup = ctx.noorm.observer.on('change:complete', (data) => { + const cleanup = noormObserver.on('change:complete', (data) => { events.push({ name: data.name, @@ -99,13 +100,13 @@ describe('ctx.noorm.observer change:complete', () => { }); -describe('ctx.noorm.observer regex pattern matching', () => { +describe('noormObserver regex pattern matching', () => { it('routes multiple file:* events through a /^file:/ subscription during run.dir', async () => { const fileEvents: string[] = []; - const cleanup = ctx.noorm.observer.on(/^file:/, (payload) => { + const cleanup = noormObserver.on(/^file:/, (payload) => { // RegExp listeners receive { event, data }. const evt = payload.event; @@ -140,13 +141,13 @@ describe('ctx.noorm.observer regex pattern matching', () => { }); -describe('ctx.noorm.observer file:after status=failed', () => { +describe('noormObserver file:after status=failed', () => { it('emits file:after with status="failed" when the executed SQL throws', async () => { const events: Array<{ filepath: string; status: string; error?: string }> = []; - const cleanup = ctx.noorm.observer.on('file:after', (data) => { + const cleanup = noormObserver.on('file:after', (data) => { events.push({ filepath: data.filepath, diff --git a/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts b/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts index 623ce3c2..6354bb52 100644 --- a/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts +++ b/examples/llm-memory-db-pg/tests/integration/03_vault.test.ts @@ -24,8 +24,7 @@ beforeAll(async () => { const status = await ctx.noorm.vault.status(); if (!status.isInitialized) { - const [, err] = await ctx.noorm.vault.init(); - if (err) throw err; + await ctx.noorm.vault.init(); } @@ -50,15 +49,13 @@ afterAll(async () => { describe('ctx.noorm.vault.init', () => { - it('reports already-initialized via the [result, err] tuple on repeat init', async () => { + it('returns null on repeat init and leaves status intact', async () => { - // First call happened in beforeAll. A repeat init() returns a - // non-null Error in the tuple's second slot ("Vault already - // initialized") rather than throwing — callers can safely call - // it without a try/catch and just check the tuple. - const [, err] = await ctx.noorm.vault.init(); - expect(err).not.toBeNull(); - expect(err?.message).toContain('already initialized'); + // First call happened in beforeAll. A repeat init() is idempotent + // — it returns null rather than treating "already initialized" as + // an error. Real failures (DB errors, encryption errors) throw. + const result = await ctx.noorm.vault.init(); + expect(result).toBeNull(); const status = await ctx.noorm.vault.status(); expect(status.isInitialized).toBe(true); @@ -72,8 +69,7 @@ describe('ctx.noorm.vault.set / get', () => { it('stores a value and returns it via get()', async () => { - const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'test-value', privateKey); - expect(setErr).toBeNull(); + await ctx.noorm.vault.set(TEST_KEY, 'test-value', privateKey); const value = await ctx.noorm.vault.get(TEST_KEY, privateKey); expect(value).toBe('test-value'); @@ -86,8 +82,7 @@ describe('ctx.noorm.vault.list', () => { it('includes a key after it is set', async () => { - const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey); - expect(setErr).toBeNull(); + await ctx.noorm.vault.set(TEST_KEY, 'list-me', privateKey); const keys = await ctx.noorm.vault.list(); expect(Array.isArray(keys)).toBe(true); @@ -99,13 +94,11 @@ describe('ctx.noorm.vault.list', () => { describe('ctx.noorm.vault.delete', () => { - it('returns [true, null] and exists() reports false afterwards', async () => { + it('returns true and exists() reports false afterwards', async () => { - const [, setErr] = await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey); - expect(setErr).toBeNull(); + await ctx.noorm.vault.set(TEST_KEY, 'delete-me', privateKey); - const [deleted, delErr] = await ctx.noorm.vault.delete(TEST_KEY); - expect(delErr).toBeNull(); + const deleted = await ctx.noorm.vault.delete(TEST_KEY); expect(deleted).toBe(true); const stillThere = await ctx.noorm.vault.exists(TEST_KEY); diff --git a/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl b/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl index ce2272e3..5482bee7 100644 --- a/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl +++ b/examples/todo-db/sql/10_seeds/feature_flags.sql.tmpl @@ -8,7 +8,10 @@ {% for (const flag of $.featureFlags) { const secretName = flag.required_secret; - const secretValue = secretName ? ($.secrets?.[secretName] ?? null) : null; + // `in` before read: $.secrets throws on an unresolved key so a missing + // secret can never render as the string "undefined". Probing for an + // optional secret has to say so explicitly. + const secretValue = secretName && (secretName in $.secrets) ? $.secrets[secretName] : null; const hasSecret = !secretName || secretValue !== null; const effectiveEnabled = !!flag.enabled && hasSecret; const config = { diff --git a/install.sh b/install.sh index 1d21bd66..9d8436ed 100755 --- a/install.sh +++ b/install.sh @@ -4,10 +4,55 @@ set -e # noorm installer # Usage: curl -fsSL https://noorm.dev/install.sh | sh # or: curl -fsSL https://raw.githubusercontent.com/noormdev/noorm/master/install.sh | sh +# +# NOORM_INSECURE: set to a truthy value (anything other than empty, "0", or "false") +# to skip checksum verification when checksums.txt is unreachable, has no entry +# for this platform, or no sha256 tool is available. Does NOT bypass a confirmed +# checksum mismatch -- a verified bad hash always aborts the install. REPO="noormdev/noorm" BINARY_NAME="noorm" +# NOORM_INSECURE truthy check -- mirrors the TS isInsecureMode/isYesMode convention: +# any value other than empty, "0", or "false" (case-insensitive) counts as truthy. +is_insecure() { + case "$NOORM_INSECURE" in + ''|0|false|FALSE|False) return 1 ;; + *) return 0 ;; + esac +} + +# Compare $file's sha256 against its entry (matched by $asset name) in $checksums. +# Sets $verify_result to "match", "mismatch", or "unverifiable" (no entry / no +# hashing tool present -- the caller decides whether NOORM_INSECURE allows that). +verify_checksum() { + file="$1" + checksums="$2" + asset="$3" + + expected=$(awk -v a="$asset" '$2 == a { print $1 }' "$checksums" | head -n1) + + if [ -z "$expected" ]; then + verify_result="unverifiable" + return + fi + + if command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$file" | awk '{ print $1 }') + elif command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$file" | awk '{ print $1 }') + else + verify_result="unverifiable" + return + fi + + if [ "$expected" = "$actual" ]; then + verify_result="match" + else + verify_result="mismatch" + fi +} + main() { os=$(detect_os) arch=$(detect_arch) @@ -34,6 +79,45 @@ main() { exit 1 fi + asset="noorm-${suffix}" + url_checksums="https://github.com/${REPO}/releases/download/${tag}/checksums.txt" + checksums_file=$(mktemp) + + if curl -fsSL -o "$checksums_file" "$url_checksums"; then + have_checksums=1 + else + have_checksums=0 + fi + + if [ "$have_checksums" -eq 1 ]; then + verify_checksum "$tmpfile" "$checksums_file" "$asset" + else + verify_result="unverifiable" + fi + + case "$verify_result" in + match) + echo "Checksum verified for ${asset}." + ;; + mismatch) + echo "Error: checksum mismatch for ${asset} -- downloaded binary does not match checksums.txt." >&2 + rm -f "$tmpfile" "$checksums_file" + exit 1 + ;; + unverifiable) + if is_insecure; then + echo "Warning: could not verify checksum for ${asset} (checksums.txt unreachable, no matching entry, or no sha256 tool available) -- proceeding unverified because NOORM_INSECURE is set." >&2 + else + echo "Error: could not verify checksum for ${asset} (checksums.txt unreachable, no matching entry, or no sha256 tool available)." >&2 + echo "Set NOORM_INSECURE=1 to bypass this check and install unverified (never bypasses a confirmed mismatch)." >&2 + rm -f "$tmpfile" "$checksums_file" + exit 1 + fi + ;; + esac + + rm -f "$checksums_file" + chmod +x "$tmpfile" mkdir -p "$install_dir" mv "$tmpfile" "${install_dir}/${BINARY_NAME}" diff --git a/package.json b/package.json index 4b699060..635688e6 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,12 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/cli/index.js", - "start:init": "node dist/cli/index.js init", "test": "bun test --serial", "test:watch": "bun test --serial --watch", "test:coverage": "bun test --serial --coverage", "lint": "eslint src tests", "lint:fix": "eslint src tests --fix", + "lint:docs": "bash scripts/check-flag-placement.sh", "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", "clean": "rm -rf dist coverage", @@ -39,7 +39,7 @@ "terminal" ], "author": "", - "license": "ISC", + "license": "MIT", "engines": { "node": ">=22.13", "bun": ">=1.2" @@ -58,17 +58,12 @@ "ansis": "^4.2.0", "better-sqlite3": "^12.8.0", "citty": "^0.2.2", - "consola": "^3.4.2", "csv-parse": "^6.1.0", "dayjs": "^1.11.20", "eta": "^4.5.1", "ink": "^6.8.0", - "ink-select-input": "^6.2.0", - "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", "json5": "^2.2.3", "kysely": "^0.28.12", - "node-machine-id": "^1.1.12", "react": "^19.2.4", "sql-parser-cst": "^0.39.0", "voca": "^1.4.1", diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 00000000..ee92eec1 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Danilo Alonso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/package.json b/packages/cli/package.json index 1143d8c3..99bd9120 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,7 +23,7 @@ "cli", "noorm" ], - "license": "ISC", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/noormdev/noorm" diff --git a/packages/cli/scripts/postinstall.js b/packages/cli/scripts/postinstall.js index d5456b86..3a8cb7df 100644 --- a/packages/cli/scripts/postinstall.js +++ b/packages/cli/scripts/postinstall.js @@ -8,7 +8,8 @@ * postinstall to fetch the correct binary for the user's OS and architecture. */ -import { createWriteStream, existsSync, chmodSync, mkdirSync } from 'fs'; +import { createHash } from 'crypto'; +import { createReadStream, createWriteStream, existsSync, chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'fs'; import { get as httpsGet } from 'https'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -18,6 +19,85 @@ const PACKAGE_ROOT = resolve(__dirname, '..'); const BIN_DIR = resolve(PACKAGE_ROOT, 'bin'); const REPO = 'noormdev/noorm'; +const ROOT_PACKAGE_NAME = '@noormdev/main'; + +const BINARY_NAME = process.platform === 'win32' ? 'noorm.exe' : 'noorm'; +const DEST = resolve(BIN_DIR, BINARY_NAME); +const DOWNLOAD_DEST = `${DEST}.download`; +const CHECKSUMS_TMP = resolve(BIN_DIR, 'checksums.txt.tmp'); + +/** + * A confirmed-bad or unverifiable binary. Distinguishes a hard-fail from + * every other (soft) failure so the top-level `main().catch()` can tell + * them apart without matching on error message text. + */ +class ChecksumFailure extends Error { + + constructor(message) { + + super(message); + this.name = 'ChecksumFailure'; + + } + +} + +/** + * Local mirror of `attemptSync` from `@logosdx/utils`, which this script + * cannot import: postinstall runs before the workspace is built, and in a + * consumer's tree the dependency need not be installed at all. + * + * Exists so the callers below keep the repo's error-tuple convention instead + * of ad-hoc try/catch control flow -- the one unavoidable try/catch is + * encapsulated here, exactly as the real utility encapsulates it. + */ +function attemptSync(fn) { + + try { + return [fn(), null]; + } + catch (err) { + return [null, err]; + } + +} + +/** + * Detects the noorm monorepo source checkout, where this script must not run. + * + * The download is pinned to the release tag matching this package's version, + * which in a source checkout is routinely not a published release -- every + * commit after a version bump points at a tag that does not exist yet, and + * tags cut before the release workflow started emitting checksums.txt have + * nothing to verify against. Either way the download fails, and a failed + * verification is a hard failure by design (see main().catch below), so it + * takes `bun install` down with it. + * + * Nothing in the repo needs it: the CLI is built from source here + * (`bun run build:packages`, or `bun run build:binary` for a real binary) and + * the downloaded asset is never executed. Skipping is the whole fix. + * + * Keyed on the private workspace root two levels up, which the npm tarball + * never ships -- an installed copy lives at node_modules/@noormdev/cli, whose + * grandparent is node_modules itself and has no package.json. + */ +function isSourceCheckout() { + + const rootPackage = resolve(PACKAGE_ROOT, '..', '..', 'package.json'); + + if (!existsSync(rootPackage)) { + return false; + } + + // An unreadable or malformed package.json two levels up says nothing about + // being in the source repo -- fall through to the normal download path + // rather than silently skipping a consumer's install. + const [pkg] = attemptSync(() => JSON.parse(readFileSync(rootPackage, 'utf8'))); + + return pkg?.name === ROOT_PACKAGE_NAME; + +} + /** * Resolves the platform suffix used in binary asset names. * @@ -107,26 +187,182 @@ function download(url, dest, redirects = 0) { } +/** + * Determine whether the user has opted out of binary checksum verification. + * + * Mirrors the TS `isInsecureMode` truthy-string parsing (src/cli/_utils.ts) + * so the escape hatch behaves identically from a shell's point of view: + * any non-empty NOORM_INSECURE value except `0` or `false` (case-insensitive) + * is truthy. + * + * This only ever widens the "we couldn't verify" case -- checksums.txt + * unreachable, or missing an entry for this asset -- into a warning. It + * never downgrades a confirmed checksum mismatch, which always fails. + */ +function isInsecureMode() { + + const env = process.env.NOORM_INSECURE; + + if (env === undefined || env === '') return false; + if (env === '0') return false; + if (env.toLowerCase() === 'false') return false; + + return true; + +} + +/** + * Parse a `shasum -a 256`-style checksums file into an asset -> sha256 map. + * + * Mirrors src/core/update/checksum.ts's parseChecksums regex exactly (that + * module can't be imported here -- this script runs before the package is + * built). Matches the exact format the release workflow produces: a 64-char + * hex hash, whitespace, an optional `*` binary-mode marker, then the + * filename. Blank/malformed lines are skipped rather than thrown on. + */ +function parseChecksums(text) { + + const out = {}; + + for (const line of text.split('\n')) { + + const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i); + + if (match && match[1] && match[2]) { + out[match[2]] = match[1].toLowerCase(); + } + + } + + return out; + +} + +/** + * Compute the sha256 hex digest of a file on disk. + * + * Streams the file through the hash rather than buffering it whole -- + * release binaries run tens of MB. + */ +function sha256File(path) { + + return new Promise((resolve, reject) => { + + const hash = createHash('sha256'); + const stream = createReadStream(path); + + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + + }); + +} + +/** + * Deletes the download-in-progress temp files, if present. Safe to call + * from any terminal path (success, hard-fail, soft-fail) -- guards on + * existsSync so a file that was never created, or already renamed away + * on success, is a no-op. + */ +function cleanupTempFiles() { + + if (existsSync(DOWNLOAD_DEST)) unlinkSync(DOWNLOAD_DEST); + if (existsSync(CHECKSUMS_TMP)) unlinkSync(CHECKSUMS_TMP); + +} + +/** + * Verifies the freshly downloaded binary at DOWNLOAD_DEST against + * checksums.txt fetched from the same release tag. + * + * Same invariant as every other verification call site in this ticket + * (src/core/update/checksum.ts, install.sh): a confirmed hash mismatch is + * ALWAYS a hard failure -- NOORM_INSECURE cannot downgrade it. Only "we + * couldn't get a trustworthy answer" (checksums.txt unreachable, or + * fetched but missing an entry for this asset) is bypassable. + * + * @throws {ChecksumFailure} on a confirmed mismatch, or when verification + * could not complete and NOORM_INSECURE is not set. + */ +async function verifyChecksum(checksumsUrl, assetName) { + + // A failed checksums.txt fetch is captured here, not thrown -- it + // folds into the "could not verify" branch below, which NOORM_INSECURE + // governs, same as a fetch that succeeds but has no entry for us. + const fetched = await download(checksumsUrl, CHECKSUMS_TMP) + .then(() => true) + .catch(() => false); + + let expected; + + if (fetched) { + + const { readFile } = await import('fs/promises'); + const text = await readFile(CHECKSUMS_TMP, 'utf8'); + expected = parseChecksums(text)[assetName]; + + } + + if (expected) { + + const actual = await sha256File(DOWNLOAD_DEST); + + if (actual.toLowerCase() !== expected.toLowerCase()) { + + throw new ChecksumFailure( + `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})` + ); + + } + + return; + + } + + if (isInsecureMode()) { + + console.warn( + `Warning: could not verify checksum for ${assetName} (checksums.txt unreachable or missing ` + + 'an entry for this asset); proceeding unverified because NOORM_INSECURE is set.' + ); + + return; + + } + + throw new ChecksumFailure( + `could not verify checksum for ${assetName}: checksums.txt unreachable or has no entry for this asset ` + + '(set NOORM_INSECURE=1 to install anyway)' + ); + +} + /** * Main postinstall routine. * - * Downloads the correct binary, saves it to bin/, and makes it executable. - * Exits cleanly on failure so npm install doesn't break. + * Downloads the correct binary to a temp path, verifies its checksum, and + * only then promotes it to bin/. Exits cleanly on failure so npm install + * doesn't break -- except a confirmed-bad or unverifiable binary, see + * main().catch() below. */ async function main() { + if (isSourceCheckout()) { + console.log('noorm source checkout detected - skipping release binary download (build one with `bun run build:binary`).'); + return; + } + const suffix = getPlatformSuffix(); const version = await getVersion(); const assetName = `noorm-${suffix}`; const tag = `@noormdev/cli@${version}`; const url = `https://github.com/${REPO}/releases/download/${tag}/${assetName}`; - - const binaryName = process.platform === 'win32' ? 'noorm.exe' : 'noorm'; - const dest = resolve(BIN_DIR, binaryName); + const checksumsUrl = `https://github.com/${REPO}/releases/download/${tag}/checksums.txt`; // Skip if binary already exists (e.g. reinstall) - if (existsSync(dest)) { - console.log(`noorm binary already exists at ${dest}`); + if (existsSync(DEST)) { + console.log(`noorm binary already exists at ${DEST}`); return; } @@ -134,18 +370,38 @@ async function main() { console.log(`Downloading noorm ${version} for ${suffix}...`); - await download(url, dest); + await download(url, DOWNLOAD_DEST); + + // Verify BEFORE trusting the binary -- chmod+rename below is the point + // this script starts treating the file as the live noorm CLI. + await verifyChecksum(checksumsUrl, assetName); if (process.platform !== 'win32') { - chmodSync(dest, 0o755); + chmodSync(DOWNLOAD_DEST, 0o755); } + renameSync(DOWNLOAD_DEST, DEST); + cleanupTempFiles(); + console.log(`✓ noorm ${version} installed`); } main().catch((err) => { + cleanupTempFiles(); + + if (err instanceof ChecksumFailure) { + + // Deliberate, scoped exception to this script's "never fail npm + // install" philosophy: every other failure mode below still exits + // 0, but a confirmed-bad or unverifiable binary must not be + // silently trusted and installed as the user's noorm CLI. + console.error(`Error: ${err.message}`); + process.exit(1); + + } + console.error(`Warning: Could not download noorm binary: ${err.message}`); console.error('You can download it manually from:'); console.error(` https://github.com/${REPO}/releases`); diff --git a/packages/sdk/LICENSE b/packages/sdk/LICENSE new file mode 100644 index 00000000..ee92eec1 --- /dev/null +++ b/packages/sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Danilo Alonso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9e70e0c5..a2672a78 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -13,7 +13,8 @@ } }, "files": [ - "dist" + "dist", + "!dist/**/*.map" ], "sideEffects": false, "peerDependencies": { @@ -52,7 +53,7 @@ "kysely", "noorm" ], - "license": "ISC", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/noormdev/noorm" diff --git a/scripts/check-flag-placement.sh b/scripts/check-flag-placement.sh new file mode 100755 index 00000000..cdf23ae6 --- /dev/null +++ b/scripts/check-flag-placement.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# check-flag-placement.sh - guard against root-level flags (other than -c/--cwd) in docs +# +# `extractGlobalCwd` (src/cli/index.ts) hoists only `-c`/`--cwd` out of argv +# before citty dispatch. citty forwards only `rawArgs.slice(subCommandArgIndex +# + 1)` to the resolved subcommand, so every other flag typed before the +# subcommand — `--config`, `--force`, `--dry-run`, `--json`, `--yes`/`-y` — is +# rejected outright with "Unrecognized flag … before the subcommand". Only +# placing the flag after the subcommand (`noorm run build --dry-run`) works. +# +# `-c`/`--cwd` is the sole exception, for two reasons: it is consumed before +# dispatch (it sets the working directory everything else resolves against), +# and `-c` already means `--config` after the subcommand — see the +# "`--config` / `-c` overload" note in docs/cli/flags.md. Hoisting any other +# flag would either collide with that overload or just add a declaration +# site without a reason: a flag goes on the command that uses it. +# +# This script fails the build if a broken root-level form reappears in +# doc/skill/example prose outside the files that deliberately show it as a +# contrasted gotcha ("does not work" vs. "works") rather than instructing +# the reader to actually type it. +# +# Usage: +# bash scripts/check-flag-placement.sh +# +set -e + +PATTERNS=('noorm --config ' 'noorm --force' 'noorm --dry-run' 'noorm --json' 'noorm --yes' 'noorm -y ') +TARGETS=(README.md docs skills examples) + +# Files that intentionally show the broken form, contrasted directly against +# the correct form under a "does not work"/overload-explainer heading: +# - docs/guide/troubleshooting.md: the flag-placement entry contrasts broken vs. working forms +# - docs/cli/flags.md: the placement explainer and `--config`/`-c` overload section contrast the two forms +# - docs/design/v1-49-54-cli-field-defects.md: this checkpoint's own design doc, which +# narrates the "form that cannot work" as the defect under investigation +# - examples/llm-memory-db-pg/{REPORT.md,REPORT-PHASE-1.md,postgres-problems.md}: dated +# investigation logs from a consuming example project (predate this branch — see git log), +# already stating the "must come after the subcommand" rule as a quirk they hit +# - examples/llm-memory-db-mssql/mssql-problems.md: same — a dated repro transcript, not +# instructional prose, predating this branch +EXEMPT_REGEX='^(docs/guide/troubleshooting\.md|docs/cli/flags\.md|docs/design/v1-49-54-cli-field-defects\.md|examples/llm-memory-db-pg/(REPORT\.md|REPORT-PHASE-1\.md|postgres-problems\.md)|examples/llm-memory-db-mssql/mssql-problems\.md):' + +FAILED=0 + +for PATTERN in "${PATTERNS[@]}"; do + + MATCHES=$(grep -rn -- "$PATTERN" "${TARGETS[@]}" 2>/dev/null | grep -Ev "$EXEMPT_REGEX") || true + + if [ -n "$MATCHES" ]; then + + echo "Found root-level '$PATTERN' placement (broken — flag must come after the subcommand):" + echo "$MATCHES" + FAILED=1 + + fi + +done + +if [ "$FAILED" -ne 0 ]; then + exit 1 +fi + +echo "OK: no broken root-level flag placement found." diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index 4d45fe17..00000000 --- a/scripts/install.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/bin/sh -# noorm CLI installer -# -# Usage: -# curl -fsSL https://noorm.dev/install.sh | sh -# curl -fsSL https://raw.githubusercontent.com/noormdev/noorm/master/scripts/install.sh | sh -# -# Options (via environment): -# NOORM_VERSION=1.0.0 Pin a specific version (default: latest) -# NOORM_INSTALL_DIR=~/.noorm/bin Override install directory -# -set -e - -REPO="noormdev/noorm" -INSTALL_DIR="${NOORM_INSTALL_DIR:-$HOME/.noorm/bin}" -BINARY_NAME="noorm" - -# --- Helpers --- - -info() { - printf ' \033[1;34m%s\033[0m %s\n' "$1" "$2" -} - -success() { - printf ' \033[1;32m✓\033[0m %s\n' "$1" -} - -fail() { - printf ' \033[1;31m✗\033[0m %s\n' "$1" >&2 - exit 1 -} - -# --- Detect platform --- - -detect_platform() { - - OS="$(uname -s)" - ARCH="$(uname -m)" - - case "$OS" in - Darwin) OS="darwin" ;; - Linux) OS="linux" ;; - MINGW*|MSYS*|CYGWIN*) - fail "Windows is not supported by this installer. Download the binary from:" - fail " https://github.com/$REPO/releases" - ;; - *) - fail "Unsupported operating system: $OS" - ;; - esac - - case "$ARCH" in - x86_64|amd64) ARCH="x64" ;; - arm64|aarch64) ARCH="arm64" ;; - *) - fail "Unsupported architecture: $ARCH" - ;; - esac - -} - -# --- Resolve version --- - -resolve_version() { - - if [ -n "$NOORM_VERSION" ]; then - VERSION="$NOORM_VERSION" - info "Version:" "$VERSION (pinned)" - return - fi - - info "Resolving:" "latest version..." - - # Find the latest @noormdev/cli release tag - VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" \ - | grep -o '"tag_name": *"@noormdev/cli@[^"]*"' \ - | head -1 \ - | sed 's/.*@noormdev\/cli@//' \ - | sed 's/"//') - - if [ -z "$VERSION" ]; then - fail "Could not determine latest version. Set NOORM_VERSION manually." - fi - - info "Version:" "$VERSION" - -} - -# --- Download --- - -download_binary() { - - ASSET_NAME="noorm-${OS}-${ARCH}" - TAG="@noormdev/cli@${VERSION}" - DOWNLOAD_URL="https://github.com/$REPO/releases/download/${TAG}/${ASSET_NAME}" - - info "Platform:" "${OS}-${ARCH}" - info "Downloading:" "$DOWNLOAD_URL" - - TMPDIR_DL="$(mktemp -d)" - TMPFILE="${TMPDIR_DL}/${BINARY_NAME}" - - HTTP_CODE=$(curl -fsSL -w '%{http_code}' -o "$TMPFILE" "$DOWNLOAD_URL" 2>/dev/null || true) - - if [ "$HTTP_CODE" != "200" ] || [ ! -s "$TMPFILE" ]; then - rm -rf "$TMPDIR_DL" - fail "Download failed (HTTP $HTTP_CODE). Check that version $VERSION has a binary for ${OS}-${ARCH}." - fi - -} - -# --- Install --- - -install_binary() { - - mkdir -p "$INSTALL_DIR" - mv "$TMPFILE" "${INSTALL_DIR}/${BINARY_NAME}" - chmod +x "${INSTALL_DIR}/${BINARY_NAME}" - rm -rf "$TMPDIR_DL" - - success "Installed noorm to ${INSTALL_DIR}/${BINARY_NAME}" - -} - -# --- PATH check --- - -check_path() { - - case ":$PATH:" in - *":${INSTALL_DIR}:"*) - # Already in PATH - ;; - *) - echo "" - info "Add to PATH:" "Add this to your shell profile (~/.zshrc, ~/.bashrc, etc.):" - echo "" - echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" - echo "" - ;; - esac - -} - -# --- Verify --- - -verify_install() { - - if [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then - success "noorm $VERSION is ready" - else - fail "Installation verification failed" - fi - -} - -# --- Main --- - -main() { - - echo "" - echo " noorm installer" - echo "" - - detect_platform - resolve_version - download_binary - install_binary - check_path - verify_install - - echo "" - -} - -main diff --git a/skills/noorm/SKILL.md b/skills/noorm/SKILL.md index a8d895f4..4c527245 100644 --- a/skills/noorm/SKILL.md +++ b/skills/noorm/SKILL.md @@ -1,6 +1,6 @@ --- name: noorm -description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger when you see noorm environment variables (NOORM_*), .sql.tmpl template files, or change/revert directory structures. Trigger for any database task in a noorm-managed project — the SDK conventions (error tuples, no try-catch, test safety guards, Kysely patterns) and CLI flags are non-obvious and critical to get right. +description: Guide for building applications and tests with the NoORM database SDK (@noormdev/sdk) and composing CLI headless commands for CI/CD pipelines. Use this skill whenever the user imports @noormdev/sdk, calls createContext(), writes Kysely queries in a noorm-managed project, writes database integration tests, sets up CI/CD with noorm commands, manages database schemas or migrations, runs SQL files through noorm, transfers data between databases, manages vault secrets, or discusses noorm in any development context. Also trigger when you see noorm environment variables (NOORM_*), .sql.tmpl template files, or change/revert directory structures. Trigger for any database task in a noorm-managed project — the SDK conventions (SDK methods throw named errors, `attempt()` wraps deliberately, no try-catch, test safety guards, Kysely patterns) and CLI flags are non-obvious and critical to get right. --- # NoORM @@ -48,6 +48,23 @@ Priority chain (highest wins): Env-only mode (no stored config needed): set `NOORM_CONNECTION_DIALECT` + `NOORM_CONNECTION_DATABASE` as environment variables. This is the standard approach for CI/CD pipelines. +### Error Handling + +SDK methods throw named errors and let them propagate — this is the primary contract, not an +opt-in. Wrap a call in `attempt()`/`attemptSync()` only when you're going to do something with +the error (translate it, recover, observe, or knowingly ignore it); if you'd just re-throw it +unchanged, skip `attempt` and let it propagate. + +Carve-outs that don't follow the throw contract: + +- `ctx.noorm.utils.testConnection()` returns `{ ok, error? }` by design — a connection probe + reporting failure as data, not an exception. +- Transaction callbacks (`ctx.transaction(...)`) must throw to roll back — that's Kysely's own + transaction contract. +- Raw `ctx.kysely` queries throw like any Kysely call. + +Never wrap a `ctx.noorm.*` call in try-catch. + ## Common Mistakes These are the patterns LLMs get wrong most often with noorm: @@ -61,5 +78,5 @@ These are the patterns LLMs get wrong most often with noorm: | `ctx.proc()` on SQLite | Check `ctx.dialect` first | SQLite has no stored procedures, functions, or TVFs | | `ctx.tvf()` on MySQL | Check `ctx.dialect` first | MySQL doesn't support TVFs either | | `Procs = { name: ArgsType }` | `Procs = { name: [ArgsType, ReturnType] }` | Tuple format enables return type inference | -| Forget to check error tuples | Always check `err` before using `result` | Silent failures otherwise | +| Assume every `ctx.noorm.*` call returns a tuple | SDK methods throw named errors — wrap with `attempt()` only when you'll do something with the error | Unhandled throws stop the caller immediately; that's the contract | | Use raw Kysely without noorm | Use `ctx.kysely` from a noorm Context | Misses config resolution, checksums, change tracking | diff --git a/skills/noorm/references/cli.md b/skills/noorm/references/cli.md index 2041316a..dc215ee1 100644 --- a/skills/noorm/references/cli.md +++ b/skills/noorm/references/cli.md @@ -71,7 +71,6 @@ NOORM_PATHS_CHANGES # Changes directory (default: ./changes) ```bash NOORM_CONFIG # Config name to use (alternative to --config flag) NOORM_YES # Skip confirmations: 1 or true -NOORM_JSON # Force JSON output: 1 or true NOORM_HEADLESS # Signal CI mode (routes logs to stdout; detected automatically in CI) NOORM_DEBUG # Enable debug logging ``` @@ -101,7 +100,7 @@ Set the active configuration. ```bash noorm config use dev -noorm --json config use production +noorm config use production --json ``` **JSON:** `{ "activeConfig": "production" }` @@ -115,7 +114,7 @@ Create a new cryptographic identity. Generates an X25519 keypair and stores it a ```bash noorm identity init --name "Alice" --email "alice@example.com" noorm identity init --name "Alice" --email "alice@example.com" --force -noorm --json identity init --name "Alice" --email "alice@example.com" +noorm identity init --name "Alice" --email "alice@example.com" --json ``` **JSON:** `{ "name": "Alice", "email": "alice@example.com", "fingerprint": "...", "publicKey": "..." }` @@ -135,7 +134,7 @@ Print your public key so teammates can add you to encrypted vaults. ```bash noorm identity export -noorm --json identity export +noorm identity export --json ``` ### identity list @@ -144,7 +143,7 @@ List all known users discovered from database syncs (the audit trail of who has ```bash noorm identity list -noorm --json identity list +noorm identity list --json ``` --- @@ -290,8 +289,8 @@ Execute all SQL files in the schema directory. Uses checksums to skip unchanged ```bash noorm run build -noorm --force run build # Skip checksums, rebuild everything -noorm --dry-run run build # Preview without executing +noorm run build --force # Skip checksums, rebuild everything +noorm run build --dry-run # Preview without executing ``` **JSON:** @@ -313,7 +312,7 @@ Execute a single SQL or `.sql.tmpl` file. ```bash noorm run file sql/01_tables/001_users.sql noorm run file seed.sql.tmpl -noorm --json run file sql/init.sql +noorm run file sql/init.sql --json ``` **JSON:** `{ "filepath": "seed.sql", "status": "success", "durationMs": 45 }` @@ -335,7 +334,7 @@ Show the template context for a `.sql.tmpl` file without rendering it. Lists ava ```bash noorm run inspect sql/users/001_create.sql.tmpl -noorm --json run inspect sql/core/Crons.sql.tmpl +noorm run inspect sql/core/Crons.sql.tmpl --json ``` **JSON:** @@ -362,8 +361,8 @@ Render a `.sql.tmpl` file and output the resulting SQL. Does **not** execute aga ```bash noorm run preview sql/schema.sql.tmpl # Raw SQL to stdout noorm run preview sql/schema.sql.tmpl > rendered.sql # Pipe to file -noorm --json run preview sql/seed.sql.tmpl # JSON envelope -noorm --config staging run preview sql/migrations/002.sql.tmpl +noorm run preview sql/seed.sql.tmpl --json # JSON envelope +noorm run preview sql/migrations/002.sql.tmpl --config staging ``` **JSON:** `{ "filepath": "...", "sql": "CREATE TABLE ...", "durationMs": 12 }` @@ -394,7 +393,7 @@ List every known change with its status (pending, success, failed, reverted, sta ```bash noorm change list -noorm --json change list +noorm change list --json noorm -c staging change list ``` @@ -406,8 +405,8 @@ Fast-forward: apply all pending changes in order. ```bash noorm change ff -noorm --dry-run change ff # Preview only -noorm --force change ff # Skip checksum checks +noorm change ff --dry-run # Preview only +noorm change ff --force # Skip checksum checks ``` **JSON:** @@ -430,7 +429,7 @@ Apply the next pending change only (rather than all of them). ```bash noorm change next -noorm --json change next +noorm change next --json ``` Useful when you want to apply changes one at a time and verify each before continuing. @@ -442,7 +441,7 @@ Apply a specific change by name. Omit the name on a TTY to pick from pending / r ```bash noorm change run # Interactive picker noorm change run 2024-02-01-notifications -noorm --json change run 001_init +noorm change run 001_init --json noorm -c staging change run 001_init ``` @@ -467,7 +466,7 @@ Revert a previously applied change by running its rollback SQL. Omit the name on ```bash noorm change revert # Interactive picker noorm change revert 2024-02-01-notifications -noorm --json change revert 002_users +noorm change revert 002_users --json ``` ### change rewind @@ -478,7 +477,7 @@ Revert applied changes back to (and including) a named change — the inverse of noorm change rewind # Interactive picker noorm change rewind 001_init noorm change rewind 002_users --dry-run -noorm --json change rewind 003_roles +noorm change rewind 003_roles --json ``` ### change add @@ -522,7 +521,7 @@ View execution history with timestamps, direction, duration, and identity. ```bash noorm change history noorm --count 50 change history -noorm --json change history +noorm change history --json ``` **JSON:** @@ -551,7 +550,7 @@ Per-file execution history for a specific change — every operation record plus noorm change history-detail # Interactive picker noorm change history-detail 001_init noorm change history-detail 003_roles --count 5 -noorm --json change history-detail 002_users +noorm change history-detail 002_users --json ``` --- @@ -564,7 +563,7 @@ Database schema inspection. noorm db explore # Overview counts noorm db explore tables # List all tables noorm db explore tables detail users # Describe specific table -noorm --json db explore # JSON overview +noorm db explore --json # JSON overview ``` **JSON (overview):** `{ "tables": 12, "views": 3, "indexes": 8, "functions": 2, "procedures": 0 }` @@ -586,7 +585,7 @@ noorm --json db explore # JSON overview Wipe all data from application tables. Schema and noorm tracking tables are preserved. ```bash -noorm -y db truncate +noorm db truncate -y ``` ### db teardown @@ -594,7 +593,7 @@ noorm -y db truncate Drop all database objects. Gated by the config's `db:reset` access role: `viewer` denied, `operator` must confirm (`--yes`/`NOORM_YES=1`), `admin` runs unconfirmed. No flag overrides a `viewer` denial. ```bash -noorm -y db teardown +noorm db teardown -y ``` ### db transfer @@ -698,7 +697,7 @@ Execute raw SQL queries directly. noorm sql "SELECT * FROM users LIMIT 10" noorm sql -f query.sql # Read from file noorm -c prod sql "SELECT count(*) FROM orders" -noorm --json sql "SELECT id, name FROM users" +noorm sql "SELECT id, name FROM users" --json ``` **JSON:** @@ -734,7 +733,7 @@ Show recent SQL execution history for a config. Reads persisted history from `.n ```bash noorm sql history noorm sql history -n 20 -noorm --json sql history +noorm sql history --json noorm -c prod sql history ``` @@ -756,7 +755,7 @@ Project and database status overview. ```bash noorm info -noorm --json info +noorm info --json ``` Surfaces: CLI version, schema versions, active config, connection details, identity, database object counts. @@ -767,17 +766,17 @@ CLI version and diagnostic information. ```bash noorm version -noorm --json version +noorm version --json ``` -### help +### --help -Show help for any command. +Show help for any command. There is no `help` subcommand — append `--help` (or `-h`) to any command instead. ```bash -noorm help -noorm help config use -noorm help change ff +noorm --help +noorm config use --help +noorm change ff --help ``` --- @@ -948,7 +947,7 @@ trap "noorm lock release" EXIT noorm change ff # Verify -noorm --json db explore +noorm db explore --json ``` ## Scripting Patterns @@ -956,7 +955,7 @@ noorm --json db explore ### Check for pending changes before deploying ```bash -pending=$(noorm --json change | jq '[.[] | select(.status == "pending")] | length') +pending=$(noorm change --json | jq '[.[] | select(.status == "pending")] | length') if [ "$pending" -gt 0 ]; then echo "Found $pending pending changes" noorm change ff @@ -966,14 +965,14 @@ fi ### Verify table count after migration ```bash -tables=$(noorm --json db explore | jq '.tables') +tables=$(noorm db explore --json | jq '.tables') echo "Database has $tables tables" ``` ### Check for failures in change history ```bash -failures=$(noorm --json change history | jq '[.[] | select(.status == "failed")] | length') +failures=$(noorm change history --json | jq '[.[] | select(.status == "failed")] | length') if [ "$failures" -gt 0 ]; then echo "WARNING: $failures failed changes in history" exit 1 @@ -989,14 +988,14 @@ noorm run preview sql/hotfix.sql.tmpl | psql -h localhost -d myapp ### Check template context before rendering ```bash -files=$(noorm --json run inspect sql/seed.sql.tmpl | jq '.context.dataFiles | length') +files=$(noorm run inspect sql/seed.sql.tmpl --json | jq '.context.dataFiles | length') echo "Template has $files data files available" ``` ### Check vault access and propagate if needed ```bash -pending=$(noorm --json vault list | jq '.status.usersWithoutAccess') +pending=$(noorm vault list --json | jq '.status.usersWithoutAccess') if [ "$pending" -gt 0 ]; then noorm vault propagate fi diff --git a/skills/noorm/references/sdk.md b/skills/noorm/references/sdk.md index 1cef8d0b..2b6e8b14 100644 --- a/skills/noorm/references/sdk.md +++ b/skills/noorm/references/sdk.md @@ -369,22 +369,22 @@ Lock options: `timeout` (lock duration, default 5min), `wait` (block until avail Team-shared encrypted secrets stored in the database. ```typescript -await ctx.noorm.vault.init(); +await ctx.noorm.vault.init(); // Buffer | null — null when already initialized (idempotent, not an error) const status = await ctx.noorm.vault.status(); // CRUD (requires private key for encryption/decryption) -const [, err] = await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey); +await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey); // throws on failure const value = await ctx.noorm.vault.get('API_KEY', privateKey); // string | null const all = await ctx.noorm.vault.getAll(privateKey); // Record const keys = await ctx.noorm.vault.list(); // string[] -const [deleted, err] = await ctx.noorm.vault.delete('API_KEY'); // [boolean, Error | null] +const deleted = await ctx.noorm.vault.delete('API_KEY'); // boolean — throws on failure const exists = await ctx.noorm.vault.exists('API_KEY'); // Team access management await ctx.noorm.vault.propagate(privateKey); // Copy between configs -const [result, err] = await ctx.noorm.vault.copy(destConfig, keys, privateKey, { +const result = await ctx.noorm.vault.copy(destConfig, keys, privateKey, { force: true, // Overwrite existing }); ``` @@ -413,15 +413,15 @@ const result = await ctx.noorm.templates.render('sql/001_users.sql.tmpl'); Data transfer between database configurations. ```typescript -// Execute transfer -const [result, err] = await ctx.noorm.transfer.to(destConfig, { +// Execute transfer — throws on failure +const result = await ctx.noorm.transfer.to(destConfig, { tables: ['users', 'posts'], // Specific tables (default: all) onConflict: 'skip', // 'fail' | 'skip' | 'update' | 'replace' truncate: false, // Clear destination first? }); -// Plan without executing -const [plan, err] = await ctx.noorm.transfer.plan(destConfig, options); +// Plan without executing — throws on failure +const plan = await ctx.noorm.transfer.plan(destConfig, options); ``` ### dt @@ -429,16 +429,16 @@ const [plan, err] = await ctx.noorm.transfer.plan(destConfig, options); Portable data files: `.dt` (plain), `.dtz` (compressed), `.dtzx` (encrypted). ```typescript -// Export -const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.dtz', { +// Export — throws on failure +const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz', { passphrase: 'secret', // Use .dtzx encryption schema: 'public', // PostgreSQL schema batchSize: 5000, // Rows per batch (default: 1000) }); // result: { rowsWritten, bytesWritten } -// Import -const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', { +// Import — throws on failure +const result = await ctx.noorm.dt.importFile('./exports/users.dtz', { onConflict: 'skip', // 'fail' | 'skip' | 'update' | 'replace' truncate: true, // Clear table first batchSize: 1000, @@ -610,35 +610,39 @@ import { ## Observer Events -Subscribe to real-time progress events emitted by core operations: +Subscribe to real-time progress events emitted by core operations via the +process-global `noormObserver` bus — a singleton shared across every +`Context` in the process, not scoped to one `ctx`: ```typescript +import { noormObserver } from '@noormdev/sdk'; + // File execution progress -ctx.noorm.observer.on('file:before', (data) => { +noormObserver.on('file:before', (data) => { console.log('Running:', data.filepath); }); -ctx.noorm.observer.on('file:after', (data) => { +noormObserver.on('file:after', (data) => { console.log(data.filepath, data.status, data.durationMs + 'ms'); }); -ctx.noorm.observer.on('file:skip', (data) => { +noormObserver.on('file:skip', (data) => { console.log('Skipped:', data.filepath, data.reason); }); // Change lifecycle -ctx.noorm.observer.on('change:start', (data) => { +noormObserver.on('change:start', (data) => { console.log(`Applying ${data.name} (${data.files.length} files)`); }); -ctx.noorm.observer.on('change:complete', (data) => { +noormObserver.on('change:complete', (data) => { console.log(data.name, data.direction, data.status); }); // Build progress -ctx.noorm.observer.on('build:start', (data) => { +noormObserver.on('build:start', (data) => { console.log(`Building ${data.fileCount} files from ${data.sqlPath}`); }); // Pattern matching for multiple events -ctx.noorm.observer.on(/^file:/, ({ event, data }) => { +noormObserver.on(/^file:/, ({ event, data }) => { console.log(`[${event}]`, data); }); ``` diff --git a/src/cli/_utils.ts b/src/cli/_utils.ts index 7cbd137d..aeb43613 100644 --- a/src/cli/_utils.ts +++ b/src/cli/_utils.ts @@ -20,7 +20,7 @@ import { createContext } from '../sdk/index.js'; import { ensureSchemaVersion, type NoormDatabase } from '../core/version/index.js'; import { loadPrivateKey, loadIdentityMetadata } from '../core/identity/storage.js'; import { registerIdentity } from '../core/identity/sync.js'; -import { isDev } from '../core/environment.js'; +import { isDev, isEnvTruthy } from '../core/environment.js'; import { getConfig } from '../core/config/index.js'; /** @@ -33,6 +33,7 @@ export interface CliArgs { force?: boolean; dryRun?: boolean; yes?: boolean; + insecure?: boolean; [key: string]: unknown; } @@ -74,9 +75,32 @@ export const sharedArgs = { */ export function isYesMode(args: CliArgs): boolean { - if (args.yes) return true; + return !!args.yes || isEnvTruthy(process.env['NOORM_YES']); - const env = process.env['NOORM_YES']; +} + +/** + * Determine whether the user has opted out of binary checksum verification. + * + * Returns true when either the `--insecure` flag is set or the + * `NOORM_INSECURE` environment variable holds a truthy value. Mirrors + * `isYesMode`'s exact truthy-string parsing so both escape hatches behave + * identically from a shell's point of view. + * + * This only ever widens the "we couldn't verify" case — checksums.txt + * unreachable — into a warning. It never downgrades a confirmed checksum + * mismatch, which always fails regardless of this flag. + * + * @example + * if (isInsecureMode(args)) { + * process.stderr.write('Warning: checksum verification will be skipped if checksums.txt is unreachable.\n'); + * } + */ +export function isInsecureMode(args: CliArgs): boolean { + + if (args.insecure) return true; + + const env = process.env['NOORM_INSECURE']; if (env === undefined || env === '') return false; if (env === '0') return false; @@ -134,6 +158,7 @@ async function createCliLogger(projectRoot: string, json: boolean): Promise(opts: { const logger = await createCliLogger(projectRoot, !!args.json); await logger.start(); - const [ctx, ctxError] = await attempt(() => createContext({ config: args.config })); + const [ctx, ctxError] = await attempt(() => createContext({ config: args.config, yes: isYesMode(args) })); if (ctxError) { outputError(args, `Failed to create context: ${ctxError.message}`, logger); @@ -330,9 +355,12 @@ export async function withVaultContext(opts: { /** * Output a success result as either JSON or text. * - * When logger is provided and args.json is false, the text message is - * routed through logger.info so it appears in the same stream as event - * output. Otherwise it writes directly to stdout. + * The result always goes to stdout, in every mode — that is what + * distinguishes a command's result from the logger's event stream, which + * always goes to stderr (see Logger#writeConsole). `--json` funnels + * through `logger.result()` when a logger is available (stdout + log + * file); plain text writes directly to stdout, never through + * `logger.info`, so it can't be pulled onto the diagnostics stream. */ export function outputResult( args: CliArgs, @@ -357,16 +385,7 @@ export function outputResult( } else { - if (logger) { - - logger.info(text); - - } - else { - - process.stdout.write(text + '\n'); - - } + process.stdout.write(text + '\n'); } diff --git a/src/cli/change/history.ts b/src/cli/change/history.ts index 8be6c18b..6eb2ba93 100644 --- a/src/cli/change/history.ts +++ b/src/cli/change/history.ts @@ -25,37 +25,17 @@ const historyCommand = defineCommand({ const [history, error] = await withContext({ args, - fn: (ctx, logger) => { - - return ctx.noorm.changes.history(count).then((records) => { - - if (!args.json) { - - logger.info(`Execution History: ${records.length} records`); - - for (const record of records) { - - const date = new Date(record.executedAt).toLocaleString(); - logger.info(` ${record.name} - ${record.status} (${date})`); - - } - - } - - return records; - - }); - - }, + fn: (ctx) => ctx.noorm.changes.history(count), }); if (error) process.exit(1); - if (args.json) { - - outputResult(args, history, ''); + const text = [ + `Execution History: ${history.length} records`, + ...history.map((record) => ` ${record.name} - ${record.status} (${new Date(record.executedAt).toLocaleString()})`), + ].join('\n'); - } + outputResult(args, history, text); process.exit(0); diff --git a/src/cli/change/list.ts b/src/cli/change/list.ts index 949f5170..92a2fef5 100644 --- a/src/cli/change/list.ts +++ b/src/cli/change/list.ts @@ -23,42 +23,21 @@ const listCommand = defineCommand({ const [changes, error] = await withContext({ args, - fn: (ctx, logger) => { - - return ctx.noorm.changes.status().then((res) => { - - if (!args.json) { - - for (const cs of res) { - - logger.info(`${cs.name} (${cs.status})`); - - } - - const pending = res.filter((c) => c.status === 'pending').length; - - if (pending > 0) { - - logger.info(`${pending} pending change(s)`); - - } - - } - - return res; - - }); - - }, + fn: (ctx) => ctx.noorm.changes.status(), }); if (error) process.exit(1); - if (args.json) { + const pending = changes.filter((c) => c.status === 'pending').length; - outputResult(args, changes, ''); + const text = changes.length === 0 + ? 'No changes found.' + : [ + ...changes.map((cs) => `${cs.name} (${cs.status})`), + ...(pending > 0 ? [`${pending} pending change(s)`] : []), + ].join('\n'); - } + outputResult(args, changes, text); process.exit(0); diff --git a/src/cli/change/rewind.ts b/src/cli/change/rewind.ts index e3bce86e..a9fd0e03 100644 --- a/src/cli/change/rewind.ts +++ b/src/cli/change/rewind.ts @@ -67,7 +67,7 @@ const rewindCommand = defineCommand({ const summaryMsg = `Rewind: ${res.status} (${res.executed} reverted, ${res.failed} failed)`; - if (res.status === 'failed') { + if (res.status !== 'success') { logger.error(summaryMsg); @@ -116,7 +116,7 @@ const rewindCommand = defineCommand({ } - process.exit(result.status === 'failed' ? 2 : 0); + process.exit(result.status === 'success' ? 0 : 2); }, }); diff --git a/src/cli/change/rm.ts b/src/cli/change/rm.ts index 624739b8..ff1baa53 100644 --- a/src/cli/change/rm.ts +++ b/src/cli/change/rm.ts @@ -4,21 +4,30 @@ * Offline operation: no database connection required. Reads settings * to locate the changes directory, then removes the named change. * - * On a TTY the command prompts the user to pick a change (if omitted) - * and confirms the deletion interactively; `--yes` skips the confirm. - * On a non-TTY (CI, piped) both the name and `--yes` are required so - * the command never hangs or deletes silently. + * Gated by the resolved config change:rm access role, best-effort: + * when an active or --config config is resolvable, viewer is denied + * outright and operator/admin require confirmation. When no config is + * resolvable at all (fresh project, config add never run), the gate + * is a no-op and confirmation defaults to required, matching the + * pre-ticket behavior of this command. + * + * Confirmation has no TTY prompt substitute: --yes or NOORM_YES is the + * only mechanism, mirroring the headless-only confirmation stance used + * by config rm. On a TTY the command still prompts the user to pick a + * change when the name is omitted; that picker is unrelated UX, not + * the confirm gate. */ import { join } from 'node:path'; import { stat } from 'node:fs/promises'; -import * as p from '@clack/prompts'; import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { deleteChange } from '../../core/change/index.js'; import { getSettingsManager } from '../../core/settings/index.js'; -import { outputResult, outputError, sharedArgs } from '../_utils.js'; +import { initState, getStateManager } from '../../core/state/index.js'; +import { checkConfigPolicy } from '../../core/policy/index.js'; +import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; import { selectChangeFromFs, requireTty } from './_prompt.js'; const rmCommand = defineCommand({ @@ -29,12 +38,36 @@ const rmCommand = defineCommand({ description: 'Change name to delete (omit to pick interactively on a TTY)', required: false, }, + config: sharedArgs.config, yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { const projectRoot = process.cwd(); + + const [, initErr] = await attempt(() => initState(projectRoot)); + + if (initErr) { + + outputError(args, `Failed to load state: ${initErr.message}`); + process.exit(1); + + } + + const stateManager = getStateManager(projectRoot); + const configName = args.config ?? stateManager.getActiveConfigName(); + const config = configName ? stateManager.getConfig(configName) : null; + + const check = config ? checkConfigPolicy('user', config, 'change:rm') : null; + + if (check && !check.allowed) { + + outputError(args, check.blockedReason ?? `Config "${configName}" cannot delete changes.`); + process.exit(1); + + } + const settingsManager = getSettingsManager(projectRoot); const [, settingsErr] = await attempt(() => settingsManager.load()); @@ -76,29 +109,16 @@ const rmCommand = defineCommand({ } - if (!args.yes) { + const requiresConfirmation = check ? check.requiresConfirmation : true; - if (!process.stdin.isTTY) { + if (requiresConfirmation && !isYesMode(args)) { - outputError( - args, - `Pass --yes to confirm deletion of change: ${changeName}`, - ); - process.exit(1); + const message = check + ? `This is a destructive operation requiring confirmation (${check.confirmationPhrase}). Pass --yes to confirm.` + : `Pass --yes to confirm deletion of change: ${changeName}`; - } - - const confirmed = await p.confirm({ - message: `Delete change "${changeName}"? This cannot be undone.`, - initialValue: false, - }); - - if (p.isCancel(confirmed) || !confirmed) { - - process.stderr.write('Cancelled.\n'); - process.exit(1); - - } + outputError(args, message); + process.exit(1); } diff --git a/src/cli/ci/identity/enroll.ts b/src/cli/ci/identity/enroll.ts index 28444a86..47f7aec4 100644 --- a/src/cli/ci/identity/enroll.ts +++ b/src/cli/ci/identity/enroll.ts @@ -40,7 +40,7 @@ const enrollCommand = defineCommand({ description: 'Email address for the CI identity', required: true, }, - 'public-key': { + publicKey: { type: 'string', description: 'Pre-generated X25519 public key (hex). If omitted, a new keypair is generated.', }, @@ -50,7 +50,7 @@ const enrollCommand = defineCommand({ const name = args.name?.trim(); const email = args.email?.trim(); - const providedPublicKey = args['public-key']?.trim(); + const providedPublicKey = args.publicKey?.trim(); if (!name || !email || !args.config) { diff --git a/src/cli/config/add.ts b/src/cli/config/add.ts index e6f6774d..00bfac44 100644 --- a/src/cli/config/add.ts +++ b/src/cli/config/add.ts @@ -14,8 +14,8 @@ const addCommand = defineCommand({ }, async run() { - process.stdout.write('Interactive only — run: noorm ui\n'); - process.exit(0); + process.stderr.write('Interactive only — run: noorm ui\n'); + process.exit(1); }, }); diff --git a/src/cli/config/edit.ts b/src/cli/config/edit.ts index 2c367932..1b13e22b 100644 --- a/src/cli/config/edit.ts +++ b/src/cli/config/edit.ts @@ -21,8 +21,8 @@ const editCommand = defineCommand({ }, async run() { - process.stdout.write('Interactive only — run: noorm ui\n'); - process.exit(0); + process.stderr.write('Interactive only — run: noorm ui\n'); + process.exit(1); }, }); diff --git a/src/cli/config/export.ts b/src/cli/config/export.ts index 70c6498b..fc49fe14 100644 --- a/src/cli/config/export.ts +++ b/src/cli/config/export.ts @@ -5,7 +5,7 @@ * or to a file when --output is provided. Intended for backup and * cross-machine transfer workflows where the user explicitly owns the data. */ -import { writeFile } from 'node:fs/promises'; +import { chmod, writeFile } from 'node:fs/promises'; import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; @@ -50,7 +50,9 @@ const exportCommand = defineCommand({ if (args.output) { - const [, writeErr] = await attempt(() => writeFile(args.output as string, json, 'utf8')); + const [, writeErr] = await attempt(() => + writeFile(args.output as string, json, { encoding: 'utf8', mode: 0o600 }), + ); if (writeErr) { @@ -59,6 +61,9 @@ const exportCommand = defineCommand({ } + // Ensure permissions are correct (writeFile mode may not work on all platforms) + await attempt(() => chmod(args.output as string, 0o600)); + process.stdout.write(`Config '${args.name}' exported to ${args.output}\n`); } diff --git a/src/cli/config/list.ts b/src/cli/config/list.ts index 1dc2abed..46dfe8cf 100644 --- a/src/cli/config/list.ts +++ b/src/cli/config/list.ts @@ -8,7 +8,7 @@ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; -import { guarded } from '../../core/policy/index.js'; +import { formatAccessTag } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; const listCommand = defineCommand({ @@ -45,7 +45,7 @@ const listCommand = defineCommand({ const lines = configs.map((c) => { const active = c.isActive ? ' (active)' : ''; - const accessTag = guarded(c) ? `user:${c.access.user} mcp:${c.access.mcp === false ? 'off' : c.access.mcp}` : null; + const accessTag = formatAccessTag(c); const flags = [ c.isTest ? 'test' : null, accessTag, diff --git a/src/cli/config/rm.ts b/src/cli/config/rm.ts index 93e8ac02..7df69b4e 100644 --- a/src/cli/config/rm.ts +++ b/src/cli/config/rm.ts @@ -1,34 +1,105 @@ /** - * noorm config rm — directs the user to the TUI. + * noorm config delete command -- headless deletion of a configuration. * - * Removing a config requires confirmation and interactive state management. - * The CLI directs the user to the TUI for now; this may be wired to - * @clack/prompts in a future change. + * Gated by the config's access role for this permission (viewer denied, + * operator and admin require confirmation) and routed through the same + * core deletion path the TUI's ConfigRemoveScreen calls, so ticket 29's + * locked-stage guard is enforced identically here. */ +import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; +import { initState, getStateManager } from '../../core/state/index.js'; +import { getSettingsManager } from '../../core/settings/index.js'; +import { checkConfigPolicy } from '../../core/policy/index.js'; +import { SettingsProvider } from '../../core/config/resolver.js'; +import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; + const rmCommand = defineCommand({ meta: { name: 'rm', - description: 'Remove a configuration (interactive, via TUI)', + description: 'Remove a configuration', }, args: { name: { type: 'positional', description: 'Configuration name to remove', - required: false, + required: true, }, + yes: sharedArgs.yes, + json: sharedArgs.json, }, - async run() { + async run({ args }) { + + const projectRoot = process.cwd(); + + const [, initErr] = await attempt(() => initState(projectRoot)); + + if (initErr) { + + outputError(args, `Failed to load state: ${initErr.message}`); + process.exit(1); + + } + + const stateManager = getStateManager(projectRoot); + const config = stateManager.getConfig(args.name); + + if (!config) { + + outputError(args, `Config "${args.name}" not found.`); + process.exit(1); + + } + + const settingsManager = getSettingsManager(projectRoot); + const [, settingsErr] = await attempt(() => settingsManager.load()); + + if (settingsErr) { + + outputError(args, `Failed to load settings: ${settingsErr.message}`); + process.exit(1); + + } + + const check = checkConfigPolicy('user', config, 'config:rm'); + + if (!check.allowed) { + + outputError(args, check.blockedReason ?? `Config "${args.name}" cannot be removed.`); + process.exit(1); + + } + + if (check.requiresConfirmation && !isYesMode(args)) { + + outputError( + args, + `This is a destructive operation requiring confirmation (${check.confirmationPhrase}). Pass --yes to confirm.`, + ); + process.exit(1); + + } + + const settingsProvider = new SettingsProvider(settingsManager); + const [, deleteErr] = await attempt(() => stateManager.deleteConfig(args.name, settingsProvider)); + + if (deleteErr) { + + outputError(args, deleteErr.message); + process.exit(1); + + } - process.stdout.write('Interactive only — run: noorm ui\n'); + outputResult(args, { name: args.name, deleted: true }, `Deleted: ${args.name}`); process.exit(0); }, }); (rmCommand as typeof rmCommand & { examples: string[] }).examples = [ - 'noorm ui # then navigate to config > rm', + 'noorm config rm staging --yes', + 'noorm config rm staging --yes --json', ]; export default rmCommand; diff --git a/src/cli/config/validate.ts b/src/cli/config/validate.ts index 9fa86ec7..3b4b5fa7 100644 --- a/src/cli/config/validate.ts +++ b/src/cli/config/validate.ts @@ -8,16 +8,9 @@ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; -import { testConnection } from '../../core/connection/factory.js'; +import { validateConfigChecks } from '../../core/config/validate.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; -interface CheckResult { - key: string; - label: string; - status: 'success' | 'error'; - detail: string; -} - const validateCommand = defineCommand({ meta: { name: 'validate', @@ -54,55 +47,7 @@ const validateCommand = defineCommand({ } - const checks: CheckResult[] = []; - let valid = true; - - // Connection test - const connResult = await testConnection(config.connection); - - checks.push({ - key: 'connection', - label: 'Connection', - status: connResult.ok ? 'success' : 'error', - detail: connResult.ok ? 'Connection successful' : (connResult.error ?? 'Connection failed'), - }); - - if (!connResult.ok) valid = false; - - // Required fields - const requiredChecks = [ - { key: 'name', label: 'Name', value: config.name }, - { key: 'database', label: 'Database', value: config.connection.database }, - ]; - - for (const check of requiredChecks) { - - const isSet = Boolean(check.value); - checks.push({ - key: check.key, - label: check.label, - status: isSet ? 'success' : 'error', - detail: isSet ? check.value : 'Not set', - }); - - if (!isSet) valid = false; - - } - - // Host check for non-SQLite - if (config.connection.dialect !== 'sqlite') { - - const hasHost = Boolean(config.connection.host); - checks.push({ - key: 'host', - label: 'Host', - status: hasHost ? 'success' : 'error', - detail: hasHost ? config.connection.host! : 'Not set', - }); - - if (!hasHost) valid = false; - - } + const { checks, valid } = await validateConfigChecks(config); // Output const statusText = valid ? 'VALID' : 'INVALID'; diff --git a/src/cli/db/create.ts b/src/cli/db/create.ts index 293c8b08..513d3426 100644 --- a/src/cli/db/create.ts +++ b/src/cli/db/create.ts @@ -4,10 +4,12 @@ * Creates the database and bootstraps noorm tracking tables. * Uses server-level connection so the target database need not exist yet. */ -import { attempt } from '@logosdx/utils'; +import { attempt, attemptSync } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; +import { getSettingsManager } from '../../core/settings/index.js'; +import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js'; import { checkDbStatus, createDb } from '../../core/db/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; @@ -34,24 +36,41 @@ const createCommand = defineCommand({ } const stateManager = getStateManager(projectRoot); - const configName = args.config ?? stateManager.getActiveConfigName(); + const settingsManager = getSettingsManager(projectRoot); - if (!configName) { + const [, settingsErr] = await attempt(() => settingsManager.load()); - outputError(args, 'No active configuration. Use: noorm config use '); + if (settingsErr) { + + outputError(args, `Failed to load settings: ${settingsErr.message}`); process.exit(1); } - const config = stateManager.getConfig(configName); + // resolveConfig merges DEFAULTS <- stage <- stored <- env <- flags, the + // same path every other connecting command reaches via withContext, so + // NOORM_CONNECTION_* outranks a persisted config here too. + const [config, resolveErr] = attemptSync(() => resolveConfig(stateManager, { + name: args.config, + settings: new SettingsProvider(settingsManager), + })); + + if (resolveErr) { + + outputError(args, resolveErr.message); + process.exit(1); + + } if (!config) { - outputError(args, `Config "${configName}" not found.`); + outputError(args, 'No active configuration. Use: noorm config use '); process.exit(1); } + const configName = config.name; + // Check current status const status = await checkDbStatus(config.connection); @@ -74,7 +93,7 @@ const createCommand = defineCommand({ } // Create database - const result = await createDb(config.connection, configName); + const result = await createDb(config.connection, configName, { precheckedStatus: status }); if (!result.ok) { diff --git a/src/cli/db/drop.ts b/src/cli/db/drop.ts index 733182c0..335b1573 100644 --- a/src/cli/db/drop.ts +++ b/src/cli/db/drop.ts @@ -6,10 +6,12 @@ * access: viewer/operator are denied outright; admin requires --yes to * satisfy the matrix's confirmation requirement. */ -import { attempt } from '@logosdx/utils'; +import { attempt, attemptSync } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; +import { getSettingsManager } from '../../core/settings/index.js'; +import { resolveConfig, SettingsProvider } from '../../core/config/resolver.js'; import { destroyDb } from '../../core/db/index.js'; import { checkConfigPolicy } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; @@ -38,24 +40,41 @@ const dropCommand = defineCommand({ } const stateManager = getStateManager(projectRoot); - const configName = args.config ?? stateManager.getActiveConfigName(); + const settingsManager = getSettingsManager(projectRoot); - if (!configName) { + const [, settingsErr] = await attempt(() => settingsManager.load()); - outputError(args, 'No active configuration. Use: noorm config use '); + if (settingsErr) { + + outputError(args, `Failed to load settings: ${settingsErr.message}`); process.exit(1); } - const config = stateManager.getConfig(configName); + // resolveConfig merges DEFAULTS <- stage <- stored <- env <- flags, the + // same path every other connecting command reaches via withContext, so + // NOORM_CONNECTION_* outranks a persisted config here too. + const [config, resolveErr] = attemptSync(() => resolveConfig(stateManager, { + name: args.config, + settings: new SettingsProvider(settingsManager), + })); + + if (resolveErr) { + + outputError(args, resolveErr.message); + process.exit(1); + + } if (!config) { - outputError(args, `Config "${configName}" not found.`); + outputError(args, 'No active configuration. Use: noorm config use '); process.exit(1); } + const configName = config.name; + const check = checkConfigPolicy('user', config, 'db:destroy'); if (!check.allowed) { diff --git a/src/cli/db/explore.ts b/src/cli/db/explore.ts index 41198f4a..1dc360f4 100644 --- a/src/cli/db/explore.ts +++ b/src/cli/db/explore.ts @@ -30,36 +30,21 @@ const exploreCommand = defineCommand({ const [overview, error] = await withContext({ args, - fn: (ctx, logger) => { - - return ctx.noorm.db.overview().then((res) => { - - if (!args.json) { - - logger.info('Database Overview', { - tables: res.tables, - views: res.views, - functions: res.functions, - procedures: res.procedures, - types: res.types, - }); - - } - - return res; - - }); - - }, + fn: (ctx) => ctx.noorm.db.overview(), }); if (error) process.exit(1); - if (args.json) { - - outputResult(args, overview, ''); + const text = [ + 'Database Overview', + ` Tables: ${overview.tables}`, + ` Views: ${overview.views}`, + ` Functions: ${overview.functions}`, + ` Procedures: ${overview.procedures}`, + ` Types: ${overview.types}`, + ].join('\n'); - } + outputResult(args, overview, text); process.exit(0); diff --git a/src/cli/db/reset.ts b/src/cli/db/reset.ts index 5f80d53d..c045bd62 100644 --- a/src/cli/db/reset.ts +++ b/src/cli/db/reset.ts @@ -6,7 +6,7 @@ */ import { defineCommand } from 'citty'; -import { withContext, outputResult, sharedArgs } from '../_utils.js'; +import { withContext, outputResult, isYesMode, sharedArgs } from '../_utils.js'; const resetCommand = defineCommand({ meta: { @@ -20,7 +20,7 @@ const resetCommand = defineCommand({ }, async run({ args }) { - if (!args.yes) { + if (!isYesMode(args)) { process.stderr.write('Error: This is a destructive operation. Pass --yes to confirm.\n'); process.exit(1); diff --git a/src/cli/db/teardown.ts b/src/cli/db/teardown.ts index a3209635..2f69da39 100644 --- a/src/cli/db/teardown.ts +++ b/src/cli/db/teardown.ts @@ -13,16 +13,19 @@ const teardownCommand = defineCommand({ args: { config: sharedArgs.config, force: sharedArgs.force, + dryRun: sharedArgs.dryRun, yes: sharedArgs.yes, json: sharedArgs.json, }, async run({ args }) { + const dryRun = Boolean(args.dryRun); + const [result, error] = await withContext({ args, fn: (ctx, logger) => { - return ctx.noorm.db.teardown().then((res) => { + return ctx.noorm.db.teardown({ dryRun }).then((res) => { const droppedCount = res.dropped.tables.length + res.dropped.views.length + @@ -31,7 +34,9 @@ const teardownCommand = defineCommand({ if (!args.json) { - logger.info(`Dropped ${droppedCount} objects`); + const verb = dryRun ? 'Would drop' : 'Dropped'; + + logger.info(`${verb} ${droppedCount} objects`); logger.info(` Tables: ${res.dropped.tables.length}`); logger.info(` Views: ${res.dropped.views.length}`); logger.info(` Functions: ${res.dropped.functions.length}`); @@ -58,6 +63,7 @@ const teardownCommand = defineCommand({ outputResult(args, { dropped: result.dropped, count: droppedCount, + ...(dryRun ? { dryRun: true } : {}), }, ''); } @@ -70,6 +76,7 @@ const teardownCommand = defineCommand({ (teardownCommand as typeof teardownCommand & { examples: string[] }).examples = [ 'noorm db teardown', 'noorm db teardown --yes', + 'noorm db teardown --dry-run', 'noorm db teardown --force --yes', 'noorm db teardown --json --yes', ]; diff --git a/src/cli/db/transfer.ts b/src/cli/db/transfer.ts index a7d081de..7ebaf324 100644 --- a/src/cli/db/transfer.ts +++ b/src/cli/db/transfer.ts @@ -3,10 +3,12 @@ * * Transfers data to another DB config, or exports/imports .dt files. */ +import * as p from '@clack/prompts'; import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { resolveExportExtension, resolveExportPath, ensureExportDirectory } from '../../core/dt/index.js'; +import { MIN_PASSPHRASE_LENGTH } from '../../core/dt/crypto.js'; import { getStateManager } from '../../core/state/index.js'; import type { TransferOptions, ConflictStrategy } from '../../core/transfer/index.js'; import { withContext, outputResult, outputError, sharedArgs, type CliArgs } from '../_utils.js'; @@ -22,11 +24,11 @@ type TransferArgs = CliArgs & { compress?: boolean; passphrase?: string; tables?: string; - 'on-conflict'?: string; - 'batch-size'?: string; + onConflict?: string; + batchSize?: string; truncate?: boolean; - 'no-fk'?: boolean; - 'no-identity'?: boolean; + noFk?: boolean; + noIdentity?: boolean; dryRun?: boolean; }; @@ -44,6 +46,96 @@ function isConflictStrategy(value: unknown): value is ValidStrategy { } +// --------------------------------------------------------------------------- +// Passphrase resolution — flag, masked prompt, or CI escape hatch +// --------------------------------------------------------------------------- + +/** + * Resolve the passphrase for a .dtzx export, enforcing MIN_PASSPHRASE_LENGTH. + * + * A bare `--passphrase` flag leaks via ps/shell history, so an interactive + * TTY session (and no `--json`) prompts via a masked @clack/prompts input + * instead. Non-interactive callers without the flag get an immediate, + * actionable exit rather than hanging on a stdin read — the documented CI + * escape hatch is still the flag. + */ +async function resolveExportPassphrase(passphrase: string | undefined, args: TransferArgs): Promise { + + if (passphrase) { + + if (passphrase.length < MIN_PASSPHRASE_LENGTH) { + + outputError(args, `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`); + process.exit(1); + + } + + return passphrase; + + } + + if (!process.stdin.isTTY || args.json) { + + outputError(args, '--passphrase required for .dtzx encrypted export (non-interactive session; pass --passphrase or run interactively for a masked prompt)'); + process.exit(1); + + } + + const result = await p.password({ + message: 'Passphrase for .dtzx encryption', + validate: (value) => ( + value && value.length >= MIN_PASSPHRASE_LENGTH + ? undefined + : `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters` + ), + }); + + if (p.isCancel(result)) { + + p.cancel('Cancelled.'); + process.exit(0); + + } + + return result; + +} + +/** + * Resolve the passphrase for a .dtzx import. + * + * No length floor — legacy archives may have been encrypted with a + * passphrase shorter than MIN_PASSPHRASE_LENGTH, and a wrong passphrase + * already fails via GCM auth tag verification. Same masked-prompt / + * CI-escape-hatch idiom as the export path. + */ +async function resolveImportPassphrase(passphrase: string | undefined, args: TransferArgs): Promise { + + if (passphrase) return passphrase; + + if (!process.stdin.isTTY || args.json) { + + outputError(args, '--passphrase required for .dtzx encrypted import (non-interactive session; pass --passphrase or run interactively for a masked prompt)'); + process.exit(1); + + } + + const result = await p.password({ + message: 'Passphrase for .dtzx decryption', + validate: (value) => (value ? undefined : 'Passphrase is required'), + }); + + if (p.isCancel(result)) { + + p.cancel('Cancelled.'); + process.exit(0); + + } + + return result; + +} + const transferCommand = defineCommand({ meta: { name: 'transfer', @@ -72,17 +164,17 @@ const transferCommand = defineCommand({ }, passphrase: { type: 'string', - description: 'Passphrase for .dtzx encryption/decryption (implies compression)', + description: `Passphrase for .dtzx (implies compression, min ${MIN_PASSPHRASE_LENGTH} chars on export). Omit on TTY for masked prompt.`, }, tables: { type: 'string', description: 'Comma-separated list of tables (default: all)', }, - 'on-conflict': { + onConflict: { type: 'string', description: 'Conflict strategy: fail, skip, update, replace (default: fail)', }, - 'batch-size': { + batchSize: { type: 'string', description: 'Rows per batch for cross-server transfers (default: 1000)', }, @@ -90,11 +182,11 @@ const transferCommand = defineCommand({ type: 'boolean', description: 'Truncate destination tables before transfer', }, - 'no-fk': { + noFk: { type: 'boolean', description: 'Do not disable foreign key checks', }, - 'no-identity': { + noIdentity: { type: 'boolean', description: 'Do not preserve identity/auto-increment values', }, @@ -104,7 +196,7 @@ const transferCommand = defineCommand({ const destConfigName = args.to; const exportPath = args.export; const importPath = args.import; - const passphrase = args.passphrase; + let passphrase = args.passphrase; const compress = args.compress === true; if (compress && !exportPath) { @@ -130,22 +222,20 @@ const transferCommand = defineCommand({ } - if (exportPath?.endsWith('.dtzx') && !passphrase) { + if (exportPath?.endsWith('.dtzx')) { - outputError(args, '--passphrase required for .dtzx encrypted export'); - process.exit(1); + passphrase = await resolveExportPassphrase(passphrase, args); } - if (importPath?.endsWith('.dtzx') && !passphrase) { + if (importPath?.endsWith('.dtzx')) { - outputError(args, '--passphrase required for .dtzx encrypted import'); - process.exit(1); + passphrase = await resolveImportPassphrase(passphrase, args); } // Validate --on-conflict once before mode dispatch - const rawConflict = args['on-conflict'] ?? 'fail'; + const rawConflict = args.onConflict ?? 'fail'; if (!isConflictStrategy(rawConflict)) { @@ -157,7 +247,7 @@ const transferCommand = defineCommand({ const onConflict: ConflictStrategy = rawConflict; const tableList = args.tables ? String(args.tables).split(',').map((t) => t.trim()) : undefined; - const batchSize = args['batch-size'] ? parseInt(String(args['batch-size']), 10) : undefined; + const batchSize = args.batchSize ? parseInt(String(args.batchSize), 10) : undefined; if (exportPath) { @@ -212,8 +302,8 @@ const transferCommand = defineCommand({ tables: tableList, onConflict, batchSize, - disableForeignKeys: args['no-fk'] !== true, - preserveIdentity: args['no-identity'] !== true, + disableForeignKeys: args.noFk !== true, + preserveIdentity: args.noIdentity !== true, truncateFirst: args.truncate === true, dryRun: args.dryRun === true, }; @@ -239,14 +329,7 @@ const transferCommand = defineCommand({ if (planError) process.exit(1); - const [planResult, planErr] = plan; - - if (planErr) { - - outputError(args, planErr.message); - process.exit(1); - - } + const planResult = plan; if (args.json) { @@ -322,14 +405,7 @@ const transferCommand = defineCommand({ if (transferError) process.exit(1); - const [result, transferErr] = transferResult; - - if (transferErr) { - - outputError(args, transferErr.message); - process.exit(1); - - } + const result = transferResult; if (args.json) { @@ -339,6 +415,7 @@ const transferCommand = defineCommand({ tables: result?.tables, totalRows: result?.totalRows, durationMs: result?.durationMs, + fkChecksRestored: result?.fkChecksRestored, }, ''); } @@ -352,6 +429,15 @@ const transferCommand = defineCommand({ process.stdout.write(` Tables: ${successCount} success, ${failedCount} failed\n`); process.stdout.write(` Duration: ${((result?.durationMs ?? 0) / 1000).toFixed(2)}s\n`); + if (result?.fkChecksRestored === false) { + + process.stderr.write( + 'WARNING: foreign key checks were NOT restored on the destination — ' + + 'referential integrity may be disabled. Re-enable manually.\n', + ); + + } + const failures = result?.tables.filter((t) => t.status === 'failed') ?? []; for (const f of failures) { @@ -375,7 +461,8 @@ const transferCommand = defineCommand({ 'noorm db transfer --export ./backup/ --compress', 'noorm db transfer --export ./backup/users.dt --tables users', 'noorm db transfer --import ./backup/users.dt', - 'noorm db transfer --import ./backup/users.dtzx --passphrase mySecret', + 'noorm db transfer --import ./backup/users.dtzx --passphrase correct-horse-battery', + 'noorm db transfer --export ./backup/users.dtzx # omits --passphrase, prompts interactively', ]; export default transferCommand; @@ -427,17 +514,11 @@ async function handleExport(opts: { ext, }); - const [result, err] = await ctx.noorm.dt.exportTable(tableName, filepath, { + const result = await ctx.noorm.dt.exportTable(tableName, filepath, { passphrase, batchSize, }); - if (err) { - - throw err; - - } - totalRows += result?.rowsWritten ?? 0; totalBytes += result?.bytesWritten ?? 0; tableResults.push({ @@ -542,19 +623,13 @@ async function handleImport(opts: { } - const [result, err] = await ctx.noorm.dt.importFile(importPath, { + const result = await ctx.noorm.dt.importFile(importPath, { passphrase, batchSize, onConflict, truncate, }); - if (err) { - - throw err; - - } - return result; }, diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d13acbd..1e3901ae 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,25 +12,6 @@ import { resolve } from 'node:path'; import tab from '@bomb.sh/tab/citty'; import { defineCommand, runMain, renderUsage, type CommandDef } from 'citty'; -import change from './change/index.js'; -import ci from './ci/index.js'; -import config from './config/index.js'; -import db from './db/index.js'; -import dev from './dev/index.js'; -import identity from './identity/index.js'; -import info from './info.js'; -import init from './init.js'; -import lock from './lock/index.js'; -import mcp from './mcp/index.js'; -import run from './run/index.js'; -import secret from './secret/index.js'; -import settings from './settings/index.js'; -import sql from './sql/index.js'; -import ui from './ui.js'; -import update from './update.js'; -import vault from './vault/index.js'; -import version from './version.js'; - import { initProjectContext, setOriginalCwd } from '../core/project.js'; import { loadIdentityFromEnv } from '../core/identity/env.js'; import { setKeyOverride, setIdentityOverride } from '../core/identity/storage.js'; @@ -41,31 +22,51 @@ import { setKeyOverride, setIdentityOverride } from '../core/identity/storage.js */ export type CommandWithExamples = CommandDef & { examples?: string[] }; +/** + * Zero-cost stand-in for the `complete` subcommand that `@bomb.sh/tab` + * would otherwise register by walking the entire `subCommands` tree + * (forcing every lazy thunk below to resolve on every invocation, the + * exact cost this file exists to avoid). Same meta the adapter itself + * uses, so `noorm --help` / bare `noorm` list `complete` identically to + * before. Overwritten in place by the real `tab(main)` call below when + * the invocation actually is a completion request. + */ +const completeStub = defineCommand({ + meta: { + name: 'complete', + description: 'Generate shell completion scripts', + }, + run() {}, +}); + +declare const __CLI_VERSION__: string; + const main = defineCommand({ meta: { name: 'noorm', - version: '0.0.0', // replaced at bundle time via --define __CLI_VERSION__ + version: typeof __CLI_VERSION__ !== 'undefined' ? __CLI_VERSION__ : '0.0.0-dev', description: 'Database schema & changeset manager. Global: -c, --cwd runs the subcommand in (must precede the subcommand, like git -C).', }, subCommands: { - change, - ci, - config, - db, - dev, - identity, - info, - init, - lock, - mcp, - run, - secret, - settings, - sql, - ui, - update, - vault, - version, + change: () => import('./change/index.js').then((m) => m.default), + ci: () => import('./ci/index.js').then((m) => m.default), + config: () => import('./config/index.js').then((m) => m.default), + db: () => import('./db/index.js').then((m) => m.default), + dev: () => import('./dev/index.js').then((m) => m.default), + identity: () => import('./identity/index.js').then((m) => m.default), + info: () => import('./info.js').then((m) => m.default), + init: () => import('./init.js').then((m) => m.default), + lock: () => import('./lock/index.js').then((m) => m.default), + mcp: () => import('./mcp/index.js').then((m) => m.default), + run: () => import('./run/index.js').then((m) => m.default), + secret: () => import('./secret/index.js').then((m) => m.default), + settings: () => import('./settings/index.js').then((m) => m.default), + sql: () => import('./sql/index.js').then((m) => m.default), + ui: () => import('./ui.js').then((m) => m.default), + update: () => import('./update.js').then((m) => m.default), + vault: () => import('./vault/index.js').then((m) => m.default), + version: () => import('./version.js').then((m) => m.default), + complete: completeStub, }, }); @@ -73,11 +74,14 @@ const main = defineCommand({ * Walk argv one positional at a time to find the target command. * * Stops at the first flag (token starting with `-`) or unknown subcommand. - * Returns the resolved command definition object. + * Returns the resolved command definition plus the ordered chain of parent + * command names (root to immediate parent) walked to reach it, so callers + * can rebuild the full USAGE breadcrumb instead of citty's single-level one. */ -async function resolveCommand(rootDef: CommandDef, argv: string[]): Promise { +async function resolveCommand(rootDef: CommandDef, argv: string[]): Promise<{ cmd: CommandDef; parentNames: string[] }> { let current: unknown = rootDef; + const parentNames: string[] = []; for (const arg of argv) { @@ -92,23 +96,58 @@ async function resolveCommand(rootDef: CommandDef, argv: string[]): Promise Promise)() : current as CommandDef; + const cmd = typeof current === 'function' ? await (current as () => Promise)() : current as CommandDef; + + return { cmd, parentNames }; } /** - * Strip `-c ` / `--cwd ` / `--cwd=` that appear before - * the first subcommand. Mirrors `git -C ` semantics: the flag is only - * recognized as global when it precedes the subcommand, so per-command - * aliases (like `--config -c`) keep working after the subcommand token. + * `--help`/`-h`/`--version`/`-v` are already recognized at any position: + * `entry()` scans for `--help`/`-h` directly (below) and citty's own + * `runMain` handles a bare `--version`/`-v` before ever reaching + * `runCommand`. They must pass through `extractGlobalCwd` untouched + * rather than trip its "unrecognized flag" error. + */ +const BUILTIN_ROOT_FLAGS = new Set(['--help', '-h', '--version', '-v']); + +/** + * Strip `-c ` / `--cwd ` / `--cwd=` from the argv that + * precedes the subcommand name — the sole flag with a genuine root-level + * meaning. Every other flag belongs on the command that uses it and is + * rejected outright if it appears before the subcommand, for two reasons: + * + * 1. `--cwd` is consumed before dispatch: it sets the working directory + * everything else (project discovery, config resolution) resolves + * against, so it is the CLI's own flag rather than any subcommand's. + * 2. `-c` already means `--config` after the subcommand (see the + * "`--config` / `-c` overload" note in docs/cli/flags.md). Hoisting + * any other flag to the root doesn't hit that collision, but it does + * reintroduce the asymmetry this function used to have with + * `--config`/`--force` — which were never hoisted — for no reason + * beyond "why not". A flag goes on the command that uses it. * - * Returns the resolved cwd (or null) plus argv with global flags removed. + * citty forwards only `rawArgs.slice(subCommandArgIndex + 1)` to the + * resolved subcommand (`node_modules/citty/dist/index.mjs:217`), so any + * flag before that index is silently dropped before a leaf command's own + * arg parser ever sees it. Mirrors `git -C ` semantics: `--cwd` is + * only recognized as global when it precedes the subcommand, so a + * per-command flag of the same short name (`--config -c`) keeps working + * untouched once seen after it. + * + * Any other flag-looking token seen before the subcommand can't be + * honoured for the same reason an unrecognised flag couldn't — silently + * forwarding it would be the defect this function exists to avoid — so + * it is reported as an error instead of being dropped. */ -function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[] } { +function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[]; error: null } | { cwd: null; rest: null; error: string } { const rest: string[] = []; let cwd: string | null = null; @@ -119,7 +158,7 @@ function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[] const arg = argv[i]!; - if (seenSubcommand) { + if (seenSubcommand || BUILTIN_ROOT_FLAGS.has(arg)) { rest.push(arg); i++; @@ -153,12 +192,17 @@ function extractGlobalCwd(argv: string[]): { cwd: string | null; rest: string[] } - rest.push(arg); - i++; + return { + cwd: null, + rest: null, + error: `Unrecognized flag '${arg}' before the subcommand — noorm can't forward it there. ` + + 'The only root-level flag is -c/--cwd ; every other flag goes on the command that uses it. ' + + `Move '${arg}' after the subcommand instead, e.g. noorm ... ${arg}.`, + }; } - return { cwd, rest }; + return { cwd, rest, error: null }; } @@ -223,10 +267,18 @@ function rewriteBareSqlArgv(argv: string[]): string[] { /** * Print citty's usage followed by a custom EXAMPLES block from * the command's top-level `examples` property (if present). + * + * citty's renderUsage only concatenates one level (`parent.meta.name + ' ' + + * cmd.meta.name`), so a synthetic parent joining the full breadcrumb is + * built here rather than always passing the absolute root command. */ -async function printHelpWithExamples(cmd: CommandWithExamples, rootDef: CommandDef): Promise { +async function printHelpWithExamples(cmd: CommandWithExamples, parentNames: string[]): Promise { - const usage = await renderUsage(cmd, rootDef); + const parent: CommandDef | undefined = parentNames.length > 0 + ? { meta: { name: parentNames.join(' ') } } + : undefined; + + const usage = await renderUsage(cmd, parent); process.stdout.write(usage + '\n'); if (cmd.examples && cmd.examples.length > 0) { @@ -253,7 +305,16 @@ async function printHelpWithExamples(cmd: CommandWithExamples, rootDef: CommandD async function entry(): Promise { const rawArgv = process.argv.slice(2); - const { cwd: explicitCwd, rest: cleanArgv } = extractGlobalCwd(rawArgv); + const parsedCwd = extractGlobalCwd(rawArgv); + + if (parsedCwd.error !== null) { + + process.stderr.write(`Error: ${parsedCwd.error}\n`); + process.exit(1); + + } + + const { cwd: explicitCwd, rest: cleanArgv } = parsedCwd; if (explicitCwd !== null) { @@ -268,17 +329,20 @@ async function entry(): Promise { setOriginalCwd(process.cwd()); process.chdir(resolvedCwd); - process.argv = [process.argv[0]!, process.argv[1]!, ...rewriteBareSqlArgv(cleanArgv)]; } else { initProjectContext(); - const rewritten = rewriteBareSqlArgv(process.argv.slice(2)); - process.argv = [process.argv[0]!, process.argv[1]!, ...rewritten]; } + // Both branches funnel through the same reassignment. `cleanArgv` is + // rawArgv with only the -c/--cwd tokens (if any) removed, so this is + // a no-op rewrite when --cwd was absent — using it unconditionally + // keeps one code path instead of two that must stay in sync. + process.argv = [process.argv[0]!, process.argv[1]!, ...rewriteBareSqlArgv(cleanArgv)]; + // Install env-based identity overrides at process startup so that // every downstream loadPrivateKey() / loadIdentityMetadata() call // returns the env values without touching ~/.noorm/. Validated by @@ -292,16 +356,24 @@ async function entry(): Promise { } - // Register shell completion as the `complete` subcommand on main. - // The adapter walks main.subCommands to generate completions. - await tab(main); - const rawArgs = process.argv.slice(2); + // Only walk the entire subCommands tree (resolving every lazy thunk + // above) when the invocation is actually a completion request. The + // always-present completeStub above keeps `complete` listed in every + // other help/usage output at zero resolution cost; tab(main) replaces + // it in place (same main.subCommands object reference) before runMain + // dispatches to it. + if (rawArgs[0] === 'complete') { + + await tab(main); + + } + if (rawArgs.includes('--help') || rawArgs.includes('-h')) { - const cmd = await resolveCommand(main as CommandDef, rawArgs); - await printHelpWithExamples(cmd as CommandWithExamples, main as CommandDef); + const { cmd, parentNames } = await resolveCommand(main as CommandDef, rawArgs); + await printHelpWithExamples(cmd as CommandWithExamples, parentNames); process.exit(0); } diff --git a/src/cli/lock/status.ts b/src/cli/lock/status.ts index 2ae89f91..7144180c 100644 --- a/src/cli/lock/status.ts +++ b/src/cli/lock/status.ts @@ -18,53 +18,27 @@ const statusCommand = defineCommand({ const [status, error] = await withContext({ args, - fn: (ctx, logger) => { - - return ctx.noorm.lock.status().then((res) => { - - if (!args.json) { - - if (res.isLocked && res.lock) { - - logger.info(`Locked by ${res.lock.lockedBy}`, { - since: res.lock.lockedAt.toISOString(), - expires: res.lock.expiresAt.toISOString(), - }); - - } - else { - - logger.info('No active lock'); - - } - - } - - return res; - - }); - - }, + fn: (ctx) => ctx.noorm.lock.status(), }); if (error) process.exit(1); - if (args.json) { - - const output = status.isLocked && status.lock - ? { - isLocked: true, - lock: { - lockedBy: status.lock.lockedBy, - lockedAt: status.lock.lockedAt.toISOString(), - expiresAt: status.lock.expiresAt.toISOString(), - }, - } - : { isLocked: false, lock: null }; - - outputResult(args, output, ''); - - } + const output = status.isLocked && status.lock + ? { + isLocked: true, + lock: { + lockedBy: status.lock.lockedBy, + lockedAt: status.lock.lockedAt.toISOString(), + expiresAt: status.lock.expiresAt.toISOString(), + }, + } + : { isLocked: false, lock: null }; + + const text = status.isLocked && status.lock + ? `Locked by ${status.lock.lockedBy} (since ${status.lock.lockedAt.toISOString()}, expires ${status.lock.expiresAt.toISOString()})` + : 'No active lock'; + + outputResult(args, output, text); process.exit(0); diff --git a/src/cli/run/_render-secrets.ts b/src/cli/run/_render-secrets.ts new file mode 100644 index 00000000..df1b9fc8 --- /dev/null +++ b/src/cli/run/_render-secrets.ts @@ -0,0 +1,86 @@ +/** + * Shared "resolve $.secrets for an offline render" helper for + * `run preview` and `run inspect`. + * + * Both commands are documented as offline — they render a template + * without executing it, and must keep working with no database + * reachable. But the vault tier can only be read through a connection, + * and a preview that shows different secrets than `run file`/`run build` + * would actually apply is the same class of silent divergence CP6 exists + * to remove. + * + * The probe below connects with retry/backoff disabled + * (`ConnectionRetryOptions`, `src/core/connection/factory.ts`), so an + * unreachable database degrades to the local tiers in well under a + * second instead of the ~6-7s the default 3-attempt policy adds. When + * the probe fails, `vaultProbeFailed` tells the caller to say so — + * silently showing only local secrets recreates exactly the class of + * bug this branch fixes. + */ +import { attempt } from '@logosdx/utils'; + +import { createContext } from '../../sdk/index.js'; +import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js'; +import type { StateManager } from '../../core/state/index.js'; +import type { NoormDatabase } from '../../core/shared/index.js'; + +/** One attempt, no wait — a best-effort probe must not inherit the + * connecting-command retry/backoff policy. */ +const PROBE_RETRY_OPTIONS = { retries: 1, delay: 0 }; + +/** Shared wording so the text and `--json` shapes never drift apart. */ +export const RENDER_SECRETS_NOTICE = + 'Vault-backed secrets were not resolved (no reachable database) — showing local-only secrets.'; + +export interface RenderSecretsResult { + secrets: Record; + + /** + * True when a config was active but the vault tier could not be + * reached (no connection, no vault access). False when there was no + * config to probe against in the first place — nothing was skipped, + * so there's nothing to disclose. + */ + vaultProbeFailed: boolean; +} + +/** + * Resolve `$.secrets` through all three tiers for a preview/inspect + * render, without blocking on an unreachable database. + * + * @example + * ```typescript + * const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, 'prod'); + * ``` + */ +export async function resolveRenderSecrets( + stateManager: StateManager, + configName: string | null | undefined, +): Promise { + + if (!configName) return { secrets: {}, vaultProbeFailed: false }; + + const [vault, connErr] = await attempt(() => createContext({ config: configName })); + + if (connErr) { + + return { secrets: await buildSecretsContext(stateManager, configName), vaultProbeFailed: true }; + + } + + const [, connectErr] = await attempt(() => vault.connect(PROBE_RETRY_OPTIONS)); + + if (connectErr) { + + return { secrets: await buildSecretsContext(stateManager, configName), vaultProbeFailed: true }; + + } + + const vaultKey = await resolveVaultKey(vault.kysely, vault.dialect); + const secrets = await buildSecretsContext(stateManager, configName, vault.kysely, vaultKey, vault.dialect); + + await attempt(() => vault.disconnect()); + + return { secrets, vaultProbeFailed: false }; + +} diff --git a/src/cli/run/build.ts b/src/cli/run/build.ts index ca3b3d0a..10cfeda9 100644 --- a/src/cli/run/build.ts +++ b/src/cli/run/build.ts @@ -13,18 +13,27 @@ const buildCommand = defineCommand({ args: { config: sharedArgs.config, force: sharedArgs.force, + dryRun: sharedArgs.dryRun, json: sharedArgs.json, }, async run({ args }) { + const dryRun = Boolean(args.dryRun); + const [result, error] = await withContext({ args, fn: (ctx, logger) => { - return ctx.noorm.run.build({ force: args.force }).then((res) => { + return ctx.noorm.run.build({ force: args.force, dryRun }).then((res) => { if (!args.json) { + if (dryRun) { + + logger.info('Dry run: rendering files to tmp/ (no DB writes)'); + + } + for (const file of res.files) { if (file.status === 'failed') { @@ -38,6 +47,11 @@ const buildCommand = defineCommand({ logger.info(`${file.filepath} (skipped: ${file.skipReason})`); } + else if (dryRun) { + + logger.info(`${file.filepath} (${file.status}, dry-run)`); + + } } @@ -47,16 +61,19 @@ const buildCommand = defineCommand({ filesSkipped: res.filesSkipped, filesFailed: res.filesFailed, durationMs: res.durationMs, + ...(dryRun ? { dryRun: true } : {}), }; + const headerPrefix = dryRun ? 'Build (dry-run)' : 'Build'; + if (res.status === 'success') { - logger.info('Build completed', summary); + logger.info(`${headerPrefix} completed`, summary); } else { - logger.error('Build completed', summary); + logger.error(`${headerPrefix} completed`, summary); } @@ -73,7 +90,11 @@ const buildCommand = defineCommand({ if (args.json) { - outputResult(args, result, ''); + // Annotate the JSON payload with the dry-run flag, matching + // `change ff --dry-run`, so CI can detect a no-write result + // without parsing human output. + const payload = dryRun ? { ...result, dryRun: true } : result; + outputResult(args, payload, ''); } @@ -85,6 +106,7 @@ const buildCommand = defineCommand({ (buildCommand as typeof buildCommand & { examples: string[] }).examples = [ 'noorm run build', 'noorm run build --force', + 'noorm run build --dry-run', 'noorm run build --json', ]; diff --git a/src/cli/run/inspect.ts b/src/cli/run/inspect.ts index 68e6390c..75cc7a39 100644 --- a/src/cli/run/inspect.ts +++ b/src/cli/run/inspect.ts @@ -19,6 +19,7 @@ import { outputError, outputResult, sharedArgs } from '../_utils.js'; import { buildContext } from '../../core/template/context.js'; import { loadHelpers } from '../../core/template/helpers.js'; import { getStateManager } from '../../core/state/index.js'; +import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js'; // Built-in helper names (always present in context) const BUILTIN_HELPERS = new Set(['quote', 'escape', 'uuid', 'now', 'json', 'include']); @@ -89,6 +90,7 @@ const inspectCommand = defineCommand({ const activeConfigName = args.config ?? stateManager.getActiveConfigName(); const activeConfig = activeConfigName ? stateManager.getConfig(activeConfigName) : undefined; + const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, activeConfigName); // Load context and helpers in parallel const [results, err] = await attempt(async () => { @@ -97,7 +99,7 @@ const inspectCommand = defineCommand({ buildContext(fullPath, { projectRoot, config: activeConfig as unknown as Record, - secrets: activeConfigName ? stateManager.getAllSecrets(activeConfigName) : {}, + secrets, globalSecrets: stateManager.getAllGlobalSecrets(), }), loadHelpers(templateDir, projectRoot), @@ -160,9 +162,10 @@ const inspectCommand = defineCommand({ })), builtins, configKeys: activeConfig ? Object.keys(activeConfig) : [], - secretCount: activeConfigName ? Object.keys(stateManager.getAllSecrets(activeConfigName)).length : 0, + secretCount: Object.keys(secrets).length, globalSecretCount: Object.keys(stateManager.getAllGlobalSecrets()).length, }, + ...(vaultProbeFailed ? { vaultProbeFailed: true, notice: RENDER_SECRETS_NOTICE } : {}), }; const textLines = [ @@ -217,9 +220,14 @@ const inspectCommand = defineCommand({ if (activeConfigName) { - const secretCount = Object.keys(stateManager.getAllSecrets(activeConfigName)).length; const globalCount = Object.keys(stateManager.getAllGlobalSecrets()).length; - textLines.push(`Secrets: ${secretCount} config, ${globalCount} global`); + textLines.push(`Secrets: ${Object.keys(secrets).length} resolved, ${globalCount} global`); + + if (vaultProbeFailed) { + + textLines.push(RENDER_SECRETS_NOTICE); + + } } diff --git a/src/cli/run/preview.ts b/src/cli/run/preview.ts index 4f149e14..41d96f2e 100644 --- a/src/cli/run/preview.ts +++ b/src/cli/run/preview.ts @@ -19,6 +19,7 @@ import { attempt } from '@logosdx/utils'; import { outputError, outputResult, sharedArgs } from '../_utils.js'; import { processFile } from '../../core/template/engine.js'; import { getStateManager } from '../../core/state/index.js'; +import { resolveRenderSecrets, RENDER_SECRETS_NOTICE } from './_render-secrets.js'; const previewCommand = defineCommand({ meta: { @@ -52,12 +53,13 @@ const previewCommand = defineCommand({ const activeConfigName = args.config ?? stateManager.getActiveConfigName(); const activeConfig = activeConfigName ? stateManager.getConfig(activeConfigName) : undefined; + const { secrets, vaultProbeFailed } = await resolveRenderSecrets(stateManager, activeConfigName); // Render the template const [result, err] = await attempt(() => processFile(fullPath, { projectRoot, config: activeConfig as unknown as Record, - secrets: activeConfigName ? stateManager.getAllSecrets(activeConfigName) : {}, + secrets, globalSecrets: stateManager.getAllGlobalSecrets(), })); @@ -74,11 +76,21 @@ const previewCommand = defineCommand({ filepath: args.path, sql: result.sql, durationMs: result.durationMs, + ...(vaultProbeFailed ? { vaultProbeFailed: true, notice: RENDER_SECRETS_NOTICE } : {}), }, ''); } else { + // Diagnostic, not result — stdout is reserved for the raw SQL + // below so `noorm run preview x.sql.tmpl > rendered.sql` stays + // pipeable. + if (vaultProbeFailed) { + + process.stderr.write(RENDER_SECRETS_NOTICE + '\n'); + + } + // Output raw SQL so it can be piped to a file or other tool process.stdout.write(result.sql); diff --git a/src/cli/sql/repl.ts b/src/cli/sql/repl.ts index 08b436bd..b3ab664b 100644 --- a/src/cli/sql/repl.ts +++ b/src/cli/sql/repl.ts @@ -7,13 +7,15 @@ * * If --config is provided, the active config is switched before the TUI * launches so the REPL starts against the intended database. + * + * Ink and React are loaded lazily inside run() (alongside the TUI app) + * so that resolving this module's meta never pays their import cost — + * only actually launching the REPL does. */ import { Writable } from 'node:stream'; import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; -import { render } from 'ink'; -import React from 'react'; import { observer } from '../../core/observer.js'; import { enableAutoLoggerInit } from '../../core/logger/init.js'; @@ -91,7 +93,11 @@ const replCommand = defineCommand({ } // === Launch TUI at db/sql === - const { App } = await import('../../tui/app.js'); + const [{ render }, { default: React }, { App }] = await Promise.all([ + import('ink'), + import('react'), + import('../../tui/app.js'), + ]); enableAutoLoggerInit(process.cwd(), { console: nullStream, diff --git a/src/cli/ui.ts b/src/cli/ui.ts index a8ef5d61..6436501c 100644 --- a/src/cli/ui.ts +++ b/src/cli/ui.ts @@ -3,12 +3,15 @@ * * This is the only CLI subcommand that renders the Ink/React TUI. * The TUI always starts at the home route; deep-linking is not supported. + * + * Ink, React, and the TUI app are all loaded lazily inside run() so that + * resolving this module's meta (e.g. for `noorm --help`'s root command + * listing, or `noorm ui --help`) never pays their import cost — only + * actually launching the UI does. */ import { Writable } from 'node:stream'; import { defineCommand } from 'citty'; -import { render } from 'ink'; -import React from 'react'; import { observer } from '../core/observer.js'; import { enableAutoLoggerInit } from '../core/logger/init.js'; @@ -28,9 +31,11 @@ const uiCommand = defineCommand({ }, async run() { - // Lazy import the TUI app so citty --help on other commands - // doesn't pay the cost of loading Ink + all screens. - const { App } = await import('../tui/app.js'); + const [{ render }, { default: React }, { App }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/app.js'), + ]); enableAutoLoggerInit(process.cwd(), { console: nullStream, diff --git a/src/cli/update.ts b/src/cli/update.ts index e1bb30d9..e2338792 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -10,7 +10,7 @@ import { attempt } from '@logosdx/utils'; import { observer } from '../core/observer.js'; import { checkForUpdate, getCurrentVersion } from '../core/update/checker.js'; import { installUpdate } from '../core/update/updater.js'; -import { outputError, outputResult, sharedArgs } from './_utils.js'; +import { isInsecureMode, outputError, outputResult, sharedArgs } from './_utils.js'; /** Render a byte count as MB with one decimal (e.g. 41.2). */ function toMb(bytes: number): string { @@ -26,6 +26,10 @@ const updateCommand = defineCommand({ }, args: { json: sharedArgs.json, + insecure: { + type: 'boolean', + description: 'Skip checksum verification when checksums.txt is unreachable (never bypasses a confirmed mismatch)', + }, }, async run({ args }) { @@ -115,10 +119,18 @@ const updateCommand = defineCommand({ } + const insecure = isInsecureMode(args); + + if (insecure) { + + process.stderr.write('Warning: checksum verification will be skipped if checksums.txt is unreachable (--insecure).\n'); + + } + observer.on('update:progress', onProgress); observer.on('update:retry', onRetry); - const [result, installErr] = await attempt(() => installUpdate(checkResult.latestVersion)); + const [result, installErr] = await attempt(() => installUpdate(checkResult.latestVersion, { insecure })); observer.off('update:progress', onProgress); observer.off('update:retry', onRetry); @@ -184,6 +196,7 @@ const updateCommand = defineCommand({ (updateCommand as typeof updateCommand & { examples: string[] }).examples = [ 'noorm update', 'noorm update --json', + 'noorm update --insecure', ]; export default updateCommand; diff --git a/src/core/change/executor.ts b/src/core/change/executor.ts index 646d35ce..ec36e662 100644 --- a/src/core/change/executor.ts +++ b/src/core/change/executor.ts @@ -22,6 +22,7 @@ */ import path from 'node:path'; import { readFile, writeFile as fsWriteFile, mkdir } from 'node:fs/promises'; +import type { Kysely } from 'kysely'; import { sql } from 'kysely'; import { attempt, attemptSync } from '@logosdx/utils'; @@ -31,8 +32,10 @@ import { formatIdentity } from '../identity/resolver.js'; import { processFile, isTemplate } from '../template/index.js'; import { assertPolicy } from '../policy/index.js'; import type { Permission } from '../policy/index.js'; +import type { Dialect } from '../connection/types.js'; import { computeChecksum, computeCombinedChecksum } from '../runner/checksum.js'; import { getSqlErrorMessage } from '../shared/index.js'; +import type { NoormDatabase } from '../shared/index.js'; import { getLockManager } from '../lock/index.js'; import { ChangeHistory } from './history.js'; import { ChangeTracker } from './tracker.js'; @@ -58,9 +61,6 @@ const DEFAULT_OPTIONS: Required> & { output: strin output: null, }; -/** Default SQL template - files with only this content are considered empty */ -const SQL_TEMPLATE = '-- TODO: Add SQL statements here'; - /** * Gate a change entrypoint against the config's access policy. * @@ -237,6 +237,7 @@ export async function executeChange( files, 'change', changeChecksum, + opts.force, history, start, ), @@ -376,6 +377,7 @@ export async function revertChange( files, 'revert', revertChecksum, + opts.force, history, start, ), @@ -405,8 +407,46 @@ export async function revertChange( // Internal: Execute Files // ───────────────────────────────────────────────────────────── +/** + * Dialects where wrapping a change's execution in a DB transaction + * actually rolls back DDL alongside the history rows written for it. + * Postgres only, for now: MySQL's DDL + * implicitly commits (a wrapping transaction would silently do nothing), + * MSSQL's GO-batch-split execution (`runner/mssql-batches.ts`) hasn't been + * verified to compose safely with a wrapping transaction, and SQLite is + * excluded on purpose so the per-file-skip scenario this ticket's unit + * tests depend on (file A commits independently of file B's failure) + * keeps working rather than collapsing into all-or-nothing. + */ +const TRANSACTIONAL_DIALECTS = new Set(['postgres']); + +/** + * Sentinel used to carry a failed `ChangeResult` out of a rolled-back + * Postgres transaction. + * + * Thrown (never returned) from inside `context.db.transaction().execute()` + * so Kysely rolls back everything issued in the callback — DDL and the + * operation/file history rows alike. Caught immediately outside the + * transaction and unwrapped back into the result the caller sees. + */ +class ChangeRollback extends Error { + + constructor(readonly result: ChangeResult) { + + super('change rolled back'); + + } + +} + /** * Execute files with tracking. + * + * Dispatches to `runFileBatch` either directly against `context.db` + * (non-transactional dialects — identical to CP1's behavior) or inside a + * `context.db.transaction()` (Postgres), so a failed Postgres change + * leaves no partial state: neither the DDL nor the operation/file history + * rows persist. */ async function executeFiles( context: ChangeContext, @@ -414,13 +454,105 @@ async function executeFiles( files: ChangeFile[], direction: 'change' | 'revert', checksum: string, + force: boolean, history: ChangeHistory, startTime: number, ): Promise { - // Expand .txt manifests to actual file list + const dialect = context.dialect ?? 'postgres'; const expandedFiles = await expandFiles(files, context.sqlDir); + if (!TRANSACTIONAL_DIALECTS.has(dialect)) { + + return runFileBatch( + context, + change, + expandedFiles, + direction, + checksum, + force, + history, + context.db, + startTime, + ); + + } + + // On Postgres a FAILED change leaves NO persisted history at all — + // the operation and file rows created inside this transaction roll + // back together with the DDL. The caller still sees the failure via + // the returned ChangeResult (unwrapped from ChangeRollback below), + // but it's invisible in the DB — intentional per spec (atomicity over + // a persisted failure record). On retry, the top-level `needsRun` + // finds no record for the change and reruns it fresh. Do NOT try to + // persist a failure record outside the transaction — that would + // defeat the guarantee this branch exists for. + const [result, err] = await attempt(() => + context.db.transaction().execute(async (trx) => { + + const trxHistory = new ChangeHistory(trx, context.configName, dialect); + + const batchResult = await runFileBatch( + context, + change, + expandedFiles, + direction, + checksum, + force, + trxHistory, + trx, + startTime, + ); + + if (batchResult.status !== 'success') { + + throw new ChangeRollback(batchResult); + + } + + return batchResult; + + }), + ); + + if (err) { + + if (err instanceof ChangeRollback) { + + return { ...err.result, operationId: undefined }; + + } + + return createFailedResult(change.name, direction, err.message, startTime); + + } + + return result; + +} + +/** + * Run one execution batch — operation creation, per-file execution with + * history tracking, and finalization — against a given executor handle. + * + * Extracted from `executeFiles` so the identical batch logic runs either + * directly against `context.db` (non-transactional dialects) or inside a + * `context.db.transaction()` callback against `trx` (Postgres): only the + * `executor` (where SQL runs) and `history` (where tracking rows are + * written) vary between the two call sites. + */ +async function runFileBatch( + context: ChangeContext, + change: Change, + expandedFiles: ChangeFile[], + direction: 'change' | 'revert', + checksum: string, + force: boolean, + history: ChangeHistory, + executor: Kysely, + startTime: number, +): Promise { + // Create operation record const [operationId, createErr] = await attempt(() => history.createOperation({ @@ -509,6 +641,53 @@ async function executeFiles( }); const fileStart = performance.now(); + const relPath = path.relative(context.projectRoot, file.path); + const fileChecksum = fileChecksums.get(file.path) ?? ''; + + // Per-file skip: a prior success with a matching checksum means this + // file doesn't need to run again, even though the overall change's + // checksum differs because another file needed fixing. + const needsRunFileResult = await history.needsRunFile( + change.name, + direction, + relPath, + fileChecksum, + force, + ); + + if (!needsRunFileResult.needsRun) { + + results.push({ + filepath: file.path, + checksum: fileChecksum, + status: 'skipped', + skipReason: needsRunFileResult.skipReason, + durationMs: 0, + }); + + const skipUpdateErr = await history.updateFileExecution( + operationId, + relPath, + 'skipped', + 0, + undefined, + needsRunFileResult.skipReason, + ); + + if (skipUpdateErr) { + + // Log but continue - the skip decision itself is sound + observer.emit('error', { + source: 'change', + error: new Error(skipUpdateErr), + context: { filepath: relPath, operation: 'update-skipped-record' }, + }); + + } + + continue; + + } // Load and render file const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile(context, file.path)); @@ -524,14 +703,13 @@ async function executeFiles( results.push({ filepath: file.path, - checksum: fileChecksums.get(file.path) ?? '', + checksum: fileChecksum, status: 'failed', error: loadErr.message, durationMs, }); // Update DB record (use relative path to match createFileRecords) - const relPath = path.relative(context.projectRoot, file.path); const updateErr = await history.updateFileExecution( operationId, relPath, @@ -556,7 +734,7 @@ async function executeFiles( } // Execute SQL - const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(context.db)); + const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(executor)); const durationMs = performance.now() - fileStart; @@ -571,17 +749,16 @@ async function executeFiles( results.push({ filepath: file.path, - checksum: fileChecksums.get(file.path) ?? '', + checksum: fileChecksum, status: 'failed', error: errorMessage, durationMs, }); // Update DB record (use relative path to match createFileRecords) - const execRelPath = path.relative(context.projectRoot, file.path); const updateErr2 = await history.updateFileExecution( operationId, - execRelPath, + relPath, 'failed', durationMs, errorMessage, @@ -593,7 +770,7 @@ async function executeFiles( observer.emit('error', { source: 'change', error: new Error(updateErr2), - context: { filepath: execRelPath, operation: 'update-failed-record' }, + context: { filepath: relPath, operation: 'update-failed-record' }, }); } @@ -605,16 +782,15 @@ async function executeFiles( // Success results.push({ filepath: file.path, - checksum: fileChecksums.get(file.path) ?? '', + checksum: fileChecksum, status: 'success', durationMs, }); // Update DB record (use relative path to match createFileRecords) - const successRelPath = path.relative(context.projectRoot, file.path); const updateErr = await history.updateFileExecution( operationId, - successRelPath, + relPath, 'success', durationMs, ); @@ -625,7 +801,7 @@ async function executeFiles( observer.emit('error', { source: 'change', error: new Error(updateErr), - context: { filepath: successRelPath, operation: 'update-success-record' }, + context: { filepath: relPath, operation: 'update-success-record' }, }); } @@ -923,10 +1099,7 @@ async function validateFilesHaveContent(files: ChangeFile[]): Promise { } - const trimmed = content?.trim() ?? ''; - - // Check if file has actual content (not empty, not just the template) - if (trimmed && trimmed !== SQL_TEMPLATE) { + if (hasExecutableSql(content ?? '')) { return true; @@ -938,6 +1111,27 @@ async function validateFilesHaveContent(files: ChangeFile[]): Promise { } +/** + * True if content has a line that isn't blank or a `--` line comment. + * + * Scaffolded stubs (and the default template) are comment-only by design; + * matching on a single fixed template string missed any other wording, + * so a differently-worded stub would silently execute as a no-op. + */ +function hasExecutableSql(content: string): boolean { + + return content + .split('\n') + .some((line) => { + + const trimmed = line.trim(); + + return trimmed.length > 0 && !trimmed.startsWith('--'); + + }); + +} + /** * Create a failed result. */ diff --git a/src/core/change/history.ts b/src/core/change/history.ts index ad170bd5..c78f8d9c 100644 --- a/src/core/change/history.ts +++ b/src/core/change/history.ts @@ -48,6 +48,43 @@ import type { } from './types.js'; import type { ChangeType } from '../shared/index.js'; +// ───────────────────────────────────────────────────────────── +// Date Hydration +// ───────────────────────────────────────────────────────────── + +/** + * Normalizes a change-tracking timestamp column to a real `Date`. + * + * WHY: postgres/mysql/mssql drivers parse `executed_at` into a `Date` + * automatically, but SQLite (both `bun:sqlite` and `better-sqlite3`) + * hands back the raw `CURRENT_TIMESTAMP` text (`'YYYY-MM-DD HH:MM:SS'`, + * always UTC, no offset marker). Parsing that string with `new Date(str)` + * directly reads it as local time, silently shifting the result by the + * host's UTC offset — so the string must be marked UTC explicitly first. + * + * @example + * hydrateDate('2026-07-12 09:02:59') // -> 2026-07-12T09:02:59.000Z + * hydrateDate(new Date('2026-07-12T09:02:59.000Z')) // -> unchanged + * hydrateDate(null) // -> null + */ +export function hydrateDate(value: Date | string | null | undefined): Date | null { + + if (value === null || value === undefined) { + + return null; + + } + + if (value instanceof Date) { + + return value; + + } + + return new Date(`${value.replace(' ', 'T')}Z`); + +} + // ───────────────────────────────────────────────────────────── // History Class // ───────────────────────────────────────────────────────────── @@ -117,6 +154,7 @@ export class ChangeHistory { this.#ndb .selectFrom(this.#tables.change) .select([ + 'id', 'name', 'status', 'executed_at', @@ -169,10 +207,11 @@ export class ChangeHistory { return { name: record.name, status: record.status, - appliedAt: record.executed_at, + appliedAt: hydrateDate(record.executed_at), appliedBy: record.executed_by, - revertedAt: revertRecord?.executed_at ?? null, + revertedAt: hydrateDate(revertRecord?.executed_at), errorMessage: record.error_message || null, + appliedHistoryId: record.id, }; } @@ -220,10 +259,11 @@ export class ChangeHistory { statuses.set(record.name, { name: record.name, status: record.status, - appliedAt: record.executed_at, + appliedAt: hydrateDate(record.executed_at), appliedBy: record.executed_by, revertedAt: null, // Will be filled in below errorMessage: record.error_message || null, + appliedHistoryId: record.id, }); } @@ -253,7 +293,7 @@ export class ChangeHistory { if (!seenReverts.has(revert.name) && statuses.has(revert.name)) { const status = statuses.get(revert.name)!; - status.revertedAt = revert.executed_at; + status.revertedAt = hydrateDate(revert.executed_at); seenReverts.add(revert.name); } @@ -379,6 +419,132 @@ export class ChangeHistory { } + /** + * Check if a single file within a change needs to run. + * + * Mirrors `Tracker.needsRun` (runner/tracker.ts) but scoped to this + * change's name+direction+config instead of a global filepath lookup, + * so file A's success under one change never satisfies file A's check + * under a different change. + * + * Excludes `pending` rows from consideration: `createFileRecords` + * inserts a fresh pending row for every file before the per-file loop + * runs, and that row (always the highest id for this filepath) would + * otherwise shadow the prior operation's real success/failure record, + * making retries re-run every file instead of just the one that failed. + * + * Also excludes `skipped` rows: `status: 'skipped'` is written for two + * different meanings — `skipRemainingFiles` writes it for files never + * reached after an earlier failure (must re-run), while this method's + * own per-file-skip path (called from `executor.ts`) writes it for a + * file that matched a prior success (must stay skipped). A `skipped` + * row is never itself a decision basis: a success-match skip still has + * its covering `success` row further back in history (found once the + * `skipped` row is excluded), and a never-reached skip has no terminal + * row at all, so the lookup falls through to `{ needsRun: true, reason: + * 'new' }` below and the file runs. Both resolve correctly without + * consulting the ambiguous row — excluding it here is what prevents a + * third attempt from re-running a file a prior success already covered. + * + * @param name - Change name + * @param direction - 'change' or 'revert' + * @param filepath - Relative filepath as stored in execution records + * @param checksum - Current checksum of the file + * @param force - Force re-run regardless of status + * @returns Whether the file needs to run and why + */ + async needsRunFile( + name: string, + direction: Direction, + filepath: string, + checksum: string, + force: boolean, + ): Promise { + + // Force always runs + if (force) { + + return { needsRun: true, reason: 'force' }; + + } + + // Get most recent completed execution record for this file, scoped + // to this change's name+direction+config + const [record, err] = await attempt(() => + (this.#ndb + .selectFrom(this.#tables.executions) + .innerJoin( + this.#tables.change, + `${this.#tables.change}.id`, + `${this.#tables.executions}.change_id`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .select((eb: any) => [ + eb.ref(`${this.#tables.executions}.checksum`).as('checksum'), + eb.ref(`${this.#tables.executions}.status`).as('exec_status'), + ]) + .where(`${this.#tables.change}.name`, '=', name) + .where(`${this.#tables.change}.direction`, '=', direction) + .where(`${this.#tables.change}.config_name`, '=', this.#configName) + .where(`${this.#tables.executions}.filepath`, '=', filepath) + .where(`${this.#tables.executions}.status`, 'not in', ['pending', 'skipped']) + .orderBy(`${this.#tables.executions}.id`, 'desc') + .limit(1) + .executeTakeFirst(), + ); + + if (err) { + + observer.emit('error', { + source: 'change', + error: err, + context: { name, filepath, operation: 'needs-run-file-check' }, + }); + + // On error, assume needs to run + return { needsRun: true, reason: 'new' }; + + } + + // No previous completed record - new file + if (!record) { + + return { needsRun: true, reason: 'new' }; + + } + + // Previous execution failed - retry + if (record.exec_status === 'failed') { + + return { + needsRun: true, + reason: 'failed', + previousChecksum: record.checksum, + }; + + } + + // Checksum changed since the last recorded attempt + if (record.checksum !== checksum) { + + return { + needsRun: true, + reason: 'changed', + previousChecksum: record.checksum, + }; + + } + + // Success and unchanged - skip + return { + needsRun: false, + skipReason: 'already applied', + previousChecksum: record.checksum, + }; + + } + // ───────────────────────────────────────────────────────── // Create Records // ───────────────────────────────────────────────────────── @@ -908,7 +1074,9 @@ export class ChangeHistory { name: r.name, direction: r.direction, status: r.status, - executedAt: r.executed_at, + // Non-null: executed_at is NOT NULL with a CURRENT_TIMESTAMP + // default, always populated on write (see createOperation). + executedAt: hydrateDate(r.executed_at)!, executedBy: r.executed_by, durationMs: r.duration_ms, errorMessage: r.error_message || null, @@ -981,7 +1149,9 @@ export class ChangeHistory { changeType: r.change_type, direction: r.direction, status: r.status, - executedAt: r.executed_at, + // Non-null: executed_at is NOT NULL with a CURRENT_TIMESTAMP + // default, always populated on write (see createOperation). + executedAt: hydrateDate(r.executed_at)!, executedBy: r.executed_by, durationMs: r.duration_ms, errorMessage: r.error_message || null, diff --git a/src/core/change/manager.ts b/src/core/change/manager.ts index 1e42c175..81520536 100644 --- a/src/core/change/manager.ts +++ b/src/core/change/manager.ts @@ -135,6 +135,7 @@ export class ChangeManager { appliedBy: status?.appliedBy ?? null, revertedAt: status?.revertedAt ?? null, errorMessage: status?.errorMessage ?? null, + appliedHistoryId: status?.appliedHistoryId ?? null, isNew: !status, orphaned: false, }); @@ -153,6 +154,7 @@ export class ChangeManager { appliedBy: status.appliedBy, revertedAt: status.revertedAt, errorMessage: status.errorMessage, + appliedHistoryId: status.appliedHistoryId ?? null, isNew: false, orphaned: true, // Disk fields are not available for orphaned changes @@ -362,7 +364,12 @@ export class ChangeManager { const opts = { ...DEFAULT_BATCH, ...options }; const start = performance.now(); - // Get applied changes sorted by appliedAt (most recent first) + // Get applied changes sorted by appliedAt (most recent first), tiebreaking + // on the history table's autoincrement id -- appliedAt is second-precision, + // so changes applied within the same tick (e.g. `change ff`) tie, and the + // id is the only reliable true apply-order key. appliedAt stays primary + // because not every applied entry is guaranteed to carry an id (older/ + // partial hydration paths leave it undefined). const list = await this.list(); const applied = list .filter((cs) => !cs.orphaned && cs.status === 'success' && cs.appliedAt) @@ -371,7 +378,16 @@ export class ChangeManager { const aTime = a.appliedAt?.getTime() ?? 0; const bTime = b.appliedAt?.getTime() ?? 0; - return bTime - aTime; // Most recent first + if (aTime !== bTime) { + + return bTime - aTime; // Most recent first + + } + + const aId = a.appliedHistoryId ?? 0; + const bId = b.appliedHistoryId ?? 0; + + return bId - aId; // Tie: most recently applied (highest id) first }); diff --git a/src/core/change/scaffold.ts b/src/core/change/scaffold.ts index 653a8809..45b7110e 100644 --- a/src/core/change/scaffold.ts +++ b/src/core/change/scaffold.ts @@ -27,6 +27,8 @@ import path from 'node:path'; import { mkdir, writeFile, rename, unlink, rm, stat } from 'node:fs/promises'; import { attempt } from '@logosdx/utils'; +import dayjs from 'dayjs'; +import v from 'voca'; import { observer } from '../observer.js'; import { parseSequence, parseDescription } from './parser.js'; @@ -46,6 +48,14 @@ import { ChangeValidationError } from './types.js'; const SQL_TEMPLATE = `-- TODO: Add SQL statements here `; +/** Stub content for a freshly scaffolded change/_.sql */ +const CHANGE_STUB_TEMPLATE = `-- Add the SQL statements that apply this change. +`; + +/** Stub content for a freshly scaffolded revert/_.sql */ +const REVERT_STUB_TEMPLATE = `-- Add the SQL statements that undo this change. +`; + /** Default template for changelog */ const CHANGELOG_TEMPLATE = `# Changelog @@ -71,7 +81,7 @@ TODO: Describe any impact on existing data or functionality. * * @param changesDir - Parent directory for changes * @param options - Creation options - * @returns Parsed change (empty, ready for files) + * @returns Parsed change with a runnable change/revert stub already scaffolded * * @example * ```typescript @@ -90,8 +100,8 @@ export async function createChange( // Generate name const date = options.date ?? new Date(); - const dateStr = formatDate(date); - const slug = slugify(options.description); + const dateStr = dayjs(date).format('YYYY-MM-DD'); + const slug = v.slugify(options.description); const name = `${dateStr}-${slug}`; const changeDir = path.join(changesDir, name); @@ -133,8 +143,11 @@ export async function createChange( path: changeDir, }); - // Return parsed change - return { + // An empty change/ + revert/ pair fails parseChange's validation, and the + // caller sees that misreported as "change not found" rather than "needs + // editing". Scaffold a stub in each folder via addFile so the sequence/ + // filename convention has one source of truth. + let change: Change = { name, path: changeDir, date, @@ -144,6 +157,19 @@ export async function createChange( hasChangelog: true, }; + change = await addFile(change, 'change', { + name: slug, + type: 'sql', + content: CHANGE_STUB_TEMPLATE, + }); + change = await addFile(change, 'revert', { + name: slug, + type: 'sql', + content: REVERT_STUB_TEMPLATE, + }); + + return change; + } // ───────────────────────────────────────────────────────────── @@ -187,7 +213,7 @@ export async function addFile( const sequence = maxSequence + 1; // Generate filename - const slug = slugify(options.name); + const slug = v.slugify(options.name); const extension = options.type === 'txt' ? '.txt' : '.sql'; const filename = `${padSequence(sequence)}_${slug}${extension}`; @@ -352,7 +378,7 @@ export async function renameFile( } // Generate new filename - const slug = slugify(newDescription); + const slug = v.slugify(newDescription); const newFilename = `${padSequence(sequence)}_${slug}${extension}`; const newPath = path.join(path.dirname(file.path), newFilename); @@ -563,32 +589,6 @@ export async function deleteChange(change: Change): Promise { // Internal Helpers // ───────────────────────────────────────────────────────────── -/** - * Format date as YYYY-MM-DD. - */ -function formatDate(date: Date): string { - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - - return `${year}-${month}-${day}`; - -} - -/** - * Convert text to URL-safe slug. - */ -function slugify(text: string): string { - - return text - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - -} - /** * Pad sequence number to 3 digits. */ diff --git a/src/core/change/types.ts b/src/core/change/types.ts index a38f660e..7b88d68e 100644 --- a/src/core/change/types.ts +++ b/src/core/change/types.ts @@ -147,6 +147,14 @@ export interface ChangeStatus { /** Error message if failed */ errorMessage: string | null; + + /** + * History-row id of the record that produced `appliedAt` — the true + * apply-order key (autoincrement, unlike second-precision `appliedAt`). + * Optional/additive: `undefined` where not hydrated, `null` when the + * change has never been applied. + */ + appliedHistoryId?: number | null; } // ───────────────────────────────────────────────────────────── @@ -216,6 +224,14 @@ export interface ChangeListItem { /** Error message if failed */ errorMessage: string | null; + /** + * History-row id of the record that produced `appliedAt` — the true + * apply-order key (autoincrement, unlike second-precision `appliedAt`). + * Optional/additive: `undefined` where not hydrated, `null` when the + * change has never been applied. + */ + appliedHistoryId?: number | null; + // Computed /** True if exists on disk but no DB record */ isNew: boolean; diff --git a/src/core/config/index.ts b/src/core/config/index.ts index 4aebf28f..eb645ac7 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -116,6 +116,8 @@ export { resolveConfig, checkConfigCompleteness, canDeleteConfig, + assertCanDeleteConfig, + ConfigStageLockedError, type ResolveOptions, type StateProvider, type SettingsProvider, diff --git a/src/core/config/resolver.ts b/src/core/config/resolver.ts index 4d020408..9b4c3080 100644 --- a/src/core/config/resolver.ts +++ b/src/core/config/resolver.ts @@ -415,6 +415,35 @@ export function checkConfigCompleteness( } +/** + * Error when a config is linked to a locked stage and cannot be deleted. + * + * Thrown by `assertCanDeleteConfig` — the throwing counterpart to + * `canDeleteConfig`'s bool+reason check. + * + * @example + * ```typescript + * const [, err] = await attempt(() => stateManager.deleteConfig(name, settings)) + * if (err instanceof ConfigStageLockedError) { + * console.log(`Blocked by stage "${err.stageName}"`) + * } + * ``` + */ +export class ConfigStageLockedError extends Error { + + override readonly name = 'ConfigStageLockedError' as const; + + constructor( + public readonly configName: string, + public readonly stageName: string, + ) { + + super(`Config "${configName}" is linked to locked stage "${stageName}" and cannot be deleted`); + + } + +} + /** * Check if a config can be deleted. * @@ -440,7 +469,7 @@ export function canDeleteConfig( return { allowed: false, - reason: `Config "${configName}" is linked to a locked stage and cannot be deleted`, + reason: `Config "${configName}" is linked to locked stage "${stageName ?? configName}" and cannot be deleted`, }; } @@ -448,3 +477,29 @@ export function canDeleteConfig( return { allowed: true }; } + +/** + * Runs `canDeleteConfig` and throws when denied — the throwing counterpart + * every deletion seam reaches for, mirroring `checkConfigPolicy`/`assertPolicy` + * in `core/policy/check.ts`. + * + * @throws ConfigStageLockedError when the config's stage is locked. + * + * @example + * assertCanDeleteConfig(configName, settingsProvider); + */ +export function assertCanDeleteConfig( + configName: string, + settings?: SettingsProvider, + stageName?: string, +): void { + + const check = canDeleteConfig(configName, settings, stageName); + + if (!check.allowed) { + + throw new ConfigStageLockedError(configName, stageName ?? configName); + + } + +} diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 63cfd62e..7c6740f9 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -6,6 +6,7 @@ */ import { z } from 'zod'; +import { PortSchema } from '../connection/defaults.js'; import { resolveLegacyAccess } from '../policy/index.js'; import type { ConfigAccess } from '../policy/index.js'; @@ -49,7 +50,7 @@ function withResolvedAccess conn.dialect === 'sqlite' || conn.host, { message: 'Host is required for non-SQLite databases', path: ['host'], - }); + }) + .refine( + (conn) => conn.dialect === 'sqlite' || !containsDangerousDbNameChar(conn.database), + { + message: + 'Database name must not contain quotes, backticks, brackets, semicolons, or control characters', + path: ['database'], + }, + ); /** * Full config object schema, before access/protected resolution. diff --git a/src/core/config/validate.ts b/src/core/config/validate.ts new file mode 100644 index 00000000..ecf76b43 --- /dev/null +++ b/src/core/config/validate.ts @@ -0,0 +1,89 @@ +/** + * Config validate algorithm. + * + * Single source for the three-check sequence (connection, name/database + * presence, host presence for non-sqlite) shared by `cli/config/validate.ts` + * (text/JSON output) and `ConfigValidateScreen.tsx` (`StatusList`/toast). + * Both call `validateConfigChecks` and keep only their own presentation + * layer. + */ +import { testConnection } from '../connection/factory.js'; +import type { Config } from './types.js'; + +/** + * Result of a single validate check. + */ +export interface ConfigCheckResult { + key: string; + label: string; + status: 'success' | 'error'; + detail: string; +} + +/** + * Runs the config-validate check sequence against a resolved config: + * connection test, then name/database presence, then host presence for + * non-sqlite dialects. All checks always run (no fail-fast) so callers can + * show a full report; `valid` is the AND of every check's status. + * + * @example + * ```typescript + * const { checks, valid } = await validateConfigChecks(config); + * ``` + */ +export async function validateConfigChecks( + config: Config, +): Promise<{ checks: ConfigCheckResult[]; valid: boolean }> { + + const checks: ConfigCheckResult[] = []; + let valid = true; + + const connResult = await testConnection(config.connection); + + checks.push({ + key: 'connection', + label: 'Connection', + status: connResult.ok ? 'success' : 'error', + detail: connResult.ok ? 'Connection successful' : (connResult.error ?? 'Connection failed'), + }); + + if (!connResult.ok) valid = false; + + const requiredChecks = [ + { key: 'name', label: 'Name', value: config.name }, + { key: 'database', label: 'Database', value: config.connection.database }, + ]; + + for (const check of requiredChecks) { + + const isSet = Boolean(check.value); + checks.push({ + key: check.key, + label: check.label, + status: isSet ? 'success' : 'error', + detail: isSet ? check.value : 'Not set', + }); + + if (!isSet) valid = false; + + } + + if (config.connection.dialect !== 'sqlite') { + + const host = config.connection.host; + const hasHost = Boolean(host); + + checks.push({ + key: 'host', + label: 'Host', + status: hasHost ? 'success' : 'error', + detail: host ? host : 'Not set', + }); + + if (!hasHost) valid = false; + + } + + return { checks, valid }; + +} diff --git a/src/core/connection/defaults.ts b/src/core/connection/defaults.ts new file mode 100644 index 00000000..deb14b10 --- /dev/null +++ b/src/core/connection/defaults.ts @@ -0,0 +1,27 @@ +/** + * Default connection ports by dialect. + * + * Single source for the port every dialect factory, `same-server.ts`, and + * the TUI's connection-config builder fall back to when a config omits + * `port`. + */ +import { z } from 'zod'; + +import type { Dialect } from './types.js'; + +export const DEFAULT_PORTS: Record = { + postgres: 5432, + mysql: 3306, + sqlite: 0, // Not applicable + mssql: 1433, +}; + +/** + * Port number validation. Shared by `core/config` and `core/settings` so the + * bound has one source of truth. + */ +export const PortSchema = z + .number() + .int() + .min(1, 'Port must be at least 1') + .max(65535, 'Port must be at most 65535'); diff --git a/src/core/connection/dialects/mssql.ts b/src/core/connection/dialects/mssql.ts index 367f803f..7d518c90 100644 --- a/src/core/connection/dialects/mssql.ts +++ b/src/core/connection/dialects/mssql.ts @@ -10,6 +10,7 @@ */ import { Kysely, MssqlDialect, sql } from 'kysely'; import type { ConnectionConfig, ConnectionResult } from '../types.js'; +import { DEFAULT_PORTS } from '../defaults.js'; import { MssqlLimitPlugin } from './mssql-limit-plugin.js'; /** @@ -34,7 +35,7 @@ function buildTediousConfig( }, }, options: { - port: config.port ?? 1433, + port: config.port ?? DEFAULT_PORTS.mssql, database: database ?? config.database, trustServerCertificate: !config.ssl, encrypt: true, @@ -83,7 +84,7 @@ async function verifyDatabaseExists( if (rows.length === 0) { throw new Error( - `Database '${config.database}' does not exist on ${config.host ?? 'localhost'}:${config.port ?? 1433}`, + `Database '${config.database}' does not exist on ${config.host ?? 'localhost'}:${config.port ?? DEFAULT_PORTS.mssql}`, ); } @@ -109,7 +110,6 @@ async function verifyDatabaseExists( * const conn = createMssqlConnection({ * dialect: 'mssql', * host: 'localhost', - * port: 1433, * database: 'myapp', * user: 'sa', * password: 'secret', diff --git a/src/core/connection/dialects/mysql.ts b/src/core/connection/dialects/mysql.ts index 9372b625..8291facc 100644 --- a/src/core/connection/dialects/mysql.ts +++ b/src/core/connection/dialects/mysql.ts @@ -6,6 +6,7 @@ */ import { Kysely, MysqlDialect } from 'kysely'; import type { ConnectionConfig, ConnectionResult } from '../types.js'; +import { DEFAULT_PORTS } from '../defaults.js'; /** * Create a MySQL connection. @@ -15,7 +16,6 @@ import type { ConnectionConfig, ConnectionResult } from '../types.js'; * const conn = createMysqlConnection({ * dialect: 'mysql', * host: 'localhost', - * port: 3306, * database: 'myapp', * user: 'root', * password: 'secret', @@ -30,7 +30,7 @@ export async function createMysqlConnection(config: ConnectionConfig): Promise { + const { retries = 3, delay = 1000, backoff = 2 } = retryOptions; + const [conn, err] = await attempt(() => retry( async () => { @@ -121,9 +143,9 @@ export async function createConnection( }, { - retries: 3, - delay: 1000, - backoff: 2, // 1s, 2s, 4s + retries, + delay, + backoff, jitterFactor: 0.1, shouldRetry: (err) => { @@ -179,7 +201,15 @@ export async function createConnection( destroy: wrappedDestroy, }; - observer.emit('connection:open', { configName, dialect: config.dialect }); + // host/port/database remove the "which database was I actually talking + // to" ambiguity (#51) -- never user/password. + observer.emit('connection:open', { + configName, + dialect: config.dialect, + host: config.host, + port: config.port, + database: config.database, + }); return trackedConn; diff --git a/src/core/connection/index.ts b/src/core/connection/index.ts index cf2667c4..69a07b3f 100644 --- a/src/core/connection/index.ts +++ b/src/core/connection/index.ts @@ -4,5 +4,7 @@ * Provides database connection creation and management. */ export { createConnection, testConnection } from './factory.js'; +export type { ConnectionRetryOptions } from './factory.js'; export { getConnectionManager, resetConnectionManager } from './manager.js'; +export { DEFAULT_PORTS, PortSchema } from './defaults.js'; export * from './types.js'; diff --git a/src/core/connection/manager.ts b/src/core/connection/manager.ts index 28e6f515..1c2ae81a 100644 --- a/src/core/connection/manager.ts +++ b/src/core/connection/manager.ts @@ -4,12 +4,13 @@ * Tracks ALL active database connections and ensures they're closed on shutdown. * Connections can be cached by config name for reuse, or tracked ephemerally. */ -import { attempt } from '@logosdx/utils'; +import { attempt, runWithTimeout } from '@logosdx/utils'; import type { Config } from '../config/types.js'; import type { ConnectionResult } from './types.js'; import type { WorkerBridge } from '../worker-bridge/bridge.js'; import type { ConnectionEvents } from '../worker-bridge/types.js'; import { observer } from '../observer.js'; +import { isDebug } from '../environment.js'; /** * Internal connection entry with metadata. @@ -57,7 +58,7 @@ class ConnectionManager { // Listen for shutdown event this.#unsubscribe = observer.on('app:shutdown', async () => { - if (process.env['NOORM_DEBUG']) { + if (isDebug()) { console.error( `[ConnectionManager] app:shutdown received, closing ${this.size} connections`, @@ -68,7 +69,7 @@ class ConnectionManager { this.#shuttingDown = true; await this.closeAll(); - if (process.env['NOORM_DEBUG']) { + if (isDebug()) { console.error('[ConnectionManager] all connections closed'); @@ -212,22 +213,9 @@ class ConnectionManager { const trackedEntries = Array.from(this.#tracked.entries()); for (const [id, entry] of trackedEntries) { - let timer: ReturnType; - const destroyWithTimeout = Promise.race([ - entry.conn.destroy().then(() => { - - clearTimeout(timer); - - }), - new Promise((resolve) => { - - timer = setTimeout(resolve, CLOSE_TIMEOUT); - - }), - ]); - - const [, err] = await attempt(() => destroyWithTimeout); - clearTimeout(timer!); + const [, err] = await attempt(() => + runWithTimeout(() => entry.conn.destroy(), { timeout: CLOSE_TIMEOUT, throws: true }), + ); this.#tracked.delete(id); if (err) { diff --git a/src/core/db/dialects/mssql.ts b/src/core/db/dialects/mssql.ts index 436a10ef..f0b860cb 100644 --- a/src/core/db/dialects/mssql.ts +++ b/src/core/db/dialects/mssql.ts @@ -10,6 +10,57 @@ import type { ConnectionConfig } from '../../connection/types.js'; import type { DialectDbOperations } from '../types.js'; import { createConnection } from '../../connection/factory.js'; +import { createDialectQuoting } from '../../shared/index.js'; + +const { quote } = createDialectQuoting({ open: '[', close: ']', escape: ']]' }); + +/** + * Builds the CREATE DATABASE statement. + * + * Quotes dbName as a single identifier so embedded closing brackets can't + * break out of the DDL into arbitrary statements. + * + * @example + * buildCreateDatabaseSql('my]app'); // → 'CREATE DATABASE [my]]app]' + */ +export function buildCreateDatabaseSql(dbName: string): string { + + return `CREATE DATABASE ${quote(dbName)}`; + +} + +/** + * Builds the conditional-drop batch: single-user + rollback to disconnect + * active sessions, then drop, guarded by an existence check. + * + * T-SQL string literals have no backslash-escape channel, so the embedded + * single quote is doubled per the standard SQL literal-escaping rule; the + * `ALTER`/`DROP` identifiers are bracket-quoted independently of the + * literal, since the two escaping rules don't share a channel to break out + * through. + * + * @example + * buildDropDatabaseSql('myapp'); + * // → "IF EXISTS(SELECT 1 FROM sys.databases WHERE name = 'myapp')\n" + + * // 'BEGIN\n' + + * // ' ALTER DATABASE [myapp] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;\n' + + * // ' DROP DATABASE [myapp];\n' + + * // 'END' + */ +export function buildDropDatabaseSql(dbName: string): string { + + const identifier = quote(dbName); + const literal = dbName.replace(/'/g, '\'\''); + + return [ + `IF EXISTS(SELECT 1 FROM sys.databases WHERE name = '${literal}')`, + 'BEGIN', + ` ALTER DATABASE ${identifier} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;`, + ` DROP DATABASE ${identifier};`, + 'END', + ].join('\n'); + +} /** * Execute a query against the master database. @@ -67,7 +118,7 @@ export const mssqlDbOperations: DialectDbOperations = { await withMasterDb(config, async (conn) => { - await sql.raw(`CREATE DATABASE [${dbName}]`).execute(conn.db); + await sql.raw(buildCreateDatabaseSql(dbName)).execute(conn.db); }); @@ -78,17 +129,7 @@ export const mssqlDbOperations: DialectDbOperations = { await withMasterDb(config, async (conn) => { // Set to single user mode to disconnect all users - await sql - .raw( - ` - IF EXISTS(SELECT 1 FROM sys.databases WHERE name = '${dbName}') - BEGIN - ALTER DATABASE [${dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; - DROP DATABASE [${dbName}]; - END - `, - ) - .execute(conn.db); + await sql.raw(buildDropDatabaseSql(dbName)).execute(conn.db); }); diff --git a/src/core/db/dialects/mysql.ts b/src/core/db/dialects/mysql.ts index d6d9ab43..4bb0b5eb 100644 --- a/src/core/db/dialects/mysql.ts +++ b/src/core/db/dialects/mysql.ts @@ -10,6 +10,36 @@ import type { ConnectionConfig } from '../../connection/types.js'; import type { DialectDbOperations } from '../types.js'; import { createConnection } from '../../connection/factory.js'; +import { createDialectQuoting } from '../../shared/index.js'; + +const { quote } = createDialectQuoting({ open: '`', close: '`', escape: '``' }); + +/** + * Builds the CREATE DATABASE IF NOT EXISTS statement. + * + * Quotes dbName as a single identifier so embedded backticks can't break + * out of the DDL into arbitrary statements. + * + * @example + * buildCreateDatabaseSql('my`app'); // → 'CREATE DATABASE IF NOT EXISTS `my``app`' + */ +export function buildCreateDatabaseSql(dbName: string): string { + + return `CREATE DATABASE IF NOT EXISTS ${quote(dbName)}`; + +} + +/** + * Builds the DROP DATABASE IF EXISTS statement. + * + * @example + * buildDropDatabaseSql('myapp'); // → 'DROP DATABASE IF EXISTS `myapp`' + */ +export function buildDropDatabaseSql(dbName: string): string { + + return `DROP DATABASE IF EXISTS ${quote(dbName)}`; + +} /** * Execute a query without a database (MySQL allows this). @@ -63,7 +93,7 @@ export const mysqlDbOperations: DialectDbOperations = { await withoutDb(config, async (conn) => { - await sql.raw(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``).execute(conn.db); + await sql.raw(buildCreateDatabaseSql(dbName)).execute(conn.db); }); @@ -73,7 +103,7 @@ export const mysqlDbOperations: DialectDbOperations = { await withoutDb(config, async (conn) => { - await sql.raw(`DROP DATABASE IF EXISTS \`${dbName}\``).execute(conn.db); + await sql.raw(buildDropDatabaseSql(dbName)).execute(conn.db); }); diff --git a/src/core/db/dialects/postgres.ts b/src/core/db/dialects/postgres.ts index 392abb48..1a141d0d 100644 --- a/src/core/db/dialects/postgres.ts +++ b/src/core/db/dialects/postgres.ts @@ -10,6 +10,36 @@ import type { ConnectionConfig } from '../../connection/types.js'; import type { DialectDbOperations } from '../types.js'; import { createConnection } from '../../connection/factory.js'; +import { createDialectQuoting } from '../../shared/index.js'; + +const { quote } = createDialectQuoting({ open: '"', close: '"', escape: '""' }); + +/** + * Builds the CREATE DATABASE statement. + * + * Quotes dbName as a single identifier so embedded double quotes can't + * break out of the DDL into arbitrary statements. + * + * @example + * buildCreateDatabaseSql('my"app'); // → 'CREATE DATABASE "my""app"' + */ +export function buildCreateDatabaseSql(dbName: string): string { + + return `CREATE DATABASE ${quote(dbName)}`; + +} + +/** + * Builds the DROP DATABASE IF EXISTS statement. + * + * @example + * buildDropDatabaseSql('myapp'); // → 'DROP DATABASE IF EXISTS "myapp"' + */ +export function buildDropDatabaseSql(dbName: string): string { + + return `DROP DATABASE IF EXISTS ${quote(dbName)}`; + +} /** * Execute a query against the system database. @@ -67,7 +97,7 @@ export const postgresDbOperations: DialectDbOperations = { await withSystemDb(config, async (conn) => { - await sql.raw(`CREATE DATABASE "${dbName}"`).execute(conn.db); + await sql.raw(buildCreateDatabaseSql(dbName)).execute(conn.db); }); @@ -85,7 +115,7 @@ export const postgresDbOperations: DialectDbOperations = { AND pid <> pg_backend_pid() `.execute(conn.db); - await sql.raw(`DROP DATABASE IF EXISTS "${dbName}"`).execute(conn.db); + await sql.raw(buildDropDatabaseSql(dbName)).execute(conn.db); }); diff --git a/src/core/db/operations.ts b/src/core/db/operations.ts index 84f9a9e9..2c800a55 100644 --- a/src/core/db/operations.ts +++ b/src/core/db/operations.ts @@ -32,6 +32,16 @@ import { attempt } from '@logosdx/utils'; */ export async function checkDbStatus(config: ConnectionConfig): Promise { + const ops = getDialectOperations(config.dialect); + + // SQLite's connectivity probe below connects directly to the target file + // (factory.ts has no sqlite system database to swap to), which auto-creates + // the file as a side effect. Capture existence before that happens so the + // post-probe check below can't report a false positive for a fresh target. + const sqlitePreProbeExists = config.dialect === 'sqlite' + ? await ops.databaseExists(config, config.database) + : undefined; + // Test server connectivity first const serverTest = await testConnection(config, { testServerOnly: true }); @@ -47,8 +57,7 @@ export async function checkDbStatus(config: ConnectionConfig): Promise } // Check if database exists - const ops = getDialectOperations(config.dialect); - const [exists, existsErr] = await attempt(() => ops.databaseExists(config, config.database)); + const [existsAfterProbe, existsErr] = await attempt(() => ops.databaseExists(config, config.database)); if (existsErr) { @@ -61,6 +70,8 @@ export async function checkDbStatus(config: ConnectionConfig): Promise } + const exists = sqlitePreProbeExists ?? existsAfterProbe; + if (!exists) { return { @@ -122,15 +133,17 @@ export async function createDb( options: CreateDbOptions = {}, ): Promise { - const { ifNotExists = true, initializeTracking = true } = options; + const { ifNotExists = true, initializeTracking = true, precheckedStatus } = options; const dbName = config.database; // Get dialect operations const ops = getDialectOperations(config.dialect); - // Check current status - const status = await checkDbStatus(config); + // Reuse the caller's status when supplied, instead of re-deriving it — + // a second checkDbStatus call for SQLite would see the caller's own + // probe having already auto-created the target file. + const status = precheckedStatus ?? await checkDbStatus(config); if (!status.serverOk) { diff --git a/src/core/db/types.ts b/src/core/db/types.ts index 014c2f43..643045fd 100644 --- a/src/core/db/types.ts +++ b/src/core/db/types.ts @@ -48,6 +48,14 @@ export interface CreateDbOptions { /** Initialize noorm tracking tables (default: true) */ initializeTracking?: boolean; + + /** + * Reuse a status already computed by the caller instead of calling + * `checkDbStatus` again internally. For SQLite, a caller's own probe + * already touched the target file (opening a connection auto-creates + * it), so a second internal check would see a false "already exists". + */ + precheckedStatus?: DbStatus; } /** diff --git a/src/core/dt/crypto.ts b/src/core/dt/crypto.ts index dcc576e4..8c6fa0fc 100644 --- a/src/core/dt/crypto.ts +++ b/src/core/dt/crypto.ts @@ -29,6 +29,15 @@ const KEY_LENGTH = 32; const PBKDF2_ITERATIONS = 100_000; const PBKDF2_DIGEST = 'sha256'; +/** + * Minimum passphrase length enforced on encryption. + * + * Not enforced on decryption — a floor there would brick .dtzx archives + * encrypted by older versions with shorter passphrases; a wrong passphrase + * already fails via GCM auth tag verification. + */ +export const MIN_PASSPHRASE_LENGTH = 12; + /** * Encrypt data with a passphrase using AES-256-GCM. * @@ -36,8 +45,9 @@ const PBKDF2_DIGEST = 'sha256'; * Each call generates a new salt and IV for unique ciphertexts. * * @param data - Data to encrypt - * @param passphrase - User-provided encryption passphrase + * @param passphrase - User-provided encryption passphrase, at least MIN_PASSPHRASE_LENGTH characters * @returns Encrypted payload with salt, IV, authTag, and ciphertext + * @throws If passphrase is shorter than MIN_PASSPHRASE_LENGTH * * @example * ```typescript @@ -47,6 +57,12 @@ const PBKDF2_DIGEST = 'sha256'; */ export function encryptWithPassphrase(data: Buffer, passphrase: string): DtEncryptedPayload { + if (passphrase.length < MIN_PASSPHRASE_LENGTH) { + + throw new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`); + + } + const salt = randomBytes(SALT_LENGTH); const iv = randomBytes(IV_LENGTH); diff --git a/src/core/dt/index.ts b/src/core/dt/index.ts index 34a06d21..cd03bf12 100644 --- a/src/core/dt/index.ts +++ b/src/core/dt/index.ts @@ -44,7 +44,7 @@ import type { Kysely } from 'kysely'; import type { NoormDatabase } from '../shared/tables.js'; import type { ExportTableOptions, ImportFileOptions, DtStreamerOptions, DtSchema, DtValue, DatabaseVersion } from './types.js'; import type { Dialect } from '../connection/types.js'; -import type { ConnectionEvents, ComputeEvents } from '../worker-bridge/types.js'; +import type { ComputeEvents } from '../worker-bridge/types.js'; import { observer } from '../observer.js'; import { WorkerBridge } from '../worker-bridge/bridge.js'; @@ -56,7 +56,6 @@ import { DtReader } from './reader.js'; import { DtStreamer } from './streamer.js'; import { buildDtSchema, validateSchema } from './schema.js'; -const CONNECTION_WORKER = resolveWorker('connection'); const COMPUTE_WORKER = resolveWorker('compute'); /** @@ -72,34 +71,6 @@ function createDefaultComputePool(): WorkerPool { } -/** - * Create and connect a connection worker bridge. - * - * Spawns a new worker thread and connects it to the given database. - */ -async function createDefaultConnectionBridge( - dialect: Dialect, - connectionString: string, -): Promise<[WorkerBridge | null, Error | null]> { - - const bridge = new WorkerBridge(CONNECTION_WORKER); - - const [, connectErr] = await attempt(() => - bridge.request('connect', { dialect, connectionString }), - ); - - if (connectErr) { - - await attempt(() => bridge.shutdown()); - - return [null, connectErr]; - - } - - return [bridge, null]; - -} - /** * Export a database table to a .dt/.dtz/.dtzx file. * @@ -160,36 +131,14 @@ export async function exportTable( } - // Resolve workers — use provided overrides or create our own - let ownedConnectionBridge: WorkerBridge | null = null; - let ownedComputePool: WorkerPool | null = null; - - let connectionBridge = options.connectionBridge; - let computePool = options.computePool; - - // Create connection worker when connection string is available but no bridge provided - if (!connectionBridge && options.connectionString) { - - const [bridge, bridgeErr] = await createDefaultConnectionBridge(dialect, options.connectionString); - - if (bridgeErr) { - - return [null, bridgeErr]; - - } - - connectionBridge = bridge!; - ownedConnectionBridge = bridge!; - - } - - // Always create compute pool when not provided - if (!computePool) { - - computePool = createDefaultComputePool(); - ownedComputePool = computePool; - - } + // This seam once let a caller hand in a running connection worker for + // off-main-thread fetch (TUI responsiveness during a big export), a + // connectionString to spin a dedicated fetch worker against a different + // database, or a shared computePool to amortize worker spinup across a + // batch of table exports. No caller ever used any of the three — deleted + // per D8. The removed createDefaultConnectionBridge (see git history on + // this file) is the rebuild recipe if a real caller shows up. + const computePool = createDefaultComputePool(); // Worker pipeline: offload fetching and serialization to worker threads const [result, workerErr] = await exportTableWithWorkers({ @@ -199,23 +148,11 @@ export async function exportTable( filepath, dialect, batchSize, - connectionBridge, computePool, kyselyDb, }); - // Shut down owned workers - if (ownedComputePool) { - - await attempt(() => ownedComputePool!.shutdown()); - - } - - if (ownedConnectionBridge) { - - await attempt(() => ownedConnectionBridge!.shutdown()); - - } + await attempt(() => computePool.shutdown()); if (workerErr) { @@ -249,28 +186,6 @@ function getQuoteIdent(dialect: string): (c: string) => string { } -/** - * Build a raw SQL string for a paginated SELECT query. - */ -function buildBatchSql( - dialect: string, - tableName: string, - columnList: string, - orderCol: string, - batchSize: number, - offset: number, -): string { - - if (dialect === 'mssql') { - - return `SELECT ${columnList} FROM [${tableName}] ORDER BY ${orderCol} OFFSET ${offset} ROWS FETCH NEXT ${batchSize} ROWS ONLY`; - - } - - return `SELECT ${columnList} FROM "${tableName}" LIMIT ${batchSize} OFFSET ${offset}`; - -} - /** * Worker-based export pipeline — offload fetching and serialization to worker threads. * @@ -288,38 +203,16 @@ async function exportTableWithWorkers(ctx: { filepath: string; dialect: string; batchSize: number; - connectionBridge?: WorkerBridge; computePool: WorkerPool; kyselyDb: Kysely; }): Promise<[{ rowsWritten: number; bytesWritten: number } | null, Error | null]> { const { writer, dtSchema, tableName, filepath, dialect, batchSize } = ctx; - const { connectionBridge, computePool, kyselyDb } = ctx; + const { computePool, kyselyDb } = ctx; const backpressureLimit = batchSize * 3; // --- Stage 0: Get total row count --- - let totalRows = 0; - - if (connectionBridge) { - - const countSql = dialect === 'mssql' - ? `SELECT COUNT(*) AS cnt FROM [${tableName}]` - : `SELECT COUNT(*) AS cnt FROM "${tableName}"`; - - const [countResult, countErr] = await attempt(() => - connectionBridge.request('query', { sql: countSql }), - ); - - if (countErr) { - - return [null, countErr]; - - } - - const firstRow = countResult!.rows[0] as Record | undefined; - totalRows = Number(firstRow?.['cnt'] ?? 0); - - } + const totalRows = 0; // --- Stage 1-3: Fetch → Serialize → Write --- const columns = dtSchema.columns.map((c) => c.name); @@ -358,70 +251,38 @@ async function exportTableWithWorkers(ctx: { } // Fetch a batch - let batchRows: Record[]; - - if (connectionBridge) { - - const batchSql = buildBatchSql(dialect, tableName, columnList, orderCol, batchSize, offset); - - const [queryResult, queryErr] = await attempt(() => - connectionBridge.request('query', { sql: batchSql }), - ); - - if (queryErr) { + const [rows, fetchErr] = await attempt(() => { - pipelineError = queryErr; - break; - - } - - if (queryResult!.error) { - - pipelineError = new Error(queryResult!.error); - break; - - } - - batchRows = queryResult!.rows as Record[]; - - } - else { - - // Direct Kysely fetch when no connection worker is available - const [rows, fetchErr] = await attempt(() => { - - if (dialect === 'mssql') { - - return sql>` - SELECT ${sql.raw(columnList)} - FROM ${sql.table(tableName)} - ORDER BY ${sql.raw(orderCol)} - OFFSET ${offset} ROWS - FETCH NEXT ${batchSize} ROWS ONLY - `.execute(kyselyDb); - - } + if (dialect === 'mssql') { return sql>` SELECT ${sql.raw(columnList)} FROM ${sql.table(tableName)} - LIMIT ${batchSize} - OFFSET ${offset} + ORDER BY ${sql.raw(orderCol)} + OFFSET ${offset} ROWS + FETCH NEXT ${batchSize} ROWS ONLY `.execute(kyselyDb); - }); + } - if (fetchErr) { + return sql>` + SELECT ${sql.raw(columnList)} + FROM ${sql.table(tableName)} + LIMIT ${batchSize} + OFFSET ${offset} + `.execute(kyselyDb); - pipelineError = fetchErr; - break; + }); - } + if (fetchErr) { - batchRows = rows.rows; + pipelineError = fetchErr; + break; } + const batchRows = rows.rows; + if (batchRows.length === 0) break; loaded += batchRows.length; @@ -615,16 +476,7 @@ export async function importDtFile( } - // Resolve compute pool — use provided override or create our own - let ownedComputePool: WorkerPool | null = null; - let computePool = options.computePool; - - if (!computePool) { - - computePool = createDefaultComputePool(); - ownedComputePool = computePool; - - } + const computePool = createDefaultComputePool(); // Worker pipeline: offload deserialization to compute pool const [result, workerErr] = await importFileWithWorkers({ @@ -640,12 +492,7 @@ export async function importDtFile( kyselyDb, }); - // Shut down owned compute pool - if (ownedComputePool) { - - await attempt(() => ownedComputePool!.shutdown()); - - } + await attempt(() => computePool.shutdown()); if (workerErr) { diff --git a/src/core/dt/reader.ts b/src/core/dt/reader.ts index 835928cf..bcd7bf46 100644 --- a/src/core/dt/reader.ts +++ b/src/core/dt/reader.ts @@ -175,6 +175,10 @@ export class DtReader { const fileStream = createReadStream(this.#filepath); const gunzip = createGunzip(); + // .pipe() does not forward the source 'error' event, so an + // unhandled fileStream error (e.g. ENOENT) would otherwise + // crash the process instead of rejecting open(). + fileStream.on('error', (err) => gunzip.destroy(err)); fileStream.pipe(gunzip); return gunzip; diff --git a/src/core/dt/schema.ts b/src/core/dt/schema.ts index 431c87d4..c88295bf 100644 --- a/src/core/dt/schema.ts +++ b/src/core/dt/schema.ts @@ -84,7 +84,6 @@ export async function buildDtSchema( const mapping = toUniversalType({ dbType: col.dataType, dialect, - version, }); const dtCol: DtColumn = { diff --git a/src/core/dt/type-map.ts b/src/core/dt/type-map.ts index e95df1fd..a344bb94 100644 --- a/src/core/dt/type-map.ts +++ b/src/core/dt/type-map.ts @@ -34,9 +34,6 @@ export interface ToUniversalOptions { /** Source database dialect. */ dialect: Dialect; - /** Source database version (optional, for future version-aware source mapping). */ - version?: DatabaseVersion; - } /** diff --git a/src/core/dt/types.ts b/src/core/dt/types.ts index 76ff56ba..825402c2 100644 --- a/src/core/dt/types.ts +++ b/src/core/dt/types.ts @@ -22,9 +22,6 @@ * ``` */ import type { Dialect } from '../connection/types.js'; -import type { WorkerBridge } from '../worker-bridge/bridge.js'; -import type { WorkerPool } from '../worker-bridge/pool.js'; -import type { ConnectionEvents, ComputeEvents } from '../worker-bridge/types.js'; // --------------------------------------------------------------------------- // Universal type system @@ -360,15 +357,6 @@ export interface ExportTableOptions { /** Rows per batch. Default: 1000. */ batchSize?: number; - /** Connection string for the worker to connect to the database. When provided, enables worker-based fetching. */ - connectionString?: string; - - /** Shared connection worker override. When omitted, one is created if connectionString is available. */ - connectionBridge?: WorkerBridge; - - /** Shared compute pool override. When omitted, one is created automatically. */ - computePool?: WorkerPool; - } /** @@ -403,9 +391,6 @@ export interface ImportFileOptions { /** Rows per batch. Default: 1000. */ batchSize?: number; - /** Shared compute pool override. When omitted, one is created automatically. */ - computePool?: WorkerPool; - } /** diff --git a/src/core/environment.ts b/src/core/environment.ts index d434628d..73c1853d 100644 --- a/src/core/environment.ts +++ b/src/core/environment.ts @@ -88,34 +88,44 @@ export function isDev(): boolean { */ export function isDebug(): boolean { - return process.env['NOORM_DEBUG'] === 'true'; + return isEnvTruthy(process.env['NOORM_DEBUG']); } /** - * Check if confirmations should be skipped. + * Parse an environment-variable string value into a boolean using the + * shared NOORM_YES-style truthiness rule. + * + * A value is truthy iff it is a non-empty string that is not `'0'` and not + * `'false'` in any letter case — this matches common shell conventions + * (`NOORM_YES=1`, `NOORM_YES=true`, `NOORM_YES=yes` all enable; `NOORM_YES=0` + * does not). No trimming: the raw string is compared as-is. Centralized so + * every NOORM_YES-shaped env var (and future ones, e.g. NOORM_DEBUG) parses + * the same way instead of each call site inventing its own truthy set. * - * Returns true if NOORM_YES is set, enabling non-interactive mode. + * @example + * isEnvTruthy(process.env['NOORM_YES']); // '1' -> true, '0' -> false */ -export function shouldSkipConfirmations(): boolean { +export function isEnvTruthy(value: string | undefined): boolean { - const yes = process.env['NOORM_YES']; + if (!value) return false; - return yes === '1' || yes === 'true'; + const normalized = value.toLowerCase(); + + return normalized !== '0' && normalized !== 'false'; } /** - * Check if output should be JSON. + * Check if confirmations should be skipped. * - * Returns true if NOORM_JSON is set, enabling headless/parseable output. + * Returns true if NOORM_YES is set to a truthy value, enabling + * non-interactive mode. */ -export function shouldOutputJson(): boolean { - - const json = process.env['NOORM_JSON']; +export function shouldSkipConfirmations(): boolean { - return json === '1' || json === 'true'; + return isEnvTruthy(process.env['NOORM_YES']); } diff --git a/src/core/identity/storage.ts b/src/core/identity/storage.ts index 3fb2710b..0ed1e40e 100644 --- a/src/core/identity/storage.ts +++ b/src/core/identity/storage.ts @@ -249,6 +249,16 @@ export async function loadPrivateKey(): Promise { } + const permissionsOk = await validateKeyPermissions(); + + if (!permissionsOk) { + + throw new Error( + `Insecure permissions on private key file (${PRIVATE_KEY_PATH}). Fix with: chmod 600 ${PRIVATE_KEY_PATH}`, + ); + + } + return content.trim(); } @@ -331,13 +341,28 @@ export async function hasKeyFiles(): Promise { // ============================================================================= /** - * Validate that private key file has correct permissions. + * Validate that a private key file has secure permissions. + * + * Threat-model check, not strict equality: passes when no group/other + * bits are set (mode & 0o077 === 0), so both 0600 and a stricter 0400 + * pass while 0644/0640/0660/0666 fail. * - * @returns True if permissions are 600 (owner read/write only) + * Windows emulates POSIX modes and `stat` commonly reports 0666 there + * regardless of actual ACLs, so this always returns true on win32 — + * otherwise the check would hard-lock every Windows user out. + * + * @param path - Key file to check (defaults to the private key path) + * @returns True if permissions are secure, or the platform is win32 */ -export async function validateKeyPermissions(): Promise { +export async function validateKeyPermissions(path: string = PRIVATE_KEY_PATH): Promise { + + if (process.platform === 'win32') { + + return true; + + } - const [stats, err] = await attempt(() => stat(PRIVATE_KEY_PATH)); + const [stats, err] = await attempt(() => stat(path)); if (err) { @@ -348,7 +373,7 @@ export async function validateKeyPermissions(): Promise { // Check mode (mask off file type bits) const mode = stats.mode & 0o777; - return mode === PRIVATE_KEY_MODE; + return (mode & 0o077) === 0; } diff --git a/src/core/index.ts b/src/core/index.ts index f3a5b566..80bcae64 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -97,7 +97,7 @@ export type { } from './lifecycle/index.js'; // Shared (table types, constants, and utilities) -export { NOORM_TABLES, filterFilesByPaths, matchesPathPrefix } from './shared/index.js'; +export { NOORM_TABLES, filterFilesByPaths } from './shared/index.js'; export type { NoormTableName, NoormDatabase, diff --git a/src/core/lifecycle/manager.ts b/src/core/lifecycle/manager.ts index 58124646..b347ac71 100644 --- a/src/core/lifecycle/manager.ts +++ b/src/core/lifecycle/manager.ts @@ -21,7 +21,7 @@ * await lifecycle.shutdown('user') * ``` */ -import { attempt } from '@logosdx/utils'; +import { attempt, runWithTimeout } from '@logosdx/utils'; import { observer } from '../observer.js'; import { getConnectionManager } from '../connection/manager.js'; @@ -340,7 +340,7 @@ export class LifecycleManager { // Execute all resources with timeout const [, err] = await attempt(() => - this.#executeWithTimeout(() => this.#executePhaseResources(resources), timeout), + runWithTimeout(() => this.#executePhaseResources(resources), { timeout, throws: true }), ); const durationMs = Date.now() - start; @@ -377,37 +377,6 @@ export class LifecycleManager { } - /** - * Execute a function with a timeout. - */ - async #executeWithTimeout(fn: () => Promise, timeoutMs: number): Promise { - - return new Promise((resolve, reject) => { - - const timer = setTimeout(() => { - - reject(new Error(`Timeout after ${timeoutMs}ms`)); - - }, timeoutMs); - - fn() - .then((result) => { - - clearTimeout(timer); - resolve(result); - - }) - .catch((error) => { - - clearTimeout(timer); - reject(error); - - }); - - }); - - } - /** * Get timeout for a phase. */ diff --git a/src/core/lock/manager.ts b/src/core/lock/manager.ts index 6b416f01..73d712cb 100644 --- a/src/core/lock/manager.ts +++ b/src/core/lock/manager.ts @@ -23,7 +23,7 @@ * } * ``` */ -import { attempt } from '@logosdx/utils'; +import { attempt, wait } from '@logosdx/utils'; import type { Kysely } from 'kysely'; import { observer } from '../observer.js'; @@ -96,15 +96,15 @@ class LockManager { while (true) { // Clean up expired locks first - await this.cleanupExpired(db, configName, opts.dialect); + await this.#cleanupExpired(db, configName, opts.dialect); // Try to get existing lock - const existing = await this.getLock(db, configName, opts.dialect); + const existing = await this.#getLock(db, configName, opts.dialect); if (!existing) { // No lock exists, create one - const lock = await this.createLock(db, configName, identity, opts); + const lock = await this.#createLock(db, configName, identity, opts); observer.emit('lock:acquired', { configName, identity, @@ -120,7 +120,7 @@ class LockManager { if (existing.lockedBy === identity) { // We already hold the lock - extend it - const lock = await this.extendLock(db, configName, identity, opts); + const lock = await this.#extendLock(db, configName, identity, opts); observer.emit('lock:acquired', { configName, identity, @@ -165,7 +165,7 @@ class LockManager { } // Wait and retry - await sleep(opts.pollInterval); + await wait(opts.pollInterval); } @@ -192,7 +192,7 @@ class LockManager { const tables = getNoormTables(dialect); // Check existing lock - const existing = await this.getLock(db, configName, dialect); + const existing = await this.#getLock(db, configName, dialect); if (!existing) { @@ -236,7 +236,7 @@ class LockManager { const ndb = noormDb(db, dialect); const tables = getNoormTables(dialect); - const existing = await this.getLock(db, configName, dialect); + const existing = await this.#getLock(db, configName, dialect); if (!existing) { return false; @@ -323,7 +323,7 @@ class LockManager { dialect: Dialect = 'postgres', ): Promise { - const existing = await this.getLock(db, configName, dialect); + const existing = await this.#getLock(db, configName, dialect); if (!existing) { @@ -340,7 +340,7 @@ class LockManager { if (existing.expiresAt < new Date()) { // Clean it up - await this.cleanupExpired(db, configName, dialect); + await this.#cleanupExpired(db, configName, dialect); throw new LockExpiredError(configName, identity, existing.expiresAt); @@ -369,7 +369,7 @@ class LockManager { // Validate first await this.validate(db, configName, identity, opts.dialect); - return this.extendLock(db, configName, identity, options); + return this.#extendLock(db, configName, identity, options); } @@ -388,9 +388,9 @@ class LockManager { ): Promise { // Clean up expired first - await this.cleanupExpired(db, configName, dialect); + await this.#cleanupExpired(db, configName, dialect); - const lock = await this.getLock(db, configName, dialect); + const lock = await this.#getLock(db, configName, dialect); return { isLocked: lock !== null, @@ -406,7 +406,7 @@ class LockManager { /** * Get lock from database, or null if none exists. */ - private async getLock( + async #getLock( db: Kysely, configName: string, dialect: Dialect, @@ -439,7 +439,7 @@ class LockManager { /** * Create a new lock in the database. */ - private async createLock( + async #createLock( db: Kysely, configName: string, identity: string, @@ -474,7 +474,7 @@ class LockManager { /** * Extend an existing lock. */ - private async extendLock( + async #extendLock( db: Kysely, configName: string, identity: string, @@ -506,7 +506,7 @@ class LockManager { .execute(); // Fetch the updated lock - const lock = await this.getLock(db, configName, dialect); + const lock = await this.#getLock(db, configName, dialect); return lock!; @@ -515,7 +515,7 @@ class LockManager { /** * Clean up expired locks. */ - private async cleanupExpired( + async #cleanupExpired( db: Kysely, configName: string, dialect: Dialect = 'postgres', @@ -593,15 +593,5 @@ export function resetLockManager(): void { } -// ───────────────────────────────────────────────────────────── -// Utility -// ───────────────────────────────────────────────────────────── - -function sleep(ms: number): Promise { - - return new Promise((resolve) => setTimeout(resolve, ms)); - -} - // Export class for typing export { LockManager }; diff --git a/src/core/logger/index.ts b/src/core/logger/index.ts index 93e9f8fb..e30e5fb5 100644 --- a/src/core/logger/index.ts +++ b/src/core/logger/index.ts @@ -32,6 +32,9 @@ export { generateMessage, formatEntry, serializeEntry } from './formatter.js'; // Color Formatter export { formatColorLine, formatDuration, STATUS_ICONS } from './color.js'; +// Timestamp Formatter +export { formatLogTimestamp, formatLogTimestampIso } from './timestamp.js'; + // Rotation export { parseSize, diff --git a/src/core/logger/logger.ts b/src/core/logger/logger.ts index a3df4721..4c662833 100644 --- a/src/core/logger/logger.ts +++ b/src/core/logger/logger.ts @@ -23,8 +23,6 @@ import { join, dirname } from 'node:path'; import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; -import dayjs from 'dayjs'; - import { observer, type NoormEvents } from '../observer.js'; import { isCi } from '../environment.js'; import { classifyEvent, shouldLog } from './classifier.js'; @@ -32,6 +30,7 @@ import { generateMessage } from './formatter.js'; import { formatColorLine } from './color.js'; import { filterData } from './redact.js'; import { checkAndRotate } from './rotation.js'; +import { formatLogTimestamp, formatLogTimestampIso } from './timestamp.js'; import type { LogLevel, LoggerConfig, LoggerState, EntryLevel } from './types.js'; import { DEFAULT_LOGGER_CONFIG } from './types.js'; import type { Settings } from '../settings/types.js'; @@ -506,15 +505,16 @@ export class Logger { if (this.#json) { - // JSON mode for console + // Event stream, not a command result — stays on diagnostics even in + // JSON mode, so `result()`'s payload is the only thing on stdout. const entry = this.#buildJsonEntry(level, event, message, data, hasData); - this.#writers.console.write(JSON.stringify(entry) + '\n'); + this.#writers.diagnostics.write(JSON.stringify(entry) + '\n'); } else if (this.#color) { // Colored format: [timestamp] icon event message key=value key=value - const timestamp = dayjs().format('YY-MM-DD HH:mm:ss'); + const timestamp = formatLogTimestamp(new Date()); const colorLine = formatColorLine( level, event, @@ -527,7 +527,7 @@ export class Logger { else { // Plain format: [timestamp] [LEVEL] [event] message - const timestamp = dayjs().format('YY-MM-DD HH:mm:ss'); + const timestamp = formatLogTimestamp(new Date()); const levelLabel = level.toUpperCase().padEnd(5); let line = `[${timestamp}] [${levelLabel}] [${event}] ${message}`; @@ -586,7 +586,7 @@ export class Logger { ): Record { const entry: Record = { - time: dayjs().format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + time: formatLogTimestampIso(new Date()), type: event, level, message, diff --git a/src/core/logger/timestamp.ts b/src/core/logger/timestamp.ts new file mode 100644 index 00000000..9d214bff --- /dev/null +++ b/src/core/logger/timestamp.ts @@ -0,0 +1,58 @@ +/** + * Log timestamp formatting. + * + * Native `Date` formatters for the two fixed, non-locale, + * non-timezone-conversion formats the logger writes on every entry — + * avoids pulling in dayjs on the per-line hot path. + */ + +/** + * Format a Date as `YY-MM-DD HH:mm:ss` (local time) — the console log + * line timestamp. + * + * @example + * formatLogTimestamp(new Date(2024, 0, 15, 10, 30, 0)); // '24-01-15 10:30:00' + */ +export function formatLogTimestamp(d: Date): string { + + const year = String(d.getFullYear()).slice(-2); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + +} + +/** + * Format a Date as `YYYY-MM-DDTHH:mm:ss.SSS±HH:mm` (local time with UTC + * offset) — the JSON log entry's `time` field. + * + * The offset is the local UTC offset, not a literal `Z` suffix. + * `Date#getTimezoneOffset()` returns UTC-minus-local in minutes (positive + * when local is behind UTC), so the sign is flipped relative to it. + * + * @example + * formatLogTimestampIso(new Date(Date.UTC(2024, 0, 15, 10, 30, 0, 123))); // '2024-01-15T10:30:00.123+00:00' in UTC + */ +export function formatLogTimestampIso(d: Date): string { + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const seconds = String(d.getSeconds()).padStart(2, '0'); + const millis = String(d.getMilliseconds()).padStart(3, '0'); + + const offset = d.getTimezoneOffset(); + const sign = offset <= 0 ? '+' : '-'; + const absOffset = Math.abs(offset); + const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0'); + const offsetMinutes = String(absOffset % 60).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millis}${sign}${offsetHours}:${offsetMinutes}`; + +} diff --git a/src/core/observer.ts b/src/core/observer.ts index f162cf07..a6bda480 100644 --- a/src/core/observer.ts +++ b/src/core/observer.ts @@ -27,6 +27,7 @@ import type { TransferEvents } from './transfer/events.js'; import type { DtEvents } from './dt/events.js'; import type { LogLevel } from './logger/types.js'; import type { TruncateResult, TeardownResult } from './teardown/types.js'; +import { isDebug } from './environment.js'; /** * All events emitted by noorm core modules. @@ -160,7 +161,16 @@ export interface NoormEvents extends SettingsEvents, UpdateEvents, VaultEvents, 'identity:not-found': void; // Connection - 'connection:open': { configName: string; dialect: string }; + // host/port/database are carried so a log line names the database that was + // actually connected to — a config name alone cannot, since env vars can + // redirect it (see #51). + 'connection:open': { + configName: string; + dialect: string; + host?: string; + port?: number; + database?: string; + }; 'connection:close': { configName: string }; 'connection:error': { configName: string; error: string }; @@ -244,7 +254,7 @@ export type NoormEventCallback = ObserverEngine.Event */ export const observer = new ObserverEngine({ name: 'noorm', - spy: process.env['NOORM_DEBUG'] + spy: isDebug() ? (action) => console.error(`[noorm:${action.fn}] ${String(action.event)}`) : undefined, }); diff --git a/src/core/policy/check.ts b/src/core/policy/check.ts index c3a266b6..f8a8f5b8 100644 --- a/src/core/policy/check.ts +++ b/src/core/policy/check.ts @@ -154,6 +154,23 @@ export function assertPolicy( } +/** + * Whether a config is visible on a channel — the mcp-channel invisibility + * rule, extracted so `list_configs` and `session.ts`'s `connect()` share one + * fail-closed implementation instead of two independently drifting copies. + * + * Fails closed: missing `access` is treated the same as `access.mcp === + * false`. The `user` channel is always visible; only `mcp` can be hidden. + * + * @example + * isVisibleToChannel(config.access, 'mcp'); // false when access.mcp === false or access is missing + */ +export function isVisibleToChannel(access: ConfigAccess | undefined, channel: Channel): boolean { + + return !(channel === 'mcp' && (!access || access.mcp === false)); + +} + /** * Display-only shorthand for "this config isn't wide open" — used by TUI * styling, `config list`, and settings rule matching. Never an enforcement @@ -167,3 +184,22 @@ export function guarded(target: PolicyTarget): boolean { return target.access.user !== 'admin'; } + +/** + * Formats access as `user: mcp:` — the shared display + * string for `noorm config list` and the TUI config list screen, so the + * format can't drift between the two. Omitted entirely (`null`) for fully + * open (admin/admin) configs, per `guarded()`. + * + * @example + * formatAccessTag({ name: 'prod', access: { user: 'operator', mcp: false } }); // 'user:operator mcp:off' + */ +export function formatAccessTag(config: { name: string; access: ConfigAccess }): string | null { + + if (!guarded(config)) return null; + + const { access } = config; + + return `user:${access.user} mcp:${access.mcp === false ? 'off' : access.mcp}`; + +} diff --git a/src/core/policy/index.ts b/src/core/policy/index.ts index a921fca8..217fd9a6 100644 --- a/src/core/policy/index.ts +++ b/src/core/policy/index.ts @@ -4,7 +4,7 @@ * One central policy check for every channel (CLI/TUI/SDK, MCP) and every * config-scoped action. */ -export { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, guarded } from './check.js'; +export { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, formatAccessTag, guarded, isVisibleToChannel } from './check.js'; export { classifyStatements } from './classify.js'; export type { SqlClass } from './classify.js'; export { GUARDED_ACCESS, OPEN_ACCESS, resolveLegacyAccess } from './legacy-access.js'; diff --git a/src/core/policy/matrix.ts b/src/core/policy/matrix.ts index 4e6d0761..6e615268 100644 --- a/src/core/policy/matrix.ts +++ b/src/core/policy/matrix.ts @@ -14,6 +14,7 @@ export const MATRIX: Record> = { 'change:run': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, 'change:ff': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, 'change:revert': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'change:rm': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, 'run:build': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, 'run:file': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, diff --git a/src/core/policy/types.ts b/src/core/policy/types.ts index 331b9618..1f0220c5 100644 --- a/src/core/policy/types.ts +++ b/src/core/policy/types.ts @@ -35,7 +35,7 @@ export interface ConfigAccess { export type Permission = | 'explore' | 'sql:read' | 'sql:write' | 'sql:ddl' - | 'change:run' | 'change:ff' | 'change:revert' + | 'change:run' | 'change:ff' | 'change:revert' | 'change:rm' | 'run:build' | 'run:file' | 'run:dir' | 'db:create' | 'db:reset' | 'db:destroy' | 'config:rm'; diff --git a/src/core/runner/runner.ts b/src/core/runner/runner.ts index d643d643..699a0bcc 100644 --- a/src/core/runner/runner.ts +++ b/src/core/runner/runner.ts @@ -587,6 +587,27 @@ export async function executeFiles( // Convert FileInput[] to string[] for preview/dryRun modes const filepaths = files.map((f) => f.path); + // A duplicate filepath would make updateFileExecution's + // WHERE change_id = ? AND filepath = ? match more than one row later + // in this same operation -- silently updating N records instead of + // failing. Uniqueness of the discovered batch is an invariant this + // function depends on, not something that happens to hold today. + const duplicateFilepaths = findDuplicates(filepaths); + + if (duplicateFilepaths.length > 0) { + + const error = `Duplicate files in execution batch: ${duplicateFilepaths.join(', ')}`; + + observer.emit('error', { + source: 'runner:duplicate-files', + error: new Error(error), + context: { duplicates: duplicateFilepaths }, + }); + + return createFailedBatchResult(error, performance.now() - start); + + } + // Handle preview mode if (opts.preview) { @@ -741,6 +762,19 @@ export async function executeFiles( } + // When abortOnError stops the loop early, fewer results than files is + // by design -- the remaining files were never attempted, only marked + // skipped above. The invariant "one result per file" only holds for a + // full pass; this guards against a future regression re-introducing + // double (or dropped) execution within that pass. + if (!failed && results.length !== files.length) { + + throw new Error( + `Execution accounting mismatch: expected ${files.length} results, got ${results.length}`, + ); + + } + // Compute stats const filesRun = results.filter((r) => r.status === 'success').length; const filesSkipped = results.filter((r) => r.status === 'skipped').length; @@ -901,7 +935,11 @@ async function executeSingleFileWithUpdate( // For 'change', the change-level check was already done by the caller if (changeType !== 'change') { - const needsRunResult = await tracker.needsRun(relFilepath, finalChecksum, options.force); + // Exclude this operation's own rows -- createFileRecords already + // inserted a pending row for every file in this batch before this + // loop started, so without the exclusion the newest row is always + // this run's own pending record and every file reads as "new". + const needsRunResult = await tracker.needsRun(relFilepath, finalChecksum, options.force, operationId); if (!needsRunResult.needsRun) { @@ -939,11 +977,13 @@ async function executeSingleFileWithUpdate( if (execErrMsg) { + const error = execErrMsg + (await describePriorSuccesses(tracker, relFilepath, operationId)); + const result: FileResult = { filepath, checksum: finalChecksum, status: 'failed', - error: execErrMsg, + error, durationMs, }; @@ -952,14 +992,14 @@ async function executeSingleFileWithUpdate( relFilepath, 'failed', Math.round(durationMs), - execErrMsg, + error, ); observer.emit('file:after', { filepath, status: 'failed', durationMs, - error: execErrMsg, + error, }); return result; @@ -1119,11 +1159,13 @@ async function executeSingleFile( if (execErrMsg) { + const error = execErrMsg + (await describePriorSuccesses(tracker, relFilepath, operationId)); + const result: FileResult = { filepath, checksum, status: 'failed', - error: execErrMsg, + error, durationMs, }; @@ -1132,7 +1174,7 @@ async function executeSingleFile( filepath: relFilepath, checksum, status: 'failed', - errorMessage: execErrMsg, + errorMessage: error, durationMs: Math.round(durationMs), }); @@ -1140,7 +1182,7 @@ async function executeSingleFile( filepath, status: 'failed', durationMs, - error: execErrMsg, + error, }); return result; @@ -1438,3 +1480,51 @@ function formatErrorChain(err: Error): string { } +/** + * Find values that occur more than once, without depending on discovery + * order. Used to reject a duplicate discovered file loudly rather than let + * it silently update the same execution record twice. + */ +function findDuplicates(values: string[]): string[] { + + const seen = new Set(); + const duplicates = new Set(); + + for (const value of values) { + + if (seen.has(value)) duplicates.add(value); + seen.add(value); + + } + + return [...duplicates]; + +} + +/** + * Build a suffix describing prior successful executions of a file, for + * appending to a failure message. + * + * @example + * ```typescript + * const error = execErrMsg + await describePriorSuccesses(tracker, relFilepath, operationId) + * // "already exists; 1 prior successful execution (build:2026-07-24T…, operation 7)" + * ``` + */ +async function describePriorSuccesses( + tracker: Tracker, + relFilepath: string, + excludeOperationId: number, +): Promise { + + const prior = await tracker.priorSuccessfulExecutions(relFilepath, excludeOperationId); + + if (prior.length === 0) return ''; + + const [latest] = prior; + const plural = prior.length === 1 ? '' : 's'; + + return `; ${prior.length} prior successful execution${plural} (${latest!.operationName}, operation ${latest!.operationId})`; + +} + diff --git a/src/core/runner/tracker.ts b/src/core/runner/tracker.ts index 823e1eda..5eb09a51 100644 --- a/src/core/runner/tracker.ts +++ b/src/core/runner/tracker.ts @@ -93,9 +93,21 @@ export class Tracker { * @param filepath - File path to check * @param checksum - Current file checksum * @param force - Force re-run regardless of status + * @param excludeOperationId - Operation whose own rows should be ignored. + * `executeFiles` inserts a `pending` row for every discovered file + * *before* running any of them (for batch visibility), so within that + * same operation the newest row for a file is always its own pending + * record — reading as "new" forever and making checksum-based skipping + * unreachable. Passing the running operation's id here excludes those + * rows so the lookup finds the last *completed* operation instead. * @returns Whether file needs to run and why */ - async needsRun(filepath: string, checksum: string, force: boolean): Promise { + async needsRun( + filepath: string, + checksum: string, + force: boolean, + excludeOperationId?: number, + ): Promise { // Force always runs if (force) { @@ -106,23 +118,31 @@ export class Tracker { // Find most recent execution for this file and config // Also fetch the parent change status to check for stale + let query = (this.#ndb + .selectFrom(this.#tables.executions) + .innerJoin( + this.#tables.change, + `${this.#tables.change}.id`, + `${this.#tables.executions}.change_id`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .select((eb: any) => [ + eb.ref(`${this.#tables.executions}.checksum`).as('checksum'), + eb.ref(`${this.#tables.executions}.status`).as('exec_status'), + eb.ref(`${this.#tables.change}.status`).as('change_status'), + ]) + .where(`${this.#tables.executions}.filepath`, '=', filepath) + .where(`${this.#tables.change}.config_name`, '=', this.#configName); + + if (excludeOperationId !== undefined) { + + query = query.where(`${this.#tables.executions}.change_id`, '<>', excludeOperationId); + + } + const [record, err] = await attempt(() => - (this.#ndb - .selectFrom(this.#tables.executions) - .innerJoin( - this.#tables.change, - `${this.#tables.change}.id`, - `${this.#tables.executions}.change_id`, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) as any) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .select((eb: any) => [ - eb.ref(`${this.#tables.executions}.checksum`).as('checksum'), - eb.ref(`${this.#tables.executions}.status`).as('exec_status'), - eb.ref(`${this.#tables.change}.status`).as('change_status'), - ]) - .where(`${this.#tables.executions}.filepath`, '=', filepath) - .where(`${this.#tables.change}.config_name`, '=', this.#configName) + query .orderBy(`${this.#tables.executions}.id`, 'desc') .limit(1) .executeTakeFirst(), @@ -136,8 +156,11 @@ export class Tracker { context: { filepath, operation: 'needs-run-check' }, }); - // On error, assume file needs to run - return { needsRun: true, reason: 'new' }; + // Distinct from 'new': the SELECT itself failed, so whether a + // record exists is genuinely unknown. Reporting this as 'new' + // would make a transient read failure indistinguishable from a + // first-ever run in logs and audits. + return { needsRun: true, reason: 'error' }; } @@ -531,12 +554,18 @@ export class Tracker { } - // Check if any rows were updated + // Exactly one row must match. Zero means the pending record is + // missing; more than one means (change_id, filepath) isn't unique -- + // exactly the shape a duplicate discovered file would take, and + // tolerating it here would silently update N rows and mask the + // duplicate from ever surfacing. const numUpdated = Number(result?.numUpdatedRows ?? 0); - if (numUpdated === 0) { + if (numUpdated !== 1) { - const errMsg = `No execution record found for ${filepath} (operationId: ${operationId})`; + const errMsg = numUpdated === 0 + ? `No execution record found for ${filepath} (operationId: ${operationId})` + : `Expected exactly 1 execution record for ${filepath} (operationId: ${operationId}), matched ${numUpdated}`; observer.emit('error', { source: 'runner', @@ -552,6 +581,67 @@ export class Tracker { } + /** + * Find prior successful executions of a file, most recent first. + * + * Called on the failure path only. A file that fails after a history of + * clean runs at this config rules out a broken file and points instead + * at drift between what the tracker expects and what the target + * database actually has — the detail that would have answered #54 + * immediately instead of reading as intermittent double execution. + * + * @param filepath - File path to check (relative, as stored) + * @param excludeOperationId - Omit rows belonging to the operation + * currently running, so a failure doesn't cite itself as history. + */ + async priorSuccessfulExecutions( + filepath: string, + excludeOperationId?: number, + ): Promise> { + + let query = (this.#ndb + .selectFrom(this.#tables.executions) + .innerJoin( + this.#tables.change, + `${this.#tables.change}.id`, + `${this.#tables.executions}.change_id`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .select((eb: any) => [ + eb.ref(`${this.#tables.change}.name`).as('operationName'), + eb.ref(`${this.#tables.executions}.change_id`).as('operationId'), + ]) + .where(`${this.#tables.executions}.filepath`, '=', filepath) + .where(`${this.#tables.executions}.status`, '=', 'success') + .where(`${this.#tables.change}.config_name`, '=', this.#configName); + + if (excludeOperationId !== undefined) { + + query = query.where(`${this.#tables.executions}.change_id`, '<>', excludeOperationId); + + } + + const [rows, err] = await attempt(() => + query.orderBy(`${this.#tables.executions}.id`, 'desc').execute(), + ); + + if (err) { + + observer.emit('error', { + source: 'runner', + error: err, + context: { filepath, operation: 'prior-successful-executions' }, + }); + + return []; + + } + + return rows ?? []; + + } + /** * Mark remaining pending files as skipped. * diff --git a/src/core/runner/types.ts b/src/core/runner/types.ts index f47711fd..fc8cc6f1 100644 --- a/src/core/runner/types.ts +++ b/src/core/runner/types.ts @@ -220,8 +220,12 @@ export interface BatchResult { /** * Why a file needs to run. + * + * 'error' is distinct from 'new': it means the lookup that would have + * decided this failed to read at all, so treat it as needing a run without + * claiming it's a first-ever execution. */ -export type RunReason = 'new' | 'changed' | 'failed' | 'stale' | 'force'; +export type RunReason = 'new' | 'changed' | 'failed' | 'stale' | 'force' | 'error'; /** * Result of checking if a file needs to run. diff --git a/src/core/settings/schema.ts b/src/core/settings/schema.ts index 91f55e49..6282229a 100644 --- a/src/core/settings/schema.ts +++ b/src/core/settings/schema.ts @@ -6,6 +6,8 @@ */ import { z } from 'zod'; +import { PortSchema } from '../connection/defaults.js'; + // ───────────────────────────────────────────────────────────── // Base Schemas // ───────────────────────────────────────────────────────────── @@ -30,14 +32,7 @@ const ConnectionTypeSchema = z.enum(['local', 'remote']); */ const LogLevelSchema = z.enum(['silent', 'error', 'warn', 'info', 'verbose']); -/** - * Port number validation. - */ -const PortSchema = z - .number() - .int() - .min(1, 'Port must be at least 1') - .max(65535, 'Port must be at most 65535'); +export { PortSchema }; /** * File size pattern (e.g., '10mb', '100kb'). diff --git a/src/core/shared/files.ts b/src/core/shared/files.ts index 56a70e68..91a3f4f1 100644 --- a/src/core/shared/files.ts +++ b/src/core/shared/files.ts @@ -19,26 +19,26 @@ import { relative, sep } from 'path'; * 4. Exclude wins if both match (consistent with rule evaluation) * * @param files - Absolute file paths (e.g., from discoverFiles()) - * @param baseDir - Base directory for relative path matching - * @param include - Relative paths to include (e.g., ['sql/tables', 'sql/views']) - * @param exclude - Relative paths to exclude (e.g., ['sql/archive']) + * @param baseDir - Base directory for relative path matching (the resolved sql dir) + * @param include - Relative paths to include (e.g., ['01_tables', '02_views']) + * @param exclude - Relative paths to exclude (e.g., ['archive']) * @returns Filtered array of absolute file paths * * @example * ```typescript * const files = [ - * '/project/sql/tables/users.sql', - * '/project/sql/views/active.sql', + * '/project/sql/01_tables/users.sql', + * '/project/sql/02_views/active.sql', * '/project/sql/archive/old.sql', * ] * * const filtered = filterFilesByPaths( * files, - * '/project', - * ['sql/tables', 'sql/views'], - * ['sql/archive'] + * '/project/sql', + * ['01_tables', '02_views'], + * ['archive'] * ) - * // ['/project/sql/tables/users.sql', '/project/sql/views/active.sql'] + * // ['/project/sql/01_tables/users.sql', '/project/sql/02_views/active.sql'] * ``` */ export function filterFilesByPaths( @@ -81,18 +81,3 @@ export function filterFilesByPaths( }); } - -/** - * Check if a file path matches a pattern prefix. - * - * @param filePath - Relative file path to check - * @param pattern - Pattern prefix to match against - * @returns True if the file path starts with the pattern - */ -export function matchesPathPrefix(filePath: string, pattern: string): boolean { - - const normalizedPattern = pattern.split(/[\\/]/).join(sep); - - return filePath === normalizedPattern || filePath.startsWith(normalizedPattern + sep); - -} diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index f876846e..9bd64c41 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -9,7 +9,7 @@ export { getSqlErrorMessage } from './errors.js'; // Files -export { filterFilesByPaths, matchesPathPrefix } from './files.js'; +export { filterFilesByPaths } from './files.js'; // Dialect quoting export { createDialectQuoting, type DialectQuoting } from './dialect-quoting.js'; diff --git a/src/core/state/index.ts b/src/core/state/index.ts index 9ab38669..367c940d 100644 --- a/src/core/state/index.ts +++ b/src/core/state/index.ts @@ -6,6 +6,7 @@ import { StateManager } from './manager.js'; export { StateManager }; +export { InvalidSecretKeyError, isValidSecretKey } from './manager.js'; export type { StateManagerOptions } from './manager.js'; export * from './types.js'; export { migrateState, needsMigration } from './migrations.js'; diff --git a/src/core/state/manager.ts b/src/core/state/manager.ts index 4ed1ee65..be75244f 100644 --- a/src/core/state/manager.ts +++ b/src/core/state/manager.ts @@ -6,10 +6,11 @@ * * Encryption uses the user's private key from ~/.noorm/identity.key */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { attemptSync, attempt } from '@logosdx/utils'; import type { Config } from '../config/types.js'; +import { assertCanDeleteConfig, type SettingsProvider } from '../config/resolver.js'; import type { KnownUser } from '../identity/types.js'; import { loadPrivateKey } from '../identity/storage.js'; import { resolveLegacyAccess } from '../policy/index.js'; @@ -77,20 +78,23 @@ export interface StateManagerOptions { */ export class StateManager { - private state: State | null = null; - private privateKey: string | undefined; - private statePath: string; - private loaded = false; + #state: State | null = null; + #privateKey: string | undefined; + #statePath: string; + #loaded = false; + // eslint-disable-next-line no-unused-private-class-members + #projectRoot: string; constructor( - private readonly projectRoot: string, + projectRoot: string, options: StateManagerOptions = {}, ) { - this.privateKey = options.privateKey; + this.#projectRoot = projectRoot; + this.#privateKey = options.privateKey; const stateDir = options.stateDir ?? DEFAULT_STATE_DIR; const stateFile = options.stateFile ?? DEFAULT_STATE_FILE; - this.statePath = join(projectRoot, stateDir, stateFile); + this.#statePath = join(projectRoot, stateDir, stateFile); } @@ -107,15 +111,15 @@ export class StateManager { */ async load(): Promise { - if (this.loaded) return; + if (this.#loaded) return; // Try to load private key if not provided - if (!this.privateKey) { + if (!this.#privateKey) { const [key] = await attempt(() => loadPrivateKey()); if (key) { - this.privateKey = key; + this.#privateKey = key; } @@ -124,10 +128,10 @@ export class StateManager { const currentVersion = getPackageVersion(); // New project - no state file yet - if (!existsSync(this.statePath)) { + if (!existsSync(this.#statePath)) { - this.state = createEmptyState(currentVersion); - this.loaded = true; + this.#state = createEmptyState(currentVersion); + this.#loaded = true; observer.emit('state:loaded', { configCount: 0, activeConfig: null, @@ -139,7 +143,7 @@ export class StateManager { } // Existing state file - require private key - if (!this.privateKey) { + if (!this.#privateKey) { throw new Error( 'Private key required to decrypt state. ' + 'Set up identity with: noorm init', @@ -147,7 +151,7 @@ export class StateManager { } - const [raw, readErr] = attemptSync(() => readFileSync(this.statePath, 'utf8')); + const [raw, readErr] = attemptSync(() => readFileSync(this.#statePath, 'utf8')); if (readErr) { observer.emit('error', { source: 'state', error: readErr }); @@ -163,7 +167,7 @@ export class StateManager { } - const [decrypted, decryptErr] = attemptSync(() => decrypt(payload!, this.privateKey!)); + const [decrypted, decryptErr] = attemptSync(() => decrypt(payload!, this.#privateKey!)); if (decryptErr) { observer.emit('error', { source: 'state', error: decryptErr }); @@ -194,7 +198,7 @@ export class StateManager { const needsVersionMigration = needsMigration(schemaMigratedState, currentVersion) || needsStateMigration(stateRecord); - this.state = migrateState(schemaMigratedState, currentVersion); + this.#state = migrateState(schemaMigratedState, currentVersion); // Raw-data-boundary invariant: migrations above guarantee every // config carries `access`, but a hand-edited or corrupted state @@ -215,7 +219,7 @@ export class StateManager { // to operator/viewer, never the admin/admin fallback. let backfilledAccess = false; - for (const config of Object.values(this.state.configs)) { + for (const config of Object.values(this.#state.configs)) { if (!config.access) { @@ -232,19 +236,19 @@ export class StateManager { } - this.loaded = true; + this.#loaded = true; // Persist if migrations were applied or the backfill above mutated a config if (needsVersionMigration || backfilledAccess) { - this.persist(); + this.#persist(); } observer.emit('state:loaded', { - configCount: Object.keys(this.state.configs).length, - activeConfig: this.state.activeConfig, - version: this.state.version, + configCount: Object.keys(this.#state.configs).length, + activeConfig: this.#state.activeConfig, + version: this.#state.version, }); } @@ -263,7 +267,7 @@ export class StateManager { const [key] = await attempt(() => loadPrivateKey()); if (key) { - this.privateKey = key; + this.#privateKey = key; return true; @@ -278,9 +282,9 @@ export class StateManager { * * Requires private key to be set. */ - private persist(): void { + #persist(): void { - if (!this.privateKey) { + if (!this.#privateKey) { throw new Error( 'Private key required to save state. ' + 'Set up identity with: noorm init', @@ -288,9 +292,9 @@ export class StateManager { } - const state = this.getState(); + const state = this.#getState(); - const dir = dirname(this.statePath); + const dir = dirname(this.#statePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -298,10 +302,10 @@ export class StateManager { } const json = JSON.stringify(state); - const payload = encrypt(json, this.privateKey); + const payload = encrypt(json, this.#privateKey); const [, writeErr] = attemptSync(() => - writeFileSync(this.statePath, JSON.stringify(payload, null, 2)), + writeFileSync(this.#statePath, JSON.stringify(payload, null, 2), { mode: 0o600 }), ); if (writeErr) { @@ -311,6 +315,9 @@ export class StateManager { } + // Ensure permissions are correct (writeFile mode may not work on all platforms) + attemptSync(() => chmodSync(this.#statePath, 0o600)); + observer.emit('state:persisted', { configCount: Object.keys(state.configs).length, }); @@ -320,15 +327,15 @@ export class StateManager { /** * Get the loaded state, throwing if not loaded. */ - private getState(): State { + #getState(): State { - if (!this.loaded || !this.state) { + if (!this.#loaded || !this.#state) { throw new Error('StateManager not loaded. Call load() first.'); } - return this.state; + return this.#state; } @@ -341,7 +348,7 @@ export class StateManager { */ getConfig(name: string): Config | null { - const state = this.getState(); + const state = this.#getState(); return state.configs[name] ?? null; @@ -352,11 +359,11 @@ export class StateManager { */ async setConfig(name: string, config: Config): Promise { - const state = this.getState(); + const state = this.#getState(); const isNew = !state.configs[name]; state.configs[name] = { ...config }; - this.persist(); + this.#persist(); observer.emit(isNew ? 'config:created' : 'config:updated', { name, @@ -367,10 +374,14 @@ export class StateManager { /** * Delete a config and its secrets. + * + * @throws ConfigStageLockedError if the config is linked to a locked stage. */ - async deleteConfig(name: string): Promise { + async deleteConfig(name: string, settings?: SettingsProvider): Promise { - const state = this.getState(); + assertCanDeleteConfig(name, settings); + + const state = this.#getState(); delete state.configs[name]; delete state.secrets[name]; @@ -380,7 +391,7 @@ export class StateManager { } - this.persist(); + this.#persist(); observer.emit('config:deleted', { name }); } @@ -390,7 +401,7 @@ export class StateManager { */ listConfigs(): ConfigSummary[] { - const state = this.getState(); + const state = this.#getState(); return Object.entries(state.configs).map(([name, config]) => ({ name, @@ -409,7 +420,7 @@ export class StateManager { */ getActiveConfig(): Config | null { - const state = this.getState(); + const state = this.#getState(); if (!state.activeConfig) return null; return state.configs[state.activeConfig] ?? null; @@ -421,7 +432,7 @@ export class StateManager { */ getActiveConfigName(): string | null { - const state = this.getState(); + const state = this.#getState(); return state.activeConfig; @@ -432,7 +443,7 @@ export class StateManager { */ async setActiveConfig(name: string): Promise { - const state = this.getState(); + const state = this.#getState(); if (!state.configs[name]) { throw new Error(`Config "${name}" does not exist.`); @@ -441,7 +452,7 @@ export class StateManager { const previous = state.activeConfig; state.activeConfig = name; - this.persist(); + this.#persist(); observer.emit('config:activated', { name, previous }); @@ -456,7 +467,7 @@ export class StateManager { */ getSecret(configName: string, key: string): string | null { - const state = this.getState(); + const state = this.#getState(); return state.secrets[configName]?.[key] ?? null; @@ -467,7 +478,7 @@ export class StateManager { */ getAllSecrets(configName: string): Record { - const state = this.getState(); + const state = this.#getState(); return state.secrets[configName] ? { ...state.secrets[configName] } : {}; @@ -478,7 +489,13 @@ export class StateManager { */ async setSecret(configName: string, key: string, value: string): Promise { - const state = this.getState(); + if (!isValidSecretKey(key)) { + + throw new InvalidSecretKeyError(key); + + } + + const state = this.#getState(); if (!state.configs[configName]) { @@ -493,7 +510,7 @@ export class StateManager { } state.secrets[configName][key] = value; - this.persist(); + this.#persist(); observer.emit('secret:set', { configName, key }); @@ -504,12 +521,12 @@ export class StateManager { */ async deleteSecret(configName: string, key: string): Promise { - const state = this.getState(); + const state = this.#getState(); if (state.secrets[configName]) { delete state.secrets[configName][key]; - this.persist(); + this.#persist(); observer.emit('secret:deleted', { configName, key }); @@ -522,7 +539,7 @@ export class StateManager { */ listSecrets(configName: string): string[] { - const state = this.getState(); + const state = this.#getState(); return Object.keys(state.secrets[configName] ?? {}); @@ -542,7 +559,7 @@ export class StateManager { */ getGlobalSecret(key: string): string | null { - const state = this.getState(); + const state = this.#getState(); return state.globalSecrets[key] ?? null; @@ -553,7 +570,7 @@ export class StateManager { */ getAllGlobalSecrets(): Record { - const state = this.getState(); + const state = this.#getState(); return { ...state.globalSecrets }; @@ -569,9 +586,9 @@ export class StateManager { */ async setGlobalSecret(key: string, value: string): Promise { - const state = this.getState(); + const state = this.#getState(); state.globalSecrets[key] = value; - this.persist(); + this.#persist(); observer.emit('global-secret:set', { key }); @@ -582,12 +599,12 @@ export class StateManager { */ async deleteGlobalSecret(key: string): Promise { - const state = this.getState(); + const state = this.#getState(); if (key in state.globalSecrets) { delete state.globalSecrets[key]; - this.persist(); + this.#persist(); observer.emit('global-secret:deleted', { key }); @@ -600,7 +617,7 @@ export class StateManager { */ listGlobalSecrets(): string[] { - const state = this.getState(); + const state = this.#getState(); return Object.keys(state.globalSecrets); @@ -615,7 +632,7 @@ export class StateManager { */ getKnownUsers(): Record { - const state = this.getState(); + const state = this.#getState(); return { ...state.knownUsers }; @@ -626,7 +643,7 @@ export class StateManager { */ getKnownUser(identityHash: string): KnownUser | null { - const state = this.getState(); + const state = this.#getState(); return state.knownUsers[identityHash] ?? null; @@ -639,7 +656,7 @@ export class StateManager { */ findKnownUsersByEmail(email: string): KnownUser[] { - const state = this.getState(); + const state = this.#getState(); return Object.values(state.knownUsers).filter((u) => u.email === email); @@ -652,9 +669,9 @@ export class StateManager { */ async addKnownUser(user: KnownUser): Promise { - const state = this.getState(); + const state = this.#getState(); state.knownUsers[user.identityHash] = user; - this.persist(); + this.#persist(); observer.emit('known-user:added', { email: user.email, @@ -668,7 +685,7 @@ export class StateManager { */ async addKnownUsers(users: KnownUser[]): Promise { - const state = this.getState(); + const state = this.#getState(); for (const user of users) { @@ -676,7 +693,7 @@ export class StateManager { } - this.persist(); + this.#persist(); } @@ -689,7 +706,7 @@ export class StateManager { */ getVersion(): string { - const state = this.getState(); + const state = this.#getState(); return state.version; @@ -700,7 +717,7 @@ export class StateManager { */ exists(): boolean { - return existsSync(this.statePath); + return existsSync(this.#statePath); } @@ -709,7 +726,7 @@ export class StateManager { */ getStatePath(): string { - return this.statePath; + return this.#statePath; } @@ -718,9 +735,9 @@ export class StateManager { */ exportEncrypted(): string | null { - if (!existsSync(this.statePath)) return null; + if (!existsSync(this.#statePath)) return null; - return readFileSync(this.statePath, 'utf8'); + return readFileSync(this.#statePath, 'utf8'); } @@ -729,7 +746,7 @@ export class StateManager { */ async importEncrypted(encrypted: string): Promise { - if (!this.privateKey) { + if (!this.#privateKey) { throw new Error('Private key required to import state.'); @@ -739,18 +756,22 @@ export class StateManager { const [payload, parseErr] = attemptSync(() => JSON.parse(encrypted) as EncryptedPayload); if (parseErr) throw parseErr; - const [, decryptErr] = attemptSync(() => decrypt(payload!, this.privateKey!)); + const [, decryptErr] = attemptSync(() => decrypt(payload!, this.#privateKey!)); if (decryptErr) throw decryptErr; - const dir = dirname(this.statePath); + const dir = dirname(this.#statePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(this.statePath, encrypted); - this.loaded = false; + writeFileSync(this.#statePath, encrypted, { mode: 0o600 }); + + // Ensure permissions are correct (writeFile mode may not work on all platforms) + attemptSync(() => chmodSync(this.#statePath, 0o600)); + + this.#loaded = false; await this.load(); } @@ -762,7 +783,7 @@ export class StateManager { */ setPrivateKey(privateKey: string): void { - this.privateKey = privateKey; + this.#privateKey = privateKey; } @@ -771,7 +792,46 @@ export class StateManager { */ hasPrivateKey(): boolean { - return !!this.privateKey; + return !!this.#privateKey; + + } + +} + +/** + * Identifier rule for secret keys: the one place the format regex is + * declared. `setSecret` enforces it as the actual seam every CLI/SDK/MCP + * caller funnels through; the TUI's live-typing validators call this same + * predicate instead of hand-copying the pattern. + */ +export function isValidSecretKey(key: string): boolean { + + return /^[A-Za-z][A-Za-z0-9_]*$/.test(key); + +} + +/** + * Error when a secret key fails the identifier format rule. + * + * Thrown by `setSecret` before any state mutation. + * + * @example + * ```typescript + * const [, err] = await attempt(() => stateManager.setSecret('dev', 'bad key', 'v')) + * if (err instanceof InvalidSecretKeyError) { + * console.log(`Rejected key: ${err.key}`) + * } + * ``` + */ +export class InvalidSecretKeyError extends Error { + + override readonly name = 'InvalidSecretKeyError' as const; + + constructor( + public readonly key: string, + ) { + + super(`Secret key "${key}" is invalid: must start with a letter and contain only letters, numbers, and underscores`); } diff --git a/src/core/teardown/operations.ts b/src/core/teardown/operations.ts index 970017c6..f80f09f8 100644 --- a/src/core/teardown/operations.ts +++ b/src/core/teardown/operations.ts @@ -62,6 +62,68 @@ function pushFlat(out: string[], value: string | string[]): void { } +/** + * Execute a group of teardown SQL statements against `db`. + * + * Shared by every phase (disable/truncate/enable): skips comment-only + * entries, splits `'; '`-joined compound statements (e.g. MSSQL's + * DELETE + DBCC CHECKIDENT reseed), and emits `teardown:progress` / + * `teardown:error` exactly as the old inline loop did. + * + * `continueOnError` encodes the two failure policies the FK re-enable + * guarantee needs: the disable/truncate phase stops at the first + * failure (pre-fix throw-on-first-failure semantics, preserved here as + * "stop and let the caller decide what to do next"), while the + * enable-FK phase must attempt every statement even after a failure — + * one MSSQL table's re-enable failing must not skip the other tables' + * re-enable. Either way the first error encountered is returned rather + * than thrown, so the caller can run both phases before deciding what + * to surface. + */ +async function executeStatements( + db: Kysely, + statements: string[], + continueOnError: boolean, +): Promise { + + let firstError: Error | null = null; + + for (const stmt of statements) { + + if (stmt.startsWith('--')) continue; + + const subStatements = stmt.includes('; ') + ? stmt.split('; ').map(s => s.trim()).filter(s => s.length > 0) + : [stmt]; + + for (const subStmt of subStatements) { + + observer.emit('teardown:progress', { + category: 'tables', + object: subStmt.includes('DELETE') || subStmt.includes('TRUNCATE') ? subStmt : null, + action: 'truncating', + }); + + const [, execErr] = await attempt(() => sql.raw(subStmt).execute(db)); + + if (execErr) { + + observer.emit('teardown:error', { error: execErr, object: subStmt }); + + firstError = firstError ?? execErr; + + if (!continueOnError) return firstError; + + } + + } + + } + + return firstError; + +} + /** * Truncate data from tables. * @@ -147,56 +209,45 @@ export async function truncateData( } - // Build SQL statements. Skip the FK disable/enable bookends entirely - // when nothing will be truncated — keeps the dry-run output honest - // and avoids emitting a no-op `ALTER TABLE NOCHECK` against an empty list. + // Build SQL statements in three groups (disable/truncate/enable) so the + // enable-FK phase can execute independently of a disable/truncate + // failure below. `statements` stays the same flat concatenation for + // dry-run output — skip the FK disable/enable bookends entirely when + // nothing will be truncated, keeping the dry-run output honest and + // avoiding a no-op `ALTER TABLE NOCHECK` against an empty list. + const disableStatements: string[] = []; + const truncateStatements: string[] = []; + const enableStatements: string[] = []; + if (truncated.length > 0) { - pushFlat(statements, ops.disableForeignKeyChecks(truncated)); + pushFlat(disableStatements, ops.disableForeignKeyChecks(truncated)); for (const tableName of truncated) { - statements.push(ops.truncateTable(tableName, undefined, options.restartIdentity ?? true)); + truncateStatements.push(ops.truncateTable(tableName, undefined, options.restartIdentity ?? true)); } - pushFlat(statements, ops.enableForeignKeyChecks(truncated)); + pushFlat(enableStatements, ops.enableForeignKeyChecks(truncated)); + + statements.push(...disableStatements, ...truncateStatements, ...enableStatements); } - // Execute unless dry run + // Execute unless dry run. The enable-FK phase always runs, even when the + // disable/truncate phase fails — a mid-truncate error must never leave + // FK enforcement off (e.g. MSSQL's per-table NOCHECK survives reconnects + // until manually repaired). The disable/truncate error takes priority + // when both phases fail — the caller needs to know why the truncate + // itself broke, not just that FK re-enable also failed. if (!options.dryRun) { - for (const stmt of statements) { + const truncateError = await executeStatements(db, [...disableStatements, ...truncateStatements], false); + const enableError = await executeStatements(db, enableStatements, true); - // Skip comments - if (stmt.startsWith('--')) continue; - - // Handle multi-statement strings (e.g., SQLite truncate returns two statements) - const subStatements = stmt.includes('; ') - ? stmt.split('; ').map(s => s.trim()).filter(s => s.length > 0) - : [stmt]; - - for (const subStmt of subStatements) { - - observer.emit('teardown:progress', { - category: 'tables', - object: subStmt.includes('DELETE') || subStmt.includes('TRUNCATE') ? subStmt : null, - action: 'truncating', - }); - - const [, execErr] = await attempt(() => sql.raw(subStmt).execute(db)); - - if (execErr) { - - observer.emit('teardown:error', { error: execErr, object: subStmt }); - throw execErr; - - } - - } - - } + if (truncateError) throw truncateError; + if (enableError) throw enableError; } diff --git a/src/core/template/context.ts b/src/core/template/context.ts index b398a53c..e9eeb207 100644 --- a/src/core/template/context.ts +++ b/src/core/template/context.ts @@ -31,6 +31,106 @@ import { loadHelpers } from './helpers.js'; import { loadDataFile, hasLoader } from './loaders/index.js'; import { toContextKey, sqlEscape, sqlQuote, generateUuid, isoNow } from './utils.js'; +/** + * Tiers `$.secrets` actually resolves from, in priority order. + * + * Named here (not derived) because `buildContext` only ever sees the + * already-built `options.secrets` record — it has no visibility into which + * tiers produced it. Every render-path caller (`RunNamespace`, + * `ChangesNamespace`, `TemplatesNamespace`, `run preview`, `run inspect`, + * the TUI run context) sources `secrets` from `buildSecretsContext`, which + * merges all three. Keep this list in step with that function: a + * `MissingSecretError` that names a tier nobody searched sends the reader + * looking in the wrong place, and one that omits a tier hides where the + * value should have come from. + * + * A vault tier that cannot be reached (no vault, no key, unreachable DB) + * degrades to the local tiers rather than failing, so it is still listed — + * it was searched, it just had nothing to give. + */ +const SECRET_TIERS_SEARCHED = ['config-local', 'global-local', 'vault'] as const; + +/** + * Error when a template reads a secret that could not be resolved. + * + * A missing secret has no correct SQL rendering. Before this, a miss on the + * plain `secrets` object read as `undefined`, and `sqlQuote` stringified + * that into the literal text `undefined` — a real credential shipped with + * the six-character password `undefined` (noorm#50). Thrown by the + * `$.secrets` proxy's `get` trap; the `has` trap does not throw, so + * templates can still probe for an optional secret via `'KEY' in $.secrets`. + * + * @example + * ```typescript + * const [, err] = await attempt(() => processFile(templatePath, { secrets })); + * if (err instanceof MissingSecretError) { + * console.log(`missing ${err.key}, searched: ${err.tiersSearched.join(', ')}`); + * } + * ``` + */ +export class MissingSecretError extends Error { + + override readonly name = 'MissingSecretError' as const; + + constructor( + public readonly key: string, + public readonly tiersSearched: readonly string[] = SECRET_TIERS_SEARCHED, + ) { + + super(`Secret "${key}" not found (searched: ${tiersSearched.join(', ')})`); + + } + +} + +/** + * Wrap a resolved secrets record so an unknown key fails loudly instead of + * reading as `undefined`. + * + * `Object.keys`, spreading, and `JSON.stringify` never trigger `get` for a + * key that isn't actually present — they resolve keys through `ownKeys`/ + * `getOwnPropertyDescriptor`, which this proxy leaves at the default + * (forwarded to `target`) — so none of them throw. `toJSON` is special-cased + * because `JSON.stringify` probes it as a `get` on the value itself before + * falling back to default object serialization. + * + * @param secrets - The merged secrets record to guard + * @returns A proxy over `secrets` whose `get` throws on an unresolved key + * + * @example + * ```typescript + * const secrets = createSecretsProxy({ API_KEY: 'abc' }); + * secrets.API_KEY; // 'abc' + * 'MISSING' in secrets; // false, does not throw + * secrets.MISSING; // throws MissingSecretError + * ``` + */ +function createSecretsProxy(secrets: Record): Record { + + return new Proxy(secrets, { + + get(target, prop, receiver) { + + if (typeof prop === 'symbol' || prop === 'toJSON' || prop in target) { + + return Reflect.get(target, prop, receiver); + + } + + throw new MissingSecretError(prop); + + }, + + has(target, prop) { + + return Reflect.has(target, prop); + + }, + + }); + +} + /** * Build the template context ($) for a template file. * @@ -67,7 +167,7 @@ export async function buildContext( ...(hasLocalConfig ? {} : { config: options.config }), // Secrets - secrets: options.secrets ?? {}, + secrets: createSecretsProxy(options.secrets ?? {}), globalSecrets: options.globalSecrets ?? {}, // Environment diff --git a/src/core/template/utils.ts b/src/core/template/utils.ts index aed17dd9..0e302438 100644 --- a/src/core/template/utils.ts +++ b/src/core/template/utils.ts @@ -15,31 +15,7 @@ */ import path from 'node:path'; -/** - * Convert a string to camelCase. - * - * Splits on hyphens, underscores, and case boundaries, - * then joins with first word lowercased and rest capitalized. - * - * @param str - Input string in any casing - * @returns camelCase string - */ -function camelCase(str: string): string { - - const words = str - .replace(/([a-z])([A-Z])/g, '$1 $2') - .split(/[-_\s]+/) - .filter(Boolean); - - if (words.length === 0) return ''; - - return words - .map((w, i) => i === 0 - ? w.toLowerCase() - : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) - .join(''); - -} +import v from 'voca'; /** * Convert a filename to a camelCase context key. @@ -65,7 +41,7 @@ export function toContextKey(filename: string): string { const base = path.basename(filename, ext); // Convert to camelCase - return camelCase(base); + return v.camelCase(base); } @@ -90,13 +66,42 @@ export function sqlEscape(value: string): string { } +/** + * Error when a value that must be rendered into SQL is `undefined`. + * + * `undefined` reaching `sqlQuote` is always a bug upstream — a missing + * secret, an unresolved config key, a typo'd lookup — never a value a + * template author meant to write. `null` remains a legitimate SQL value + * and still quotes to `NULL`; stringifying `undefined` is how a missing + * secret shipped as the literal password `undefined` (noorm#50). + * + * @example + * ```typescript + * sqlQuote(null) // → 'NULL' + * sqlQuote(undefined) // throws UndefinedSqlValueError + * ``` + */ +export class UndefinedSqlValueError extends Error { + + override readonly name = 'UndefinedSqlValueError' as const; + + constructor() { + + super('sqlQuote() received undefined — use null for an explicit SQL NULL, or resolve the missing value before rendering'); + + } + +} + /** * SQL-escape and wrap in single quotes. * - * Handles null values and various types appropriately. + * Handles null values and various types appropriately. Throws on + * `undefined` rather than stringifying it — see `UndefinedSqlValueError`. * * @param value - The value to quote * @returns The quoted SQL literal + * @throws UndefinedSqlValueError if value is undefined * * @example * ```typescript @@ -106,7 +111,13 @@ export function sqlEscape(value: string): string { * sqlQuote(true) // → "'true'" * ``` */ -export function sqlQuote(value: string | number | boolean | null): string { +export function sqlQuote(value: string | number | boolean | null | undefined): string { + + if (value === undefined) { + + throw new UndefinedSqlValueError(); + + } if (value === null) { diff --git a/src/core/transfer/events.ts b/src/core/transfer/events.ts index 819039f3..3e9d64d1 100644 --- a/src/core/transfer/events.ts +++ b/src/core/transfer/events.ts @@ -63,6 +63,7 @@ export interface TransferEvents { totalRows: number; tableCount: number; durationMs: number; + fkChecksRestored: boolean; }; } diff --git a/src/core/transfer/executor.ts b/src/core/transfer/executor.ts index 5b6b6fa5..f866455d 100644 --- a/src/core/transfer/executor.ts +++ b/src/core/transfer/executor.ts @@ -170,7 +170,13 @@ export async function executeTransfer( } - // Re-enable FK checks on destination + // Re-enable FK checks on destination. A failed re-enable never fails + // the transfer itself (data already moved), but it must stay visible + // to the caller — a swallowed failure here left referential integrity + // off on the destination with no signal beyond an observer event + // (QL-safe-05). + let fkChecksRestored = true; + if (options.disableForeignKeys !== false) { const [, enableErr] = await attempt(() => @@ -182,6 +188,8 @@ export async function executeTransfer( if (enableErr) { + fkChecksRestored = false; + // Log warning but don't fail the transfer observer.emit('error', { source: 'transfer', @@ -201,6 +209,7 @@ export async function executeTransfer( tables: tableResults, totalRows, durationMs, + fkChecksRestored, }; observer.emit('transfer:complete', { @@ -208,6 +217,7 @@ export async function executeTransfer( totalRows, tableCount: plan.tables.length, durationMs, + fkChecksRestored, }); return [result, null]; diff --git a/src/core/transfer/index.ts b/src/core/transfer/index.ts index 27d049d8..b80e5857 100644 --- a/src/core/transfer/index.ts +++ b/src/core/transfer/index.ts @@ -123,6 +123,7 @@ export async function transferData( tables: [], totalRows: 0, durationMs: 0, + fkChecksRestored: true, }; } @@ -141,6 +142,7 @@ export async function transferData( })), totalRows: 0, durationMs: 0, + fkChecksRestored: true, }; } diff --git a/src/core/transfer/same-server.ts b/src/core/transfer/same-server.ts index 2be1ae2e..161753ff 100644 --- a/src/core/transfer/same-server.ts +++ b/src/core/transfer/same-server.ts @@ -5,16 +5,7 @@ * enabling direct INSERT...SELECT transfers without data marshalling. */ import type { ConnectionConfig, Dialect } from '../connection/types.js'; - -/** - * Default ports by dialect. - */ -const DEFAULT_PORTS: Record = { - postgres: 5432, - mysql: 3306, - mssql: 1433, - sqlite: 0, // Not applicable -}; +import { DEFAULT_PORTS } from '../connection/defaults.js'; /** * Normalize a hostname for comparison. diff --git a/src/core/transfer/types.ts b/src/core/transfer/types.ts index 96924910..ffc4adc1 100644 --- a/src/core/transfer/types.ts +++ b/src/core/transfer/types.ts @@ -174,4 +174,15 @@ export interface TransferResult { /** Total duration in milliseconds */ durationMs: number; + /** + * Whether FK checks were successfully re-enabled on the destination. + * `false` only when checks were disabled for this transfer and the + * re-enable attempt failed; `true` otherwise, including when + * `disableForeignKeys: false` meant checks were never touched. This + * is the only signal a caller has that referential integrity may + * still be off — `status` does not flip on a failed FK restore + * (QL-safe-05). + */ + fkChecksRestored: boolean; + } diff --git a/src/core/update/checksum.ts b/src/core/update/checksum.ts new file mode 100644 index 00000000..8cd50eab --- /dev/null +++ b/src/core/update/checksum.ts @@ -0,0 +1,159 @@ +/** + * Checksum verification for binary release assets. + * + * Ports the pattern from the sibling `ignatius` repo but hardens its one + * weakness: an unreachable/missing checksums.txt is a hard failure by + * default (opt out via `insecure`), and a confirmed hash mismatch is ALWAYS + * a hard failure — `insecure` can only ever downgrade "we couldn't prove + * anything either way" to a warning, never "we proved this binary is bad". + */ +import { attempt } from '@logosdx/utils'; + +/** + * Parse a `shasum -a 256`-style checksums file into an asset → sha256 map. + * + * Mirrors the exact output format the release workflow produces: a 64-char + * hex hash, whitespace, an optional `*` binary-mode marker, then the + * filename. checksums.txt is generated by a trusted CI step, not user + * input — blank/malformed lines are skipped rather than thrown on, since a + * single stray line shouldn't take down verification for every other entry. + * + * @example + * const map = parseChecksums('deadbeef... noorm-darwin-arm64\n'); + * map['noorm-darwin-arm64']; // 'deadbeef...' + */ +export function parseChecksums(text: string): Record { + + const out: Record = {}; + + for (const line of text.split('\n')) { + + const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i); + + if (match && match[1] && match[2]) { + + out[match[2]] = match[1].toLowerCase(); + + } + + } + + return out; + +} + +/** + * Compute the sha256 hex digest of a file on disk. + * + * Streams the file rather than buffering it whole — release binaries run + * tens of MB, and buffering the full file would spike memory on every + * update check for no benefit. + * + * @example + * const hash = await sha256File('/tmp/noorm.download'); + */ +export async function sha256File(path: string): Promise { + + const hasher = new Bun.CryptoHasher('sha256'); + + for await (const chunk of Bun.file(path).stream()) { + + hasher.update(chunk); + + } + + return hasher.digest('hex'); + +} + +/** + * A checksum verification failure. + * + * `reason` distinguishes two categorically different failures so callers + * (and the `insecure` escape hatch) branch on structure instead of matching + * error message text: + * - `'unreachable'`: checksums.txt couldn't be fetched, or was fetched but + * didn't mention this asset. Either way we have no trustworthy answer — + * bypassable via `insecure`, the documented escape hatch for offline + * installs and mirrors without a checksums.txt. + * - `'mismatch'`: checksums.txt was fetched and DID have an entry for this + * asset, and the computed hash disagrees with it. We have proof the bytes + * are wrong — never bypassable, by design (see `verifyChecksum`). + */ +export class ChecksumError extends Error { + + override readonly name = 'ChecksumError' as const; + + constructor(message: string, readonly reason: 'unreachable' | 'mismatch') { + + super(message); + + } + +} + +/** + * Verify a downloaded file's sha256 against a release's checksums.txt. + * + * Security-critical asymmetry, deliberate: "we couldn't verify" (checksums.txt + * unreachable, or missing an entry for this asset) is bypassable via + * `insecure`. "We verified and it's wrong" is NEVER bypassable — `insecure` + * only ever waves through the absence of proof, not a proven-bad binary. + * Get this branch order wrong and the entire feature becomes a no-op for the + * one case it exists to catch. + * + * @throws ChecksumError('mismatch') when the computed hash disagrees with a + * found checksums.txt entry — unconditional, `insecure` has no effect here. + * @throws ChecksumError('unreachable') when checksums.txt can't be fetched, + * or has no entry for `assetName`, and `insecure` is false. + * + * @example + * await verifyChecksum({ + * checksumsUrl: getChecksumsUrl(version), + * assetName: getBinaryAssetName(), + * filePath: tmpPath, + * insecure: false, + * }); + */ +export async function verifyChecksum(opts: { + checksumsUrl: string; + assetName: string; + filePath: string; + insecure: boolean; +}): Promise { + + const { checksumsUrl, assetName, filePath, insecure } = opts; + + const [response, fetchErr] = await attempt(() => fetch(checksumsUrl)); + + if (fetchErr || !response || !response.ok) { + + if (insecure) return; + + throw new ChecksumError(`checksums.txt unreachable at ${checksumsUrl}`, 'unreachable'); + + } + + const text = await response.text(); + const expected = parseChecksums(text)[assetName]; + + if (!expected) { + + if (insecure) return; + + throw new ChecksumError(`no checksum entry for ${assetName} in ${checksumsUrl}`, 'unreachable'); + + } + + const actual = await sha256File(filePath); + + if (actual !== expected) { + + throw new ChecksumError( + `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})`, + 'mismatch', + ); + + } + +} diff --git a/src/core/update/install-mode.ts b/src/core/update/install-mode.ts index 48229463..fffabc83 100644 --- a/src/core/update/install-mode.ts +++ b/src/core/update/install-mode.ts @@ -62,18 +62,32 @@ export function detectInstallMode(): InstallMode { const GITHUB_REPO = 'noormdev/noorm'; /** - * Get the download URL for a binary release. + * Release-tag URL base shared by every asset published for a version — the + * binary itself and its checksums.txt. Factored out so both stay in + * lockstep instead of two copies of the same URL-building logic drifting + * apart. + */ +function releaseBaseUrl(version: string): string { + + return `https://github.com/${GITHUB_REPO}/releases/download/%40noormdev%2Fcli%40${version}`; + +} + +/** + * Get the platform-appropriate binary asset filename (no version, no URL). * - * @param version - Semver version to download - * @returns URL to the platform-appropriate binary asset + * Split out from `getBinaryDownloadUrl` so the download URL and the + * checksums.txt lookup key (the entry name checksum verification looks up) + * are always derived from the exact same platform/arch table — one place to + * update, not two that could silently drift apart. * * @example * ```typescript - * const url = getBinaryDownloadUrl('1.2.0'); - * // → 'https://github.com/noormdev/noorm/releases/download/@noormdev/cli@1.2.0/noorm-darwin-arm64' + * const asset = getBinaryAssetName(); + * // → 'noorm-darwin-arm64' * ``` */ -export function getBinaryDownloadUrl(version: string): string { +export function getBinaryAssetName(): string { const platform = process.platform; const arch = process.arch; @@ -111,6 +125,43 @@ export function getBinaryDownloadUrl(version: string): string { } - return `https://github.com/${GITHUB_REPO}/releases/download/%40noormdev%2Fcli%40${version}/noorm-${suffix}`; + return `noorm-${suffix}`; + +} + +/** + * Get the download URL for a binary release. + * + * @param version - Semver version to download + * @returns URL to the platform-appropriate binary asset + * + * @example + * ```typescript + * const url = getBinaryDownloadUrl('1.2.0'); + * // → 'https://github.com/noormdev/noorm/releases/download/@noormdev/cli@1.2.0/noorm-darwin-arm64' + * ``` + */ +export function getBinaryDownloadUrl(version: string): string { + + return `${releaseBaseUrl(version)}/${getBinaryAssetName()}`; + +} + +/** + * Get the URL to the checksums.txt published alongside the platform + * binaries at the same release tag. + * + * @param version - Semver version to download + * @returns URL to the release's checksums.txt + * + * @example + * ```typescript + * const url = getChecksumsUrl('1.2.0'); + * // → 'https://github.com/noormdev/noorm/releases/download/@noormdev/cli@1.2.0/checksums.txt' + * ``` + */ +export function getChecksumsUrl(version: string): string { + + return `${releaseBaseUrl(version)}/checksums.txt`; } diff --git a/src/core/update/registry.ts b/src/core/update/registry.ts index 1ad8f6d1..b237fea0 100644 --- a/src/core/update/registry.ts +++ b/src/core/update/registry.ts @@ -67,18 +67,13 @@ export interface RegistryPackageInfo { */ export async function fetchPackageInfo(): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); - const [response, fetchErr] = await attempt(() => fetch(REGISTRY_URL, { - signal: controller.signal, + signal: AbortSignal.timeout(TIMEOUT_MS), headers: { 'Accept': 'application/json' }, }), ); - clearTimeout(timeoutId); - if (fetchErr) { // Network error or timeout - return null for graceful handling diff --git a/src/core/update/updater.ts b/src/core/update/updater.ts index 4018c88f..f3ef1a0b 100644 --- a/src/core/update/updater.ts +++ b/src/core/update/updater.ts @@ -16,11 +16,12 @@ import { spawn } from 'child_process'; import { open, rename, unlink, chmod, stat } from 'fs/promises'; -import { attempt } from '@logosdx/utils'; +import { attempt, retry, wait } from '@logosdx/utils'; import { observer } from '../observer.js'; import { getCurrentVersion } from './checker.js'; -import { detectInstallMode, getBinaryDownloadUrl } from './install-mode.js'; +import { verifyChecksum } from './checksum.js'; +import { detectInstallMode, getBinaryDownloadUrl, getChecksumsUrl, getBinaryAssetName } from './install-mode.js'; import type { UpdateResult } from './types.js'; // ============================================================================= @@ -180,8 +181,6 @@ interface DownloadState { total: number; } -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - /** Current size of a file, or 0 if it doesn't exist — the resume offset. */ async function fileSizeOrZero(path: string): Promise { @@ -225,26 +224,31 @@ export async function downloadToFile( const state: DownloadState = { total: 0 }; - for (let attemptNo = 1; attemptNo <= maxAttempts; attemptNo++) { - - const offset = await fileSizeOrZero(destPath); - - // A prior attempt already pulled the whole asset — nothing left to do. - if (state.total > 0 && offset >= state.total) break; + await retry( + async () => { - const [, err] = await attempt(() => downloadAttempt(url, destPath, version, offset, state, stallMs)); + const offset = await fileSizeOrZero(destPath); - if (!err) break; + // A prior attempt already pulled the whole asset — nothing left to do. + if (state.total > 0 && offset >= state.total) return; - const retriable = !(err instanceof DownloadError) || err.retriable; + await downloadAttempt(url, destPath, version, offset, state, stallMs); - if (!retriable || attemptNo >= maxAttempts) throw err; + }, + { + retries: maxAttempts, + delay: 0, + throwLastError: true, + shouldRetry: (err) => !(err instanceof DownloadError) || err.retriable, + onRetry: async (err, attempt) => { - observer.emit('update:retry', { version, attempt: attemptNo, maxAttempts, error: err.message }); + observer.emit('update:retry', { version, attempt, maxAttempts, error: err.message }); - await sleep(backoffMs * attemptNo); + await wait(backoffMs * attempt); - } + }, + }, + ); await chmod(destPath, 0o755); @@ -388,9 +392,11 @@ async function downloadAttempt( * Streams the platform-appropriate binary to a temp file **in the target's own * directory** — a cross-filesystem `rename` (e.g. `os.tmpdir()` on a different * volume than `~/.local/bin`) throws `EXDEV`, so the swap must stage next to the - * destination — then atomically replaces the current executable. + * destination — then verifies its sha256 against checksums.txt before atomically + * replacing the current executable. A tampered or corrupted binary must never + * reach the swap, so verification runs after download and before rename. */ -async function installViaBinary(version: string, previousVersion: string): Promise { +async function installViaBinary(version: string, previousVersion: string, insecure = false): Promise { const url = getBinaryDownloadUrl(version); const currentExe = process.execPath; @@ -414,6 +420,21 @@ async function installViaBinary(version: string, previousVersion: string): Promi } + const [, verifyErr] = await attempt(() => verifyChecksum({ + checksumsUrl: getChecksumsUrl(version), + assetName: getBinaryAssetName(), + filePath: tmpPath, + insecure, + })); + + if (verifyErr) { + + await attempt(() => unlink(tmpPath)); + + return fail(verifyErr.message); + + } + // Atomic replace: rename old → backup, rename new → current, remove backup. // All three paths share `currentExe`'s directory, so every rename is // same-filesystem and atomic. @@ -457,10 +478,15 @@ async function installViaBinary(version: string, previousVersion: string): Promi * Install update via the appropriate channel. * * Automatically detects install mode and routes to the correct updater: - * - npm mode: runs `npm install -g @noormdev/cli@{version}` - * - binary mode: downloads replacement binary from GitHub releases + * - npm mode: runs `npm install -g @noormdev/cli@{version}` (npm's own + * registry integrity hashes already cover this channel — `options` is + * ignored here) + * - binary mode: downloads replacement binary from GitHub releases and + * verifies its checksum before the atomic swap * * @param version - Version to install + * @param options.insecure - Skip checksum verification when checksums.txt is + * unreachable (binary mode only). Never bypasses a confirmed mismatch. * @returns Promise that resolves when install completes * * @example @@ -479,7 +505,7 @@ async function installViaBinary(version: string, previousVersion: string): Promi * } * ``` */ -export function installUpdate(version: string): Promise { +export function installUpdate(version: string, options: { insecure?: boolean } = {}): Promise { const previousVersion = getCurrentVersion(); const mode = detectInstallMode(); @@ -488,7 +514,7 @@ export function installUpdate(version: string): Promise { if (mode === 'binary') { - return installViaBinary(version, previousVersion); + return installViaBinary(version, previousVersion, options.insecure ?? false); } diff --git a/src/core/vault/resolve.ts b/src/core/vault/resolve.ts index 66db98de..5185ba97 100644 --- a/src/core/vault/resolve.ts +++ b/src/core/vault/resolve.ts @@ -8,13 +8,53 @@ * * Local always wins over remote. */ +import { attempt } from '@logosdx/utils'; + import type { Kysely } from 'kysely'; import type { NoormDatabase } from '../shared/tables.js'; import type { Dialect } from '../connection/types.js'; import type { StateManager } from '../state/manager.js'; +import { loadIdentityMetadata, loadPrivateKey } from '../identity/storage.js'; + +import { getVaultKey, getVaultSecret } from './storage.js'; + +/** + * Resolve the vault key for the current identity, if any. + * + * Degrades to `null` on any failure — no identity on disk, no vault + * access yet, or a decrypt failure all mean the vault tier is + * unavailable, not that the caller should fail. A project with no vault + * at all must keep working exactly as it does today. + * + * Single source of truth for the five render-context builders that need + * it (`run`/`changes`/`templates` SDK namespaces, `run preview`/`run + * inspect`) — a future change to the degrade rules is one edit here. + * + * @example + * ```typescript + * const vaultKey = await resolveVaultKey(db, 'postgres'); + * ``` + */ +export async function resolveVaultKey( + db: Kysely, + dialect: Dialect, +): Promise { + + const [vaultKey] = await attempt(async () => { + + const cryptoIdentity = await loadIdentityMetadata(); + const privateKey = cryptoIdentity ? await loadPrivateKey() : null; + + if (!cryptoIdentity || !privateKey) return null; -import { getVaultSecret } from './storage.js'; + return getVaultKey(db, cryptoIdentity.identityHash, privateKey, dialect); + + }); + + return vaultKey ?? null; + +} /** * Resolve a secret value. diff --git a/src/core/vault/storage.ts b/src/core/vault/storage.ts index bed9335b..f3cc2784 100644 --- a/src/core/vault/storage.ts +++ b/src/core/vault/storage.ts @@ -160,7 +160,9 @@ export async function getVaultKey( }); - if (err || !row?.encrypted_vault_key) return null; + if (err) throw err; + + if (!row?.encrypted_vault_key) return null; const encryptedValue = row.encrypted_vault_key; @@ -312,7 +314,9 @@ export async function getVaultSecret( }); - if (err || !row) return null; + if (err) throw err; + + if (!row) return null; const [parsed, parseErr] = attemptSync(() => JSON.parse(row.encrypted_value) as { iv: string; authTag: string; ciphertext: string }, @@ -361,7 +365,9 @@ export async function getAllVaultSecrets( }); - if (err || !rows) return {}; + if (err) throw err; + + if (!rows) return {}; const secrets: Record = {}; @@ -424,7 +430,9 @@ export async function listVaultSecretKeys( }); - if (err || !rows) return []; + if (err) throw err; + + if (!rows) return []; return rows.map((r) => r.secret_key); @@ -508,7 +516,7 @@ export async function vaultSecretExists( }); - if (err) return false; + if (err) throw err; return !!row; diff --git a/src/core/worker-bridge/bridge.ts b/src/core/worker-bridge/bridge.ts index f2d5bf7a..55331d0d 100644 --- a/src/core/worker-bridge/bridge.ts +++ b/src/core/worker-bridge/bridge.ts @@ -1,9 +1,7 @@ -import { Worker, parentPort, workerData } from 'worker_threads'; -import type { Transferable as NodeTransferable } from 'worker_threads'; +import { Worker, parentPort } from 'worker_threads'; import { ObserverRelay } from '@logosdx/observer'; import { randomUUID } from 'crypto'; import { WorkerPool } from './pool.js'; -import { isTransferable } from './types.js'; import type { WireMessage, ResKey, PoolOptions } from './types.js'; type Port = Worker | import('worker_threads').MessagePort @@ -13,14 +11,14 @@ export class WorkerBridge extends Obser #port: Port; #ownsWorker: boolean; - constructor(script?: string | URL, data?: unknown) { + constructor(script?: string | URL) { const isParent = !!script; super({ name: isParent ? 'bridge:parent' : 'bridge:worker' }); if (script) { - this.#port = new Worker(script, { workerData: data }); + this.#port = new Worker(script); this.#ownsWorker = true // Detect worker crash — emit error event so orchestrators can abort @@ -59,16 +57,7 @@ export class WorkerBridge extends Obser protected override send(event: string, data: unknown): void { - if (isTransferable(data)) { - - this.#port.postMessage({ event, data }, data.__transfer as NodeTransferable[]); - - } - else { - - this.#port.postMessage({ event, data }, []); - - } + this.#port.postMessage({ event, data }, []); } @@ -87,16 +76,6 @@ export class WorkerBridge extends Obser } - transfer( - event: K, - data: TEvents[K], - transferables: NodeTransferable[], - ): void { - - this.#port.postMessage({ event, data }, transferables); - - } - static pool( script: string | URL, options: PoolOptions, @@ -106,12 +85,6 @@ export class WorkerBridge extends Obser } - static get workerData() { - - return workerData; - - } - override async shutdown(): Promise { super.shutdown(); diff --git a/src/core/worker-bridge/index.ts b/src/core/worker-bridge/index.ts index 361667bd..3c08f1ec 100644 --- a/src/core/worker-bridge/index.ts +++ b/src/core/worker-bridge/index.ts @@ -9,5 +9,4 @@ export type { ComputeEvents, PoolOptions, } from './types.js'; -export { isTransferable } from './types.js'; export { resolveWorker } from './paths.js'; diff --git a/src/core/worker-bridge/pool.ts b/src/core/worker-bridge/pool.ts index 08ae5a25..9bc175fb 100644 --- a/src/core/worker-bridge/pool.ts +++ b/src/core/worker-bridge/pool.ts @@ -67,28 +67,6 @@ export class WorkerPool { } - /** - * Registers a listener on all workers for the given event. - * - * Useful for broadcast-style events that every worker may emit. - * - * @example - * pool.on('serialize:res', ({ data }) => console.log(data.values)) - */ - on( - event: K, - callback: (payload: { data: TEvents[K] }) => void, - options?: { signal?: AbortSignal }, - ) { - - for (const worker of this.#workers) { - - worker.on(event, callback, options); - - } - - } - /** * Terminates all workers in the pool. * diff --git a/src/core/worker-bridge/types.ts b/src/core/worker-bridge/types.ts index 6b04f9c2..7257e3c7 100644 --- a/src/core/worker-bridge/types.ts +++ b/src/core/worker-bridge/types.ts @@ -15,13 +15,6 @@ export type ResKey = `${K}:res` // See Correlation Protocol in the spec. export type Correlated = T & { __cid: string } -// Type guard for zero-copy ArrayBuffer transfer -export function isTransferable(data: unknown): data is { __transfer: Transferable[] } { - - return !!data && typeof data === 'object' && '__transfer' in data; - -} - // --- Connection Worker Events --- export type ConnectionEvents = { diff --git a/src/hooks/observer.ts b/src/hooks/observer.ts deleted file mode 100644 index bf985aee..00000000 --- a/src/hooks/observer.ts +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; - -import { - type NoormEvents, - type NoormEventNames, - type NoormEventCallback, - observer as obsrv, -} from '../core/observer.js'; - -// re-export for convenience -export const observer = obsrv; - -/** - * Attaches a listener to the observer with an automatic cleanup function - * - * @param event App event - * @param cb Callback to listen to - * @param deps Dependencies to bind to - */ -export const useOnNoormEvent = ( - event: E, - cb: NoormEventCallback, - deps: React.DependencyList = [], -) => { - - useEffect(() => obsrv.on(event, cb), [event, cb, ...deps]); - -}; - -/** - * Creates an event generator for an event - * - * @param event App event - * @param deps Dependencies to bind to - * @returns - */ -export const useNoormEventGenerator = ( - event: E, - deps: React.DependencyList = [], -) => { - - return useMemo(() => obsrv.on(event), [event, ...deps]); - -}; - -/** - * Attaches a listener to the observer once with an automatic cleanup function - * - * @param event App event - * @param cb Callback to listen to - * @param deps Dependencies to bind to - */ -export const useOnceNoormEvent = ( - event: E, - cb: NoormEventCallback, - deps: React.DependencyList = [], -) => { - - useEffect(() => obsrv.once(event, cb), [event, cb, ...deps]); - -}; - -/** - * Creates a function that emits events, scoped to a single function - * - * @param event App event - * @param deps Dependencies to bind to - */ -export const useEmitNoormEvent = ( - event: E, - deps: React.DependencyList = [], -) => { - - return useCallback((data: D) => obsrv.emit(event, data as never), [event, ...deps]); - -}; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a93095b9..59bfec65 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -129,10 +129,13 @@ export function createMcpServer(registry: RpcRegistry, session: SessionManager): if (err) { + // Stack traces stay server-side; the MCP client only sees the message. + console.error(err.stack ?? err.message); + return { content: [{ type: 'text' as const, - text: JSON.stringify({ error: err.message, stack: err.stack }), + text: JSON.stringify({ error: err.message }), }], isError: true, }; diff --git a/src/rpc/commands/config.ts b/src/rpc/commands/config.ts index 40dfa2c2..98ba8464 100644 --- a/src/rpc/commands/config.ts +++ b/src/rpc/commands/config.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { attempt } from '@logosdx/utils'; import { initState } from '../../core/state/index.js'; +import { isVisibleToChannel } from '../../core/policy/index.js'; import type { ConfigSummary } from '../../core/config/types.js'; import type { RpcCommand } from '../types.js'; import { RpcError } from '../types.js'; @@ -26,15 +27,9 @@ const listConfigsCommand: RpcCommand, ConfigSummary[]> = { const summaries = manager.listConfigs(); - // Invisibility: a config with access.mcp === false does not exist - // as far as the mcp channel is concerned. - if (session.channel === 'mcp') { - - return summaries.filter((summary) => summary.access.mcp !== false); - - } - - return summaries; + // Invisibility: a config with access.mcp === false (or missing + // access) does not exist as far as the mcp channel is concerned. + return summaries.filter((summary) => isVisibleToChannel(summary.access, session.channel)); }, }; diff --git a/src/rpc/commands/session.ts b/src/rpc/commands/session.ts index 2103abf9..31b91cb3 100644 --- a/src/rpc/commands/session.ts +++ b/src/rpc/commands/session.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; +import { attempt } from '@logosdx/utils'; +import { initState } from '../../core/state/index.js'; +import { getEnvConfigName } from '../../core/environment.js'; import type { RpcCommand } from '../types.js'; +import { RpcError } from '../types.js'; const connectSchema = z.object({ config: z.string().optional().describe('Config name to connect to. Omit to use active config.'), @@ -47,7 +51,76 @@ const disconnectCommand: RpcCommand = { }, }; +/** + * Snapshot of session connection state returned by the `status` command. + */ +export interface SessionStatus { + connections: string[]; + activeConfig: string | null; + activeConnected: boolean; +} + +/** + * Reports what an agent is connected to and what a bare `connect` would + * target, giving `SessionManager.hasConnection`/`listConnections` + * (`src/rpc/session.ts:166,175`) their production callers — closing + * AP-yagni-06 productively (decision D9, tickets/v1/00-DECISIONS.md). + * Read-only: no session mutation. + * + * Active-config resolution mirrors `resolveConfig`'s no-name path + * (`src/core/config/resolver.ts:214`) so `status` reports exactly what a + * bare `connect` would target. On the mcp channel, a config hidden via + * `access.mcp === false` (or unknown to state) is reported as no active + * config, same invisibility `list_configs` applies — `status` must not leak + * a hidden config's name through the active-config field. + * + * @example + * const { connections, activeConfig, activeConnected } = await cmd.handler({}, session); + */ +const statusCommand: RpcCommand, SessionStatus> = { + name: 'status', + description: 'Show connected configs and the active config. Does not require a database connection.', + examples: [ + { description: 'check session status', input: {} }, + ], + inputSchema: z.object({}), + permission: 'open', + handler: async (_input, session): Promise => { + + const [manager, err] = await attempt(() => initState()); + + if (err) { + + throw new RpcError('Failed to load state', err.message); + + } + + const connections = session.listConnections(); + let activeConfig = getEnvConfigName() ?? manager.getActiveConfigName() ?? null; + + if (activeConfig && session.channel === 'mcp') { + + const config = manager.getConfig(activeConfig); + + if (!config || config.access.mcp === false) { + + activeConfig = null; + + } + + } + + return { + connections, + activeConfig, + activeConnected: activeConfig !== null && session.hasConnection(activeConfig), + }; + + }, +}; + export const sessionCommands: RpcCommand[] = [ connectCommand as RpcCommand, disconnectCommand as RpcCommand, + statusCommand as RpcCommand, ]; diff --git a/src/rpc/session.ts b/src/rpc/session.ts index a922ff63..360b3e97 100644 --- a/src/rpc/session.ts +++ b/src/rpc/session.ts @@ -2,7 +2,7 @@ import { attempt } from '@logosdx/utils'; import { createContext, type Context } from '../sdk/index.js'; import { configNotFoundMessage } from '../core/config/resolver.js'; -import type { Channel, Role } from '../core/policy/index.js'; +import { isVisibleToChannel, type Channel, type Role } from '../core/policy/index.js'; import { RpcError, type RpcSession } from './types.js'; /** @@ -65,7 +65,7 @@ export class SessionManager implements RpcSession { const resolvedName = ctx.noorm.config.name; const rawAccess = ctx.noorm.config.access; - if (this.channel === 'mcp' && (!rawAccess || rawAccess.mcp === false)) { + if (!isVisibleToChannel(rawAccess, this.channel)) { throw new RpcError('Failed to create context', configNotFoundMessage(resolvedName)); diff --git a/src/sdk/context.ts b/src/sdk/context.ts index e1dc4715..3e6e1543 100644 --- a/src/sdk/context.ts +++ b/src/sdk/context.ts @@ -14,10 +14,12 @@ import type { Config } from '../core/config/types.js'; import type { Settings } from '../core/settings/index.js'; import type { Identity } from '../core/identity/index.js'; import { createConnection } from '../core/connection/index.js'; +import type { ConnectionRetryOptions } from '../core/connection/index.js'; import { buildProcCall, buildFuncCall, buildTvfCall } from './sql.js'; import { NoormOps } from './noorm-ops.js'; import type { ContextState } from './state.js'; +import { requireConnection } from './state.js'; import type { CreateContextOptions, ExtractArgs, ExtractReturn } from './types.js'; import { dialectStrategy, validateUsername } from './impersonate/dialect-strategy.js'; import { buildScope } from './impersonate/scope.js'; @@ -95,13 +97,7 @@ export class Context { - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } - - return this.#state.connection.db as Kysely; + return requireConnection(this.#state).db as Kysely; } @@ -138,13 +134,21 @@ export class Context { + /** + * Connect to the configured database. + * + * `retryOptions` defaults to the standard 3-attempt backoff. Pass + * `{ retries: 1, delay: 0 }` for a best-effort probe that must fail + * fast rather than hang on an unreachable database. + */ + async connect(retryOptions?: ConnectionRetryOptions): Promise { if (this.#state.connection) return; this.#state.connection = await createConnection( this.#state.config.connection, this.#state.config.name, + retryOptions, ); } @@ -225,12 +229,6 @@ export class Context extends void ? [] : [params: ExtractArgs] ): Promise { - if (this.dialect === 'sqlite') { - - throw new Error('SQLite does not support stored procedures.'); - - } - const params = args[0] as Record | unknown[] | undefined; if (this.dialect === 'postgres') { @@ -314,12 +312,6 @@ export class Context extends void ? [column: string] : [params: ExtractArgs, column: string] ): Promise { - if (this.dialect === 'sqlite') { - - throw new Error('SQLite does not support database function calls.'); - - } - // Extract params and column from rest args const hasParams = !(args.length === 1 && typeof args[0] === 'string'); const params = hasParams ? args[0] as Record | unknown[] : undefined; @@ -363,18 +355,6 @@ export class Context extends void ? [] : [params: ExtractArgs] ): Promise { - if (this.dialect === 'sqlite') { - - throw new Error('SQLite does not support table-valued functions.'); - - } - - if (this.dialect === 'mysql') { - - throw new Error('MySQL does not support table-valued functions.'); - - } - const params = args[0] as Record | unknown[] | undefined; const query = buildTvfCall(this.dialect, name, params); const result = await query.execute(this.kysely); diff --git a/src/sdk/guards.ts b/src/sdk/guards.ts index 74145355..95766bd3 100644 --- a/src/sdk/guards.ts +++ b/src/sdk/guards.ts @@ -36,6 +36,28 @@ export class RequireTestError extends Error { } +/** + * Error thrown when a namespace method requiring a live connection is + * called before `connect()` (or after `disconnect()`). + * + * @example + * ```typescript + * const ctx = await createContext({ config: 'dev' }) + * await ctx.noorm.db.listTables() // Throws NotConnectedError — never called connect() + * ``` + */ +export class NotConnectedError extends Error { + + override readonly name = 'NotConnectedError' as const; + + constructor() { + + super('Not connected. Call connect() first.'); + + } + +} + /** * Error thrown when the config's access policy blocks a destructive * operation — either the role denies it outright, or the role requires @@ -95,14 +117,20 @@ export function checkRequireTest( * * The SDK has no interactive prompt: a permission that resolves to * "requires confirmation" (matrix `confirm` cells on the `user` channel) - * blocks just like an outright denial, naming `NOORM_YES=1` as the - * scripted opt-in and the CLI/TUI as the interactive route. + * blocks just like an outright denial unless the caller pre-confirmed via + * `options.yes` (the programmatic equivalent of the CLI's `--yes`) — + * mirrors `db drop`'s CLI gate (`check.requiresConfirmation && !args.yes`). + * `options.yes` is only consulted once `checkConfigPolicy` has already + * resolved the channel: on `mcp`, `confirm` collapses to deny before this + * function ever sees a `requiresConfirmation` result, so `yes: true` never + * unblocks an MCP-channel context. * - * @throws ProtectedConfigError if the policy denies or requires confirmation + * @throws ProtectedConfigError if the policy denies, or requires + * confirmation that `options.yes` doesn't supply */ export function checkProtectedConfig( config: Config, - options: Pick, + options: Pick, permission: Permission, operation: string, ): void { @@ -115,12 +143,12 @@ export function checkProtectedConfig( } - if (check.requiresConfirmation) { + if (check.requiresConfirmation && !options.yes) { throw new ProtectedConfigError( config.name, operation, - 'requires confirmation — set NOORM_YES=1 for scripted use, or run this via the noorm CLI/TUI to confirm interactively', + 'requires confirmation — pass --yes, or set NOORM_YES=1 for scripted use, or run this via the noorm CLI/TUI to confirm interactively', ); } diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 36e1458d..713255d3 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -5,7 +5,7 @@ * * @example * ```typescript - * import { createContext } from 'noorm/sdk' + * import { createContext } from '@noormdev/sdk' * * const ctx = await createContext({ config: 'dev' }) * await ctx.connect() @@ -190,12 +190,35 @@ export { tvp } from './tvp.js'; export type { TvpValue } from './tvp.js'; // Guards (errors for catching) -export { RequireTestError, ProtectedConfigError } from './guards.js'; +export { RequireTestError, ProtectedConfigError, NotConnectedError } from './guards.js'; +export { VaultAccessError } from './namespaces/vault.js'; // Impersonation export { ImpersonationError } from './impersonate/index.js'; export type { ImpersonatedScope } from './impersonate/index.js'; +/** + * Process-global event bus for noorm core events. + * + * This is a singleton — one `ObserverEngine` instance shared across every + * `Context`/config created in the process, NOT scoped per-context. If a + * process runs `createContext()` more than once (e.g. a server juggling + * several tenant databases), every context's events flow through this same + * bus. Every context-scoped event payload carries `configName`, so + * multi-context consumers can filter to the config they care about. + * + * @example + * ```typescript + * import { noormObserver } from '@noormdev/sdk' + * + * noormObserver.on('file:after', (data) => { + * if (data.configName !== 'my-config') return + * console.log(`Executed ${data.filepath} in ${data.durationMs}ms`) + * }) + * ``` + */ +export { observer as noormObserver } from '../core/observer.js'; + // Re-export observer types for event subscriptions export type { NoormEvents, NoormEventNames } from '../core/observer.js'; @@ -208,8 +231,20 @@ export type { TableSummary, TableDetail, ExploreOverview, + ViewSummary, + ProcedureSummary, + FunctionSummary, + TypeSummary, + IndexSummary, + ForeignKeySummary, + ViewDetail, + ProcedureDetail, + FunctionDetail, + TypeDetail, + ColumnDetail, + ParameterDetail, } from '../core/explore/index.js'; -export type { TruncateResult, TeardownResult, TeardownPreview } from '../core/teardown/index.js'; +export type { TruncateResult, TeardownResult, TeardownPreview, TruncateOptions } from '../core/teardown/index.js'; export type { BatchResult, FileResult, RunOptions } from '../core/runner/index.js'; export type { ChangeResult, diff --git a/src/sdk/namespaces/changes.ts b/src/sdk/namespaces/changes.ts index d9c3b126..c7e2a44c 100644 --- a/src/sdk/namespaces/changes.ts +++ b/src/sdk/namespaces/changes.ts @@ -35,9 +35,11 @@ import { ChangeManager, } from '../../core/change/index.js'; import { getStateManager } from '../../core/state/index.js'; +import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js'; import { checkProtectedConfig } from '../guards.js'; import type { ContextState } from '../state.js'; +import { requireConnection } from '../state.js'; // ───────────────────────────────────────────────────────────── // ChangesNamespace @@ -158,6 +160,8 @@ export class ChangesNamespace { */ async delete(change: Change): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'change:rm', 'changes.delete'); + return coreDeleteChange(change); } @@ -235,7 +239,7 @@ export class ChangesNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'change:run', 'changes.apply'); - return this.#getManager().run(name, options); + return (await this.#getManager()).run(name, options); } @@ -258,7 +262,7 @@ export class ChangesNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'change:revert', 'changes.revert'); - return this.#getManager().revert(name, options); + return (await this.#getManager()).revert(name, options); } @@ -281,7 +285,7 @@ export class ChangesNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'change:ff', 'changes.ff'); - return this.#getManager().ff(options); + return (await this.#getManager()).ff(options); } @@ -303,7 +307,7 @@ export class ChangesNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'change:run', 'changes.next'); - return this.#getManager().next(count, options); + return (await this.#getManager()).next(count, options); } @@ -321,7 +325,7 @@ export class ChangesNamespace { */ async status(): Promise { - return this.#getManager().list(); + return (await this.#getManager()).list(); } @@ -353,7 +357,7 @@ export class ChangesNamespace { */ async history(limit?: number): Promise { - return this.#getManager().getHistory(undefined, limit); + return (await this.#getManager()).getHistory(undefined, limit); } @@ -369,7 +373,7 @@ export class ChangesNamespace { */ async historyForChange(name: string, limit?: number): Promise { - return this.#getManager().getHistory(name, limit); + return (await this.#getManager()).getHistory(name, limit); } @@ -389,7 +393,7 @@ export class ChangesNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'change:revert', 'changes.rewind'); - return this.#getManager().rewind(target); + return (await this.#getManager()).rewind(target); } @@ -407,7 +411,7 @@ export class ChangesNamespace { */ async getFileHistory(operationId: number): Promise { - return this.#getManager().getFileHistory(operationId); + return (await this.#getManager()).getFileHistory(operationId); } @@ -435,29 +439,20 @@ export class ChangesNamespace { get #kysely(): Kysely { - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } - - return this.#state.connection.db; + return requireConnection(this.#state).db; } - #createChangeContext(): ChangeContext { + async #createChangeContext(): Promise { const state = getStateManager(this.#state.projectRoot); - - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } + const conn = requireConnection(this.#state); + const db = this.#kysely as unknown as Kysely; + const vaultKey = await resolveVaultKey(db, conn.dialect); return { - db: this.#kysely as unknown as Kysely, - dialect: this.#state.connection.dialect, + db, + dialect: conn.dialect, configName: this.#state.config.name, identity: this.#state.identity, projectRoot: this.#state.projectRoot, @@ -466,17 +461,17 @@ export class ChangesNamespace { access: this.#state.config.access, channel: this.#state.options.channel ?? 'user', config: this.#state.config as unknown as Record, - secrets: state.getAllSecrets(this.#state.config.name), + secrets: await buildSecretsContext(state, this.#state.config.name, db, vaultKey, conn.dialect), globalSecrets: state.getAllGlobalSecrets(), }; } - #getManager(): ChangeManager { + async #getManager(): Promise { if (!this.#manager) { - this.#manager = new ChangeManager(this.#createChangeContext()); + this.#manager = new ChangeManager(await this.#createChangeContext()); } diff --git a/src/sdk/namespaces/db.ts b/src/sdk/namespaces/db.ts index df82d7a1..e0d48803 100644 --- a/src/sdk/namespaces/db.ts +++ b/src/sdk/namespaces/db.ts @@ -24,11 +24,12 @@ import type { ExploreOverview, } from '../../core/explore/index.js'; import { fetchOverview, fetchList, fetchDetail } from '../../core/explore/index.js'; -import type { TruncateOptions, TruncateResult, TeardownResult, TeardownPreview } from '../../core/teardown/index.js'; +import type { TruncateOptions, TruncateResult, TeardownOptions, TeardownResult, TeardownPreview } from '../../core/teardown/index.js'; import { truncateData, teardownSchema, previewTeardown } from '../../core/teardown/index.js'; import { formatIdentity } from '../../core/identity/index.js'; import type { ContextState } from '../state.js'; +import { requireConnection } from '../state.js'; import type { BuildOptions } from '../types.js'; import { checkProtectedConfig } from '../guards.js'; @@ -39,11 +40,12 @@ import { checkProtectedConfig } from '../guards.js'; export class DbNamespace { #state: ContextState; - #buildFn: ((opts?: BuildOptions) => Promise) | null = null; + #buildFn: ((opts?: BuildOptions) => Promise) | null; - constructor(state: ContextState) { + constructor(state: ContextState, buildFn?: (opts?: BuildOptions) => Promise) { this.#state = state; + this.#buildFn = buildFn ?? null; } @@ -290,12 +292,17 @@ export class DbNamespace { /** * Drop all database objects except noorm tracking tables. * + * Pass `dryRun: true` to compute what would be dropped without + * executing any statements. + * * @example * ```typescript * const result = await ctx.noorm.db.teardown() + * + * const preview = await ctx.noorm.db.teardown({ dryRun: true }) * ``` */ - async teardown(): Promise { + async teardown(options?: TeardownOptions): Promise { checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'teardown'); @@ -304,6 +311,7 @@ export class DbNamespace { executedBy: formatIdentity(this.#state.identity), preserveTables: this.#state.settings.teardown?.preserveTables, postScript: this.#state.settings.teardown?.postScript, + dryRun: options?.dryRun, }); } @@ -340,30 +348,13 @@ export class DbNamespace { } - // ───────────────────────────────────────────────────── - // Build injection (for reset) - // ───────────────────────────────────────────────────── - - /** @internal Used by NoormOps to wire up reset -> build. */ - set _buildFn(fn: (opts?: BuildOptions) => Promise) { - - this.#buildFn = fn; - - } - // ───────────────────────────────────────────────────── // Private // ───────────────────────────────────────────────────── get #kysely(): Kysely { - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } - - return this.#state.connection.db; + return requireConnection(this.#state).db; } diff --git a/src/sdk/namespaces/dt.ts b/src/sdk/namespaces/dt.ts index 8148bdbc..3b38fc84 100644 --- a/src/sdk/namespaces/dt.ts +++ b/src/sdk/namespaces/dt.ts @@ -9,6 +9,7 @@ import type { Dialect } from '../../core/connection/index.js'; import { exportTable as coreExportTable, importDtFile } from '../../core/dt/index.js'; import type { ContextState } from '../state.js'; +import { requireConnection } from '../state.js'; import type { ExportOptions, ImportOptions } from '../types.js'; import { checkProtectedConfig } from '../guards.js'; @@ -31,16 +32,16 @@ export class DtNamespace { * * @example * ```typescript - * const [result, err] = await ctx.noorm.dt.exportTable('users', './exports/users.dtz') + * const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz') * ``` */ async exportTable( tableName: string, filepath: string, options?: ExportOptions, - ): Promise<[{ rowsWritten: number; bytesWritten: number } | null, Error | null]> { + ): Promise<{ rowsWritten: number; bytesWritten: number }> { - return coreExportTable({ + const [result, err] = await coreExportTable({ db: this.#kysely, dialect: this.#dialect, tableName, @@ -50,6 +51,14 @@ export class DtNamespace { batchSize: options?.batchSize, }); + if (err) throw err; + + // coreExportTable only leaves result null when err is set (checked + // above), so this narrows without a cast. + if (!result) throw new Error('exportTable returned no result and no error'); + + return result; + } /** @@ -57,7 +66,7 @@ export class DtNamespace { * * @example * ```typescript - * const [result, err] = await ctx.noorm.dt.importFile('./exports/users.dtz', { + * const result = await ctx.noorm.dt.importFile('./exports/users.dtz', { * onConflict: 'skip', * }) * ``` @@ -65,11 +74,11 @@ export class DtNamespace { async importFile( filepath: string, options?: ImportOptions, - ): Promise<[{ rowsImported: number; rowsSkipped: number } | null, Error | null]> { + ): Promise<{ rowsImported: number; rowsSkipped: number }> { checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'dt.importFile'); - return importDtFile({ + const [result, err] = await importDtFile({ filepath, db: this.#kysely, dialect: this.#dialect, @@ -79,6 +88,14 @@ export class DtNamespace { truncate: options?.truncate, }); + if (err) throw err; + + // importDtFile only leaves result null when err is set (checked + // above), so this narrows without a cast. + if (!result) throw new Error('importFile returned no result and no error'); + + return result; + } // ───────────────────────────────────────────────────── @@ -87,13 +104,7 @@ export class DtNamespace { get #kysely(): Kysely { - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } - - return this.#state.connection.db; + return requireConnection(this.#state).db; } diff --git a/src/sdk/namespaces/lock.ts b/src/sdk/namespaces/lock.ts index 5f626937..092cceff 100644 --- a/src/sdk/namespaces/lock.ts +++ b/src/sdk/namespaces/lock.ts @@ -11,6 +11,7 @@ import { getLockManager } from '../../core/lock/index.js'; import { formatIdentity } from '../../core/identity/index.js'; import type { ContextState } from '../state.js'; +import { requireConnection } from '../state.js'; // ───────────────────────────────────────────────────────────── // LockNamespace @@ -141,13 +142,7 @@ export class LockNamespace { get #kysely(): Kysely { - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } - - return this.#state.connection.db; + return requireConnection(this.#state).db; } diff --git a/src/sdk/namespaces/run.ts b/src/sdk/namespaces/run.ts index 7e7ad989..f8259f85 100644 --- a/src/sdk/namespaces/run.ts +++ b/src/sdk/namespaces/run.ts @@ -6,9 +6,12 @@ */ import path from 'node:path'; +import { attempt } from '@logosdx/utils'; + import type { Kysely } from 'kysely'; import type { NoormDatabase } from '../../core/shared/index.js'; +import { filterFilesByPaths } from '../../core/shared/index.js'; import type { RunContext, RunOptions, @@ -23,10 +26,13 @@ import { preview as corePreview, discoverFiles as coreDiscoverFiles, } from '../../core/runner/index.js'; +import { getEffectiveBuildPaths } from '../../core/settings/rules.js'; import { getStateManager } from '../../core/state/index.js'; +import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js'; import { checkProtectedConfig } from '../guards.js'; import type { ContextState } from '../state.js'; +import { requireConnection } from '../state.js'; import type { BuildOptions } from '../types.js'; // ───────────────────────────────────────────────────────────── @@ -82,7 +88,7 @@ export class RunNamespace { output?: string | null, ): Promise { - const context = this.#createRunContext(); + const context = await this.#createRunContext(); const absolutePaths = filepaths.map((fp) => this.#resolvePath(fp)); return corePreview(context, absolutePaths, output); @@ -105,7 +111,7 @@ export class RunNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'run:file', 'run.file'); - const context = this.#createRunContext(); + const context = await this.#createRunContext(); const absolutePath = this.#resolvePath(filepath); return coreRunFile(context, absolutePath, options); @@ -124,7 +130,7 @@ export class RunNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'run:dir', 'run.files'); - const context = this.#createRunContext(); + const context = await this.#createRunContext(); const absolutePaths = filepaths.map((fp) => this.#resolvePath(fp)); return coreRunFiles(context, absolutePaths, options); @@ -143,7 +149,7 @@ export class RunNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'run:dir', 'run.dir'); - const context = this.#createRunContext(); + const context = await this.#createRunContext(); const absolutePath = this.#resolvePath(dirpath); return coreRunDir(context, absolutePath, options); @@ -153,6 +159,17 @@ export class RunNamespace { /** * Execute all SQL files in the schema directory. * + * Honors `settings.build.include`/`exclude` and `settings.rules` + * identically to the TUI's Run Build screen (`RunBuildScreen.tsx`) — + * headless callers (CLI/MCP/SDK, plus `db.reset`) must see the same + * effective file set a human confirms interactively. The filtered list + * is always computed and passed through, even when nothing is excluded, + * so an all-excluded build (`preFilteredFiles = []`) reads as a real + * pre-filtered result rather than "not provided" (runner.ts's + * `if (preFilteredFiles)` check would otherwise fall back to full + * discovery for an empty array only if we conditionally passed + * `undefined` instead). + * * @example * ```typescript * await ctx.noorm.run.build({ force: true }) @@ -162,13 +179,46 @@ export class RunNamespace { checkProtectedConfig(this.#state.config, this.#state.options, 'run:build', 'run.build'); - const context = this.#createRunContext(); + const context = await this.#createRunContext(); const sqlPath = path.join( this.#state.projectRoot, this.#state.settings.paths?.sql ?? 'sql', ); - return runBuild(context, sqlPath, { force: options?.force }); + const configForMatch = { + name: this.#state.config.name, + access: this.#state.config.access, + isTest: this.#state.config.isTest ?? false, + type: this.#state.config.type, + }; + + const effectivePaths = getEffectiveBuildPaths( + this.#state.settings.build?.include ?? [], + this.#state.settings.build?.exclude ?? [], + this.#state.settings.rules ?? [], + configForMatch, + ); + + // attempt() here to preserve runBuild's discovery-failure contract: + // on error (e.g. missing sql dir) fall back to runBuild's own + // discovery, which emits and resolves a failed BatchResult instead + // of throwing — db.reset and CLI callers rely on that shape. + const [discoveredFiles, discoverErr] = await attempt(() => coreDiscoverFiles(sqlPath)); + + if (discoverErr) { + + return runBuild(context, sqlPath, { force: options?.force, dryRun: options?.dryRun }); + + } + + const filteredFiles = filterFilesByPaths( + discoveredFiles, + sqlPath, + effectivePaths.include, + effectivePaths.exclude, + ); + + return runBuild(context, sqlPath, { force: options?.force, dryRun: options?.dryRun }, filteredFiles); } @@ -178,13 +228,7 @@ export class RunNamespace { get #kysely(): Kysely { - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } - - return this.#state.connection.db; + return requireConnection(this.#state).db; } @@ -196,20 +240,23 @@ export class RunNamespace { } - #createRunContext(): RunContext { + async #createRunContext(): Promise { const state = getStateManager(this.#state.projectRoot); + const db = this.#kysely as unknown as Kysely; + const dialect = this.#state.config.connection.dialect; + const vaultKey = await resolveVaultKey(db, dialect); return { - db: this.#kysely as unknown as Kysely, + db, configName: this.#state.config.name, identity: this.#state.identity, projectRoot: this.#state.projectRoot, - dialect: this.#state.config.connection.dialect, + dialect, access: this.#state.config.access, channel: this.#state.options.channel ?? 'user', config: this.#state.config as unknown as Record, - secrets: state.getAllSecrets(this.#state.config.name), + secrets: await buildSecretsContext(state, this.#state.config.name, db, vaultKey, dialect), globalSecrets: state.getAllGlobalSecrets(), }; diff --git a/src/sdk/namespaces/templates.ts b/src/sdk/namespaces/templates.ts index a2f32315..ea795e6f 100644 --- a/src/sdk/namespaces/templates.ts +++ b/src/sdk/namespaces/templates.ts @@ -5,9 +5,13 @@ */ import path from 'node:path'; +import type { Kysely } from 'kysely'; + import type { ProcessResult as TemplateResult } from '../../core/template/index.js'; import { processFile } from '../../core/template/index.js'; import { getStateManager } from '../../core/state/index.js'; +import type { NoormDatabase } from '../../core/shared/index.js'; +import { resolveVaultKey, buildSecretsContext } from '../../core/vault/index.js'; import type { ContextState } from '../state.js'; @@ -40,11 +44,14 @@ export class TemplatesNamespace { : path.join(this.#state.projectRoot, filepath); const state = getStateManager(this.#state.projectRoot); + const conn = this.#state.connection; + const db = conn?.db as unknown as Kysely | undefined; + const vaultKey = db && conn ? await resolveVaultKey(db, conn.dialect) : null; return processFile(absolutePath, { projectRoot: this.#state.projectRoot, config: this.#state.config as unknown as Record, - secrets: state.getAllSecrets(this.#state.config.name), + secrets: await buildSecretsContext(state, this.#state.config.name, db, vaultKey, conn?.dialect), globalSecrets: state.getAllGlobalSecrets(), }); diff --git a/src/sdk/namespaces/transfer.ts b/src/sdk/namespaces/transfer.ts index 125dcd11..9752ade1 100644 --- a/src/sdk/namespaces/transfer.ts +++ b/src/sdk/namespaces/transfer.ts @@ -29,7 +29,7 @@ export class TransferNamespace { * * @example * ```typescript - * const [result, err] = await ctx.noorm.transfer.to(destConfig, { + * const result = await ctx.noorm.transfer.to(destConfig, { * tables: ['users', 'posts'], * onConflict: 'skip', * }) @@ -38,18 +38,26 @@ export class TransferNamespace { async to( destConfig: Config, options?: TransferOptions, - ): Promise<[TransferResult | null, Error | null]> { + ): Promise { // Gated against destConfig (the write target), not the source — the // SDK has no interactive prompt, so a `db:reset` confirm cell blocks // outright here, same as db.truncate()/dt.importFile(). checkProtectedConfig(destConfig, this.#state.options, 'db:reset', 'transfer.to'); - return transferData(this.#state.config, destConfig, { + const [result, err] = await transferData(this.#state.config, destConfig, { ...options, channel: this.#state.options.channel ?? 'user', }); + if (err) throw err; + + // transferData only leaves result null when err is set (checked above), + // so this narrows without a cast. + if (!result) throw new Error('transferData returned no result and no error'); + + return result; + } /** @@ -57,15 +65,23 @@ export class TransferNamespace { * * @example * ```typescript - * const [plan, err] = await ctx.noorm.transfer.plan(destConfig) + * const plan = await ctx.noorm.transfer.plan(destConfig) * ``` */ async plan( destConfig: Config, options?: TransferOptions, - ): Promise<[TransferPlan | null, Error | null]> { + ): Promise { + + const [plan, err] = await getTransferPlan(this.#state.config, destConfig, options); + + if (err) throw err; + + // getTransferPlan only leaves plan null when err is set (checked above), + // so this narrows without a cast. + if (!plan) throw new Error('getTransferPlan returned no plan and no error'); - return getTransferPlan(this.#state.config, destConfig, options); + return plan; } diff --git a/src/sdk/namespaces/utils.ts b/src/sdk/namespaces/utils.ts index 3d69e380..e7e9288e 100644 --- a/src/sdk/namespaces/utils.ts +++ b/src/sdk/namespaces/utils.ts @@ -46,6 +46,11 @@ export class UtilsNamespace { /** * Tests if the connection can be established. * + * Deliberately returns `{ ok, error? }` instead of throwing like the rest of + * the SDK: a connection attempt that correctly reports failure has done its + * job, so the caller gets the outcome as data to display, not an exception + * to handle. + * * @example * ```typescript * const result = await ctx.noorm.utils.testConnection() diff --git a/src/sdk/namespaces/vault.ts b/src/sdk/namespaces/vault.ts index c3c2fb99..a9891dc2 100644 --- a/src/sdk/namespaces/vault.ts +++ b/src/sdk/namespaces/vault.ts @@ -32,6 +32,37 @@ import { formatIdentity, loadIdentityMetadata } from '../../core/identity/index. import type { CryptoIdentity } from '../../core/identity/types.js'; import type { ContextState } from '../state.js'; +import { requireConnection } from '../state.js'; + +// ───────────────────────────────────────────────────────────── +// Errors +// ───────────────────────────────────────────────────────────── + +/** + * Error thrown by `VaultNamespace.set()` when the supplied `privateKey` + * yields no usable vault key — either the vault was never propagated to + * this identity, or the key doesn't match. The two causes are + * indistinguishable by design (see the vault absence-vs-failure rule). + * + * @example + * ```typescript + * await ctx.noorm.vault.set('API_KEY', 'value', privateKey) // Throws VaultAccessError + * ``` + */ +export class VaultAccessError extends Error { + + override readonly name = 'VaultAccessError' as const; + + constructor(public readonly configName: string) { + + super( + `No vault access for config "${configName}". Run vault.init() or have a team ` + + 'member vault.propagate() access to you.', + ); + + } + +} // ───────────────────────────────────────────────────────────── // VaultNamespace @@ -84,17 +115,17 @@ export class VaultNamespace { /** * Initialize the vault for this database. * - * Idempotent. Returns the vault key on first init; returns [null, null] - * on subsequent calls when the vault already exists. Returns [null, Error] - * only on real failures (DB errors, encryption errors). + * Idempotent. Returns the vault key on first init; returns null on + * subsequent calls when the vault already exists — that is NOT an error + * case. Throws the underlying Error only on real failures (DB errors, + * encryption errors). * * The `vault:initialized` observer event fires only on first init, never * on repeat calls. * * @example * ```typescript - * const [vaultKey, err] = await ctx.noorm.vault.init(); - * if (err) throw err; + * const vaultKey = await ctx.noorm.vault.init(); * if (vaultKey) { * // first-time init — set initial secrets, etc. * } @@ -103,17 +134,21 @@ export class VaultNamespace { * } * ``` */ - async init(): Promise<[Buffer | null, Error | null]> { + async init(): Promise { const crypto = await this.#getCryptoIdentity(); - return initializeVault( + const [vaultKey, err] = await initializeVault( this.#kysely as unknown as Kysely, crypto.identityHash, crypto.publicKey, this.#dialect, ); + if (err) throw err; + + return vaultKey; + } /** @@ -145,20 +180,20 @@ export class VaultNamespace { * * @example * ```typescript - * const [, err] = await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey) + * await ctx.noorm.vault.set('API_KEY', 'sk-live-...', privateKey) * ``` */ async set( key: string, value: string, privateKey: string, - ): Promise<[void, Error | null]> { + ): Promise { const vaultKey = await this.#getVaultKey(privateKey); - if (!vaultKey) return [undefined, new Error('No vault access')]; + if (!vaultKey) throw new VaultAccessError(this.#state.config.name); - return setVaultSecret( + const [, err] = await setVaultSecret( this.#kysely as unknown as Kysely, vaultKey, key, @@ -167,6 +202,8 @@ export class VaultNamespace { this.#dialect, ); + if (err) throw err; + } /** @@ -236,17 +273,21 @@ export class VaultNamespace { * * @example * ```typescript - * const [deleted, err] = await ctx.noorm.vault.delete('OLD_KEY') + * const deleted = await ctx.noorm.vault.delete('OLD_KEY') * ``` */ - async delete(key: string): Promise<[boolean, Error | null]> { + async delete(key: string): Promise { - return deleteVaultSecret( + const [deleted, err] = await deleteVaultSecret( this.#kysely as unknown as Kysely, key, this.#dialect, ); + if (err) throw err; + + return deleted; + } /** @@ -302,7 +343,7 @@ export class VaultNamespace { * * @example * ```typescript - * const [result, err] = await ctx.noorm.vault.copy(destConfig, ['API_KEY'], privateKey) + * const result = await ctx.noorm.vault.copy(destConfig, ['API_KEY'], privateKey) * ``` */ async copy( @@ -310,11 +351,11 @@ export class VaultNamespace { keys: string[] | 'all', privateKey: string, options?: VaultCopyOptions, - ): Promise<[VaultCopyResult | null, Error | null]> { + ): Promise { const crypto = await this.#getCryptoIdentity(); - return copyVaultSecrets( + const [result, err] = await copyVaultSecrets( this.#state.config, destConfig, keys, @@ -324,6 +365,14 @@ export class VaultNamespace { options, ); + if (err) throw err; + + // copyVaultSecrets only leaves result null when err is set (checked above), + // so this narrows without a cast. + if (!result) throw new Error('copyVaultSecrets returned no result and no error'); + + return result; + } // ───────────────────────────────────────────────────── @@ -332,13 +381,7 @@ export class VaultNamespace { get #kysely(): Kysely { - if (!this.#state.connection) { - - throw new Error('Not connected. Call connect() first.'); - - } - - return this.#state.connection.db; + return requireConnection(this.#state).db; } diff --git a/src/sdk/noorm-ops.ts b/src/sdk/noorm-ops.ts index 1aa69e9c..705e5a25 100644 --- a/src/sdk/noorm-ops.ts +++ b/src/sdk/noorm-ops.ts @@ -8,7 +8,6 @@ import type { Config } from '../core/config/types.js'; import type { Settings } from '../core/settings/index.js'; import type { Identity } from '../core/identity/index.js'; -import { observer } from '../core/observer.js'; import type { ContextState } from './state.js'; import { @@ -88,12 +87,6 @@ export class NoormOps { } - get observer() { - - return observer; - - } - // ───────────────────────────────────────────────────── // Namespace Getters (lazy) // ───────────────────────────────────────────────────── @@ -118,8 +111,7 @@ export class NoormOps { if (!this.#db) { - this.#db = new DbNamespace(this.#state); - this.#db._buildFn = (opts) => this.run.build(opts); + this.#db = new DbNamespace(this.#state, (opts) => this.run.build(opts)); } diff --git a/src/sdk/state.ts b/src/sdk/state.ts index 03899f06..b468b16c 100644 --- a/src/sdk/state.ts +++ b/src/sdk/state.ts @@ -12,6 +12,7 @@ import type { Settings } from '../core/settings/index.js'; import type { Identity } from '../core/identity/index.js'; import type { ChangeManager } from '../core/change/index.js'; +import { NotConnectedError } from './guards.js'; import type { CreateContextOptions } from './types.js'; // ───────────────────────────────────────────────────────────── @@ -27,3 +28,27 @@ export interface ContextState { projectRoot: string; changeManager: ChangeManager | null; } + +// ───────────────────────────────────────────────────────────── +// Guards +// ───────────────────────────────────────────────────────────── + +/** + * Require a live connection, collapsing the 8 duplicated + * `if (!state.connection) throw ...` sites across the namespaces into one + * helper. Covers both call shapes: namespaces that only need `.db`, and + * `ChangesNamespace#createChangeContext`, which also needs `.dialect`. + * + * @throws NotConnectedError if state.connection is null + */ +export function requireConnection(state: ContextState): ConnectionResult { + + if (!state.connection) { + + throw new NotConnectedError(); + + } + + return state.connection; + +} diff --git a/src/sdk/types.ts b/src/sdk/types.ts index b0dc2388..f494962d 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -4,6 +4,7 @@ * All interfaces and types for the noorm programmatic SDK. */ import type { Channel } from '../core/policy/index.js'; +import type { ConflictStrategy } from '../core/transfer/types.js'; // ───────────────────────────────────────────────────────────── @@ -61,6 +62,14 @@ export interface CreateContextOptions { */ channel?: Channel; + /** + * Pre-confirm operations that a policy `confirm` cell would otherwise + * block — the programmatic equivalent of the CLI's --yes. Only + * meaningful on the user channel; mcp collapses confirm to deny + * before this is consulted. Default: false. + */ + yes?: boolean; + } // ───────────────────────────────────────────────────────────── @@ -75,6 +84,9 @@ export interface BuildOptions { /** Skip checksum checks, rebuild everything. Default: false */ force?: boolean; + /** Render files without executing them. Default: false */ + dryRun?: boolean; + } // ───────────────────────────────────────────────────────────── @@ -88,7 +100,7 @@ export interface BuildOptions { * * @example * ```typescript - * const [result, err] = await ctx.exportTable('users', './exports/users.dtz', { + * const result = await ctx.noorm.dt.exportTable('users', './exports/users.dtz', { * passphrase: 'secret', * batchSize: 5000, * }); @@ -114,7 +126,7 @@ export interface ExportOptions { * * @example * ```typescript - * const [result, err] = await ctx.importFile('./exports/users.dtz', { + * const result = await ctx.noorm.dt.importFile('./exports/users.dtz', { * onConflict: 'skip', * truncate: true, * }); @@ -129,7 +141,7 @@ export interface ImportOptions { batchSize?: number; /** Conflict strategy. Default: 'fail'. */ - onConflict?: 'fail' | 'skip' | 'update' | 'replace'; + onConflict?: ConflictStrategy; /** Truncate target table before import. Default: false. */ truncate?: boolean; diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index 1a77dff1..f95669d6 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -72,7 +72,6 @@ export { SecretValueList, SecretValueListHelp, SECRET_TYPE_OPTIONS, - SECRET_KEY_PATTERN, validateSecretKey, checkDuplicateKey, } from './secrets/index.js'; diff --git a/src/tui/components/secrets/SecretValueForm.tsx b/src/tui/components/secrets/SecretValueForm.tsx index bdb08f2a..2dab99c1 100644 --- a/src/tui/components/secrets/SecretValueForm.tsx +++ b/src/tui/components/secrets/SecretValueForm.tsx @@ -12,6 +12,7 @@ import type { FormField, FormValues } from '../forms/index.js'; import type { StageSecret } from './types.js'; import { Form } from '../forms/index.js'; +import { isValidSecretKey } from '../../../core/state/index.js'; /** * Props for SecretValueForm. @@ -112,7 +113,7 @@ export function SecretValueForm({ if (!val) return 'Key is required'; // Relaxed validation - allow lowercase but warn format - if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(val)) { + if (!isValidSecretKey(val)) { return 'Key must start with letter, contain only letters, numbers, underscores'; diff --git a/src/tui/components/secrets/index.ts b/src/tui/components/secrets/index.ts index 1c4765c9..730aa096 100644 --- a/src/tui/components/secrets/index.ts +++ b/src/tui/components/secrets/index.ts @@ -25,7 +25,6 @@ export type { StageSecret, SecretType, SecretValueItem, SecretValueSummary } fro export { SECRET_TYPE_OPTIONS, - SECRET_KEY_PATTERN, validateSecretKey, checkDuplicateKey, } from './types.js'; diff --git a/src/tui/components/secrets/types.ts b/src/tui/components/secrets/types.ts index be656093..ca3c833b 100644 --- a/src/tui/components/secrets/types.ts +++ b/src/tui/components/secrets/types.ts @@ -2,6 +2,7 @@ * Shared types and utilities for secret components. */ import type { StageSecret, SecretType } from '../../../core/settings/types.js'; +import { isValidSecretKey } from '../../../core/state/index.js'; // Re-export for convenience export type { StageSecret, SecretType }; @@ -16,16 +17,12 @@ export const SECRET_TYPE_OPTIONS = [ { label: 'Connection String', value: 'connection_string' }, ] as const; -/** - * Validation pattern for secret keys. - * Must start with a letter, contain only letters, digits, and underscores. - * Allows both uppercase and lowercase (e.g., DB_PASSWORD, db_password, DbPassword). - */ -export const SECRET_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; - /** * Validate a secret key. * + * Format check delegates to `isValidSecretKey` (the `StateManager.setSecret` + * seam) so this stays live-typing feedback, not a second source of truth. + * * @returns Error message if invalid, undefined if valid */ export function validateSecretKey(key: string): string | undefined { @@ -38,7 +35,7 @@ export function validateSecretKey(key: string): string | undefined { } - if (!SECRET_KEY_PATTERN.test(trimmed)) { + if (!isValidSecretKey(trimmed)) { return 'Key must start with a letter, contain only letters, numbers, underscores'; diff --git a/src/tui/components/terminal/ResultTable.tsx b/src/tui/components/terminal/ResultTable.tsx index e705234a..bce28adf 100644 --- a/src/tui/components/terminal/ResultTable.tsx +++ b/src/tui/components/terminal/ResultTable.tsx @@ -20,6 +20,7 @@ */ import { useState, useMemo, useCallback, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; +import v from 'voca'; import type { ReactElement } from 'react'; /** @@ -241,17 +242,6 @@ function detectNumericIdColumn( } -/** - * Truncate a string to max length with ellipsis. - */ -function truncate(str: string, maxLen: number): string { - - if (str.length <= maxLen) return str; - - return str.slice(0, maxLen - 1) + '\u2026'; - -} - /** * Format a cell value for display. */ @@ -650,7 +640,7 @@ export function ResultTable({ {columns.map((col, colIndex) => { const colWidth = columnWidths[col] ?? col.length; - const value = truncate(formatCellValue(row[col]), colWidth); + const value = v.truncate(formatCellValue(row[col]), colWidth, '\u2026'); const paddedValue = value.padEnd(colWidth); return ( diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts index 8d19c882..32a6d5d1 100644 --- a/src/tui/hooks/index.ts +++ b/src/tui/hooks/index.ts @@ -5,9 +5,7 @@ export { useOnEvent, useOnceEvent, useEmit, - useEventPromise, useOnScreenPopped, - type EventPromiseState, } from './useObserver.js'; export { diff --git a/src/tui/hooks/useObserver.ts b/src/tui/hooks/useObserver.ts index 187daa09..2679bcdd 100644 --- a/src/tui/hooks/useObserver.ts +++ b/src/tui/hooks/useObserver.ts @@ -16,9 +16,6 @@ * // Emit events * const emitStart = useEmit('build:start') * emitStart({ sqlPath, fileCount }) - * - * // Promise-based subscription - * const [waiting, result, cancel] = useEventPromise('build:complete') * ``` */ import { useCallback, useRef, useMemo, type DependencyList } from 'react'; @@ -106,45 +103,6 @@ export function useEmit( } -/** - * State for useEventPromise hook. - */ -export interface EventPromiseState { - /** The resolved value, or null if pending/error */ - value: T | null; - - /** The error if rejected, or null if pending/success */ - error: Error | null; - - /** Whether the promise is still pending */ - pending: boolean; -} - -/** - * Subscribe to an event as a promise with state management. - * - * Returns the current state and a cancel function. The promise resolves - * on the first event emission. Call cancel to unsubscribe early. - * - * @example - * ```typescript - * const [waiting, result, cancel] = useEventPromise('build:complete') - * - * if (waiting) return - * if (result) return Built {result.filesRun} files - * ``` - */ -export function useEventPromise( - event: E, -): [value: NoormEvents[E] | null, error: Error | null, pending: boolean, cancel: () => void] { - - const { oncePromise } = useNoormObserver(); - const [waiting, data, cancel] = oncePromise(event); - - return [data, null, waiting, cancel]; - -} - /** * Run a callback when specific screens are popped from history. * diff --git a/src/tui/screens/change/ChangeRemoveScreen.tsx b/src/tui/screens/change/ChangeRemoveScreen.tsx index ae6dc01e..206f031a 100644 --- a/src/tui/screens/change/ChangeRemoveScreen.tsx +++ b/src/tui/screens/change/ChangeRemoveScreen.tsx @@ -23,9 +23,10 @@ import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; -import { Panel, Spinner, StatusMessage, Confirm, MissingParamPanel } from '../../components/index.js'; +import { Panel, Spinner, StatusMessage, SmartConfirm, MissingParamPanel } from '../../components/index.js'; +import { checkConfigPolicy } from '../../../core/policy/index.js'; import { useAsyncEffect } from '../../hooks/index.js'; -import { getErrorMessage, loadChangesWithStatus } from '../../utils/index.js'; +import { getErrorMessage, loadChangesWithStatus, isConfigGuarded } from '../../utils/index.js'; import { deleteChange } from '../../../core/change/scaffold.js'; import { ChangeHistory } from '../../../core/change/history.js'; import { createConnection } from '../../../core/connection/factory.js'; @@ -48,6 +49,7 @@ export function ChangeRemoveScreen({ params }: ScreenProps): ReactElement { const { navigate: _navigate, back } = useRouter(); const { isFocused } = useFocusScope('ChangeRemove'); const { activeConfig, activeConfigName, projectRoot, settings } = useAppContext(); + const check = activeConfig ? checkConfigPolicy('user', activeConfig, 'change:rm') : null; const changeName = params.name; @@ -195,6 +197,17 @@ export function ChangeRemoveScreen({ params }: ScreenProps): ReactElement { } + // Denied by policy + if (check && !check.allowed) { + + return ( + + {check.blockedReason} + + ); + + } + // Loading if (step === 'loading') { @@ -217,7 +230,7 @@ export function ChangeRemoveScreen({ params }: ScreenProps): ReactElement { : 'This change has not been applied.'; return ( - + Delete change:{' '} @@ -237,7 +250,11 @@ export function ChangeRemoveScreen({ params }: ScreenProps): ReactElement { {warningMessage} - ; - // Build context - const context = { + const manager = createChangeManager({ db, configName: activeConfigName ?? '', - identity: resolveScreenIdentity(cryptoIdentity), projectRoot, - changesDir: resolveChangesDir(projectRoot, settings), - sqlDir: resolveSqlDir(projectRoot, settings), - access: activeConfig.access, - channel: 'user' as const, - }; - - // Execute revert - const result = await revertChange(context, change); + settings, + cryptoIdentity, + activeConfig, + }); + + const result = await manager.revert(change.name); await conn.destroy(); diff --git a/src/tui/screens/change/ChangeRunScreen.tsx b/src/tui/screens/change/ChangeRunScreen.tsx index 2f0b0460..41a1880c 100644 --- a/src/tui/screens/change/ChangeRunScreen.tsx +++ b/src/tui/screens/change/ChangeRunScreen.tsx @@ -35,12 +35,9 @@ import { checkConfigPolicy } from '../../../core/policy/index.js'; import { useChangeProgress, useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage, loadChangesWithStatus, - resolveScreenIdentity, - resolveChangesDir, - resolveSqlDir, + createChangeManager, isConfigGuarded, } from '../../utils/index.js'; -import { executeChange } from '../../../core/change/executor.js'; import { validateChangeContent } from '../../../core/change/validation.js'; import { createConnection } from '../../../core/connection/factory.js'; @@ -141,20 +138,16 @@ export function ChangeRunScreen({ params }: ScreenProps): ReactElement { ); const db = conn.db as Kysely; - // Build context - const context = { + const manager = createChangeManager({ db, configName: activeConfigName ?? '', - identity: resolveScreenIdentity(cryptoIdentity), projectRoot, - changesDir: resolveChangesDir(projectRoot, settings), - sqlDir: resolveSqlDir(projectRoot, settings), - access: activeConfig.access, - channel: 'user' as const, - }; - - // Execute change - const result = await executeChange(context, change); + settings, + cryptoIdentity, + activeConfig, + }); + + const result = await manager.run(change.name); await conn.destroy(); diff --git a/src/tui/screens/config/ConfigEditScreen.tsx b/src/tui/screens/config/ConfigEditScreen.tsx index 427accfb..87d5441b 100644 --- a/src/tui/screens/config/ConfigEditScreen.tsx +++ b/src/tui/screens/config/ConfigEditScreen.tsx @@ -23,6 +23,7 @@ import { useRouter } from '../../router.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Form, useToast, MissingParamPanel, NotFoundPanel } from '../../components/index.js'; import { testConnection } from '../../../core/connection/factory.js'; +import { SettingsProvider } from '../../../core/config/resolver.js'; import { getErrorMessage, validateConfigName, @@ -39,8 +40,9 @@ import { export function ConfigEditScreen({ params }: ScreenProps): ReactElement { const { back } = useRouter(); - const { stateManager, refresh } = useAppContext(); + const { stateManager, settingsManager, refresh } = useAppContext(); const { showToast } = useToast(); + const { stdout } = useStdout(); const configName = params.name; @@ -57,6 +59,16 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { }, [stateManager, configName]); + // Settings provider is only built once settingsManager has loaded; a null + // provider means "no stages known" = no lock, matching canDeleteConfig's + // own no-settings behavior. Passed into the rename-path delete below so + // the core-seam guard (StateManager.deleteConfig) can enforce it. + const settingsProvider = useMemo( + + () => (settingsManager ? new SettingsProvider(settingsManager) : null), + [settingsManager], + ); + // Form fields with existing values const fields: FormField[] = useMemo(() => { @@ -192,7 +204,7 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { // If name changed, delete old and create new if (newName !== configName) { - await stateManager.deleteConfig(configName); + await stateManager.deleteConfig(configName, settingsProvider ?? undefined); } @@ -218,7 +230,7 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { back(); }, - [stateManager, config, configName, refresh, showToast, back], + [stateManager, config, configName, settingsProvider, refresh, showToast, back], ); // Handle cancel @@ -242,7 +254,6 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { } - const { stdout } = useStdout(); const terminalHeight = stdout.rows ?? 24; // Reserve space for Panel border (2), title (2), padding (2) diff --git a/src/tui/screens/config/ConfigListScreen.tsx b/src/tui/screens/config/ConfigListScreen.tsx index 1028e1f7..21178af8 100644 --- a/src/tui/screens/config/ConfigListScreen.tsx +++ b/src/tui/screens/config/ConfigListScreen.tsx @@ -27,7 +27,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, SelectList, type SelectListItem } from '../../components/index.js'; import { syncIdentityWithConfig } from '../../../core/identity/index.js'; -import { guarded } from '../../../core/policy/index.js'; +import { formatAccessTag } from '../../../core/policy/index.js'; import type { ConfigAccess } from '../../../core/policy/index.js'; /** @@ -41,21 +41,6 @@ interface ConfigListValue { isTest: boolean; } -/** - * Formats access as `user: mcp:` — same format as - * `noorm config list` (`src/cli/config/list.ts`) — omitted entirely for - * fully open (admin/admin) configs. - */ -function formatAccessTag(config: { name: string; access: ConfigAccess }): string | null { - - if (!guarded(config)) return null; - - const { access } = config; - - return `user:${access.user} mcp:${access.mcp === false ? 'off' : access.mcp}`; - -} - /** * ConfigListScreen component. * diff --git a/src/tui/screens/config/ConfigRemoveScreen.tsx b/src/tui/screens/config/ConfigRemoveScreen.tsx index c75b64cb..3ef79f96 100644 --- a/src/tui/screens/config/ConfigRemoveScreen.tsx +++ b/src/tui/screens/config/ConfigRemoveScreen.tsx @@ -23,6 +23,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Confirm, ProtectedConfirm, Spinner, useToast, MissingParamPanel, NotFoundPanel } from '../../components/index.js'; import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; +import { canDeleteConfig, SettingsProvider } from '../../../core/config/resolver.js'; import { getErrorMessage } from '../../utils/index.js'; /** @@ -32,7 +33,7 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { const { back } = useRouter(); const { isFocused } = useFocusScope('ConfigRemove'); - const { stateManager, activeConfigName, refresh } = useAppContext(); + const { stateManager, settingsManager, activeConfigName, refresh } = useAppContext(); const { showToast } = useToast(); const configName = params.name; @@ -54,6 +55,23 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { // Policy check for the config:rm permission const check = config ? checkConfigPolicy('user', config, 'config:rm') : null; + // Settings provider is only built once settingsManager has loaded; a null + // provider means "no stages known" = no lock, matching canDeleteConfig's + // own no-settings behavior. + const settingsProvider = useMemo( + + () => (settingsManager ? new SettingsProvider(settingsManager) : null), + [settingsManager], + ); + + // Locked-stage pre-check so the user never reaches a confirm/spinner + // step for a delete that is guaranteed to fail at the core seam. + const lockCheck = useMemo( + + () => (config && configName && settingsProvider ? canDeleteConfig(configName, settingsProvider) : { allowed: true }), + [config, settingsProvider, configName], + ); + // Handle confirm const handleConfirm = useCallback(async () => { @@ -70,7 +88,7 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { const [_, err] = await attempt(async () => { - await stateManager.deleteConfig(configName); + await stateManager.deleteConfig(configName, settingsProvider ?? undefined); await refresh(); }); @@ -94,7 +112,7 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { }); back(); - }, [stateManager, configName, refresh, showToast, back]); + }, [stateManager, configName, settingsProvider, refresh, showToast, back]); // Handle cancel const handleCancel = useCallback(() => { @@ -109,7 +127,7 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { if (!isFocused) return; // Handle escape for error states (no config, not found, active config, denied) - if (!configName || !config || isActive || (check && !check.allowed)) { + if (!configName || !config || isActive || (check && !check.allowed) || !lockCheck.allowed) { if (key.escape || key.return) { @@ -174,6 +192,23 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { } + // Denied by locked stage (belt-and-suspenders with the core-seam guard) + if (!lockCheck.allowed) { + + return ( + + + {lockCheck.reason} + + + + [Enter/Esc] Back + + + ); + + } + // Deleting if (deleting) { diff --git a/src/tui/screens/config/ConfigValidateScreen.tsx b/src/tui/screens/config/ConfigValidateScreen.tsx index 354b05ce..c93c69b8 100644 --- a/src/tui/screens/config/ConfigValidateScreen.tsx +++ b/src/tui/screens/config/ConfigValidateScreen.tsx @@ -22,7 +22,7 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, StatusList, type StatusListItem } from '../../components/index.js'; -import { testConnection } from '../../../core/connection/factory.js'; +import { validateConfigChecks } from '../../../core/config/validate.js'; /** * Validate steps. @@ -62,79 +62,17 @@ export function ConfigValidateScreen({ params }: ScreenProps): ReactElement { const validate = async () => { - const results: StatusListItem[] = []; - let allValid = true; + const { checks, valid } = await validateConfigChecks(config); - // Check connection - results.push({ - key: 'connection', - label: 'Connection', - status: 'pending', - detail: 'Testing database connection...', - }); - setItems([...results]); + const results: StatusListItem[] = checks.map((check) => ({ + key: check.key, + label: check.label, + status: check.status, + detail: check.detail, + })); - const connResult = await testConnection(config.connection); - - if (connResult.ok) { - - results[0] = { - ...results[0]!, - status: 'success', - detail: 'Connection successful', - }; - - } - else { - - results[0] = { - ...results[0]!, - status: 'error', - detail: connResult.error ?? 'Connection failed', - }; - allValid = false; - - } - setItems([...results]); - - // Check required fields - const requiredChecks = [ - { key: 'name', label: 'Name', value: config.name }, - { key: 'database', label: 'Database', value: config.connection.database }, - ]; - - for (const check of requiredChecks) { - - const isSet = Boolean(check.value); - results.push({ - key: check.key, - label: check.label, - status: isSet ? 'success' : 'error', - detail: isSet ? check.value : 'Not set', - }); - - if (!isSet) allValid = false; - - } - setItems([...results]); - - // Check host for non-SQLite - if (config.connection.dialect !== 'sqlite') { - - const hasHost = Boolean(config.connection.host); - results.push({ - key: 'host', - label: 'Host', - status: hasHost ? 'success' : 'error', - detail: hasHost ? config.connection.host! : 'Not set', - }); - - if (!hasHost) allValid = false; - setItems([...results]); - - } - - setIsValid(allValid); + setItems(results); + setIsValid(valid); setStep('complete'); }; diff --git a/src/tui/screens/db/DbTeardownScreen.tsx b/src/tui/screens/db/DbTeardownScreen.tsx index 0af4fd16..393892cc 100644 --- a/src/tui/screens/db/DbTeardownScreen.tsx +++ b/src/tui/screens/db/DbTeardownScreen.tsx @@ -19,10 +19,9 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext, useSettings } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; -import { createConnection } from '../../../core/connection/index.js'; import { previewTeardown, teardownSchema } from '../../../core/teardown/index.js'; import { formatIdentity } from '../../../core/identity/index.js'; -import { getErrorMessage, resolveScreenIdentity } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, withScreenConnection } from '../../utils/index.js'; import { useConnection, useAsyncEffect } from '../../hooks/index.js'; import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; import { attempt } from '@logosdx/utils'; @@ -133,49 +132,36 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement setPhase('running'); - const [conn, connErr] = await attempt(() => - createConnection(activeConfig.connection, activeConfigName), - ); + // Resolve identity for change tracking + const identity = resolveScreenIdentity(cryptoIdentity); - if (connErr || !conn) { + const [, err] = await withScreenConnection( + activeConfig.connection, activeConfigName, + async (db) => { - setError(`Connection failed: ${connErr?.message ?? 'Unknown error'}`); - setPhase('error'); + const teardownResult = await teardownSchema(db as Kysely, activeConfig.connection.dialect, { + preserveTables, + postScript, + configName: activeConfigName, + executedBy: formatIdentity(identity), + }); - return; - - } + setResult(teardownResult); - try { - - const db = conn.db as Kysely; - - // Resolve identity for change tracking - const identity = resolveScreenIdentity(cryptoIdentity); - - const teardownResult = await teardownSchema(db, activeConfig.connection.dialect, { - preserveTables, - postScript, - configName: activeConfigName, - executedBy: formatIdentity(identity), - }); - - setResult(teardownResult); - setPhase('done'); + }, + ); - } - catch (err) { + if (err) { setError(getErrorMessage(err)); setPhase('error'); - } - finally { - - await conn.destroy(); + return; } + setPhase('done'); + }, [activeConfig, activeConfigName, preserveTables, postScript, cryptoIdentity]); // Get categories that have items diff --git a/src/tui/screens/db/DbTransferScreen.tsx b/src/tui/screens/db/DbTransferScreen.tsx index b07324e7..5108ab35 100644 --- a/src/tui/screens/db/DbTransferScreen.tsx +++ b/src/tui/screens/db/DbTransferScreen.tsx @@ -30,6 +30,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; import { ProgressBar, TextInput } from '@inkjs/ui'; +import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; @@ -626,7 +627,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement passphrase: passphrase || undefined, }); - try { + const [, err] = await attempt(async () => { await reader.open(); const schema = reader.schema; @@ -638,8 +639,9 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement } - } - catch (err) { + }); + + if (err) { if (!isCancelled()) { diff --git a/src/tui/screens/db/DbTruncateScreen.tsx b/src/tui/screens/db/DbTruncateScreen.tsx index f36e762a..181469c7 100644 --- a/src/tui/screens/db/DbTruncateScreen.tsx +++ b/src/tui/screens/db/DbTruncateScreen.tsx @@ -18,7 +18,6 @@ import type { Kysely } from 'kysely'; import type { TruncateResult } from '../../../core/teardown/index.js'; import type { TableSummary } from '../../../core/explore/types.js'; -import { createConnection } from '../../../core/connection/index.js'; import { truncateData } from '../../../core/teardown/index.js'; import { fetchList } from '../../../core/explore/operations.js'; @@ -28,7 +27,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext, useSettings } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; import { useConnection, useAsyncEffect } from '../../hooks/index.js'; -import { getErrorMessage } from '../../utils/index.js'; +import { getErrorMessage, withScreenConnection } from '../../utils/index.js'; import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; type Phase = 'loading' | 'blocked' | 'preview' | 'confirm' | 'running' | 'done' | 'error'; @@ -118,44 +117,31 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement setPhase('running'); - const [conn, connErr] = await attempt(() => - createConnection(activeConfig.connection, activeConfigName), - ); - - if (connErr || !conn) { - - setError(`Connection failed: ${connErr?.message ?? 'Unknown error'}`); - setPhase('error'); - - return; - - } + const [, err] = await withScreenConnection( + activeConfig.connection, activeConfigName, + async (db) => { - try { + const truncateResult = await truncateData(db as Kysely, activeConfig.connection.dialect, { + preserve: preserveTables, + restartIdentity: true, + }); - const db = conn.db; + setResult(truncateResult); - const truncateResult = await truncateData(db, activeConfig.connection.dialect, { - preserve: preserveTables, - restartIdentity: true, - }); - - setResult(truncateResult); - setPhase('done'); + }, + ); - } - catch (err) { + if (err) { setError(getErrorMessage(err)); setPhase('error'); - } - finally { - - await conn.destroy(); + return; } + setPhase('done'); + }, [activeConfig, activeConfigName, preserveTables]); // Keyboard handling diff --git a/src/tui/screens/db/SqlClearScreen.tsx b/src/tui/screens/db/SqlClearScreen.tsx index 041c30d6..92c97ffc 100644 --- a/src/tui/screens/db/SqlClearScreen.tsx +++ b/src/tui/screens/db/SqlClearScreen.tsx @@ -11,6 +11,7 @@ */ import { useState, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; +import { formatByteSize } from '@logosdx/utils'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; @@ -31,18 +32,6 @@ type ClearOption = '3months' | 'all'; */ type Phase = 'select' | 'confirm' | 'clearing' | 'done'; -/** - * Format bytes for display. - */ -function formatBytes(bytes: number): string { - - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - -} - /** * SQL Clear Screen component. */ @@ -292,7 +281,7 @@ export function SqlClearScreen({ params: _params }: ScreenProps): ReactElement { Results size: - {formatBytes(stats.resultsSize)} + {formatByteSize(stats.resultsSize, { decimals: 1 })} ) : ( diff --git a/src/tui/screens/db/SqlHistoryScreen.tsx b/src/tui/screens/db/SqlHistoryScreen.tsx index e6f470cb..e49ca00b 100644 --- a/src/tui/screens/db/SqlHistoryScreen.tsx +++ b/src/tui/screens/db/SqlHistoryScreen.tsx @@ -30,6 +30,7 @@ import { SqlHistoryManager } from '../../../core/sql-terminal/index.js'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime.js'; +import v from 'voca'; dayjs.extend(relativeTime); @@ -45,19 +46,6 @@ function formatDuration(ms: number): string { } -/** - * Truncate SQL for display. - */ -function truncateSql(sql: string, maxLen: number): string { - - const oneLine = sql.replace(/\s+/g, ' ').trim(); - - if (oneLine.length <= maxLen) return oneLine; - - return oneLine.slice(0, maxLen - 3) + '...'; - -} - /** * SQL History Screen component. */ @@ -373,7 +361,7 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement inverse={isHighlighted} color={entry.success ? undefined : 'red'} > - {truncateSql(entry.query, 60)} + {v.truncate(entry.query.replace(/\s+/g, ' ').trim(), 60)} diff --git a/src/tui/screens/debug/DebugListScreen.tsx b/src/tui/screens/debug/DebugListScreen.tsx index a0465f58..8997abae 100644 --- a/src/tui/screens/debug/DebugListScreen.tsx +++ b/src/tui/screens/debug/DebugListScreen.tsx @@ -17,6 +17,7 @@ import { useState, useMemo, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; import { attempt } from '@logosdx/utils'; +import v from 'voca'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; @@ -179,7 +180,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { if (row['executed_at']) descParts.push(formatDate(row['executed_at'])); if (row['duration_ms']) descParts.push(`${row['duration_ms']}ms`); - if (row['filepath']) descParts.push(truncate(String(row['filepath']), 40)); + if (row['filepath']) descParts.push(v.truncate(String(row['filepath']), 40)); if (row['locked_by']) descParts.push(`by ${row['locked_by']}`); return { @@ -518,14 +519,3 @@ function formatDate(value: unknown): string { return String(value); } - -/** - * Truncate a string to a maximum length. - */ -function truncate(str: string, maxLen: number): string { - - if (str.length <= maxLen) return str; - - return str.slice(0, maxLen - 3) + '...'; - -} diff --git a/src/tui/screens/run/RunBuildScreen.tsx b/src/tui/screens/run/RunBuildScreen.tsx index 836c768c..254caab3 100644 --- a/src/tui/screens/run/RunBuildScreen.tsx +++ b/src/tui/screens/run/RunBuildScreen.tsx @@ -30,14 +30,10 @@ import { useRunProgress, useAsyncEffect } from '../../hooks/index.js'; import { getEffectiveBuildPaths } from '../../../core/settings/rules.js'; import { discoverFiles, runBuild } from '../../../core/runner/index.js'; import { filterFilesByPaths } from '../../../core/shared/index.js'; -import { createConnection, testConnection } from '../../../core/connection/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; -import { getErrorMessage, resolveScreenIdentity, buildRunContext } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; -import type { NoormDatabase } from '../../../core/shared/index.js'; -import type { Kysely } from 'kysely'; - type Phase = 'loading' | 'confirm' | 'running' | 'complete' | 'error'; /** @@ -132,66 +128,39 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { // Resolve identity const identity = resolveScreenIdentity(cryptoIdentity); - // Test connection - const testResult = await testConnection(activeConfig.connection); - - if (!testResult.ok) { + const [, err] = await withScreenConnection( + activeConfig.connection, activeConfigName, + async (db) => { - setError(`Connection failed: ${testResult.error}`); - setPhase('error'); + const context = await buildRunContext({ + db, configName: activeConfigName, identity, + projectRoot, activeConfig, + stateManager, dialect: activeConfig.connection.dialect, + }); - return; + // Run options from global modes + const options = { + force: globalModes.force, + dryRun: globalModes.dryRun, + abortOnError: true, + }; - } + // Run build with filtered files + await runBuild(context, sqlPath, options, files); - // Create connection - const [conn, connErr] = await attempt(() => - createConnection(activeConfig.connection, activeConfigName), + }, ); - if (connErr || !conn) { + if (err) { - setError(`Connection failed: ${connErr?.message ?? 'Unknown error'}`); + setError(getErrorMessage(err)); setPhase('error'); return; } - try { - - const db = conn.db as Kysely; - - const context = buildRunContext({ - db, configName: activeConfigName, identity, - projectRoot, activeConfig, - stateManager, dialect: conn.dialect, - }); - - // Run options from global modes - const options = { - force: globalModes.force, - dryRun: globalModes.dryRun, - abortOnError: true, - }; - - // Run build with filtered files - await runBuild(context, sqlPath, options, files); - - setPhase('complete'); - - } - catch (err) { - - setError(getErrorMessage(err)); - setPhase('error'); - - } - finally { - - await conn.destroy(); - - } + setPhase('complete'); }, [ activeConfig, diff --git a/src/tui/screens/run/RunDirScreen.tsx b/src/tui/screens/run/RunDirScreen.tsx index 0c49f496..fd8b6377 100644 --- a/src/tui/screens/run/RunDirScreen.tsx +++ b/src/tui/screens/run/RunDirScreen.tsx @@ -12,7 +12,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, Text } from 'ink'; import { ProgressBar } from '@inkjs/ui'; -import { join, relative, dirname } from 'path'; +import { join, normalize, relative, dirname } from 'path'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; @@ -23,13 +23,10 @@ import { Panel, Spinner, Confirm, SelectList, FilePicker, KeyHandler, useToast } import { useRunProgress, useAsyncEffect } from '../../hooks/index.js'; import { discoverFiles, runFiles, checkFilesStatus } from '../../../core/runner/index.js'; import type { FilesStatusResult } from '../../../core/runner/index.js'; -import { createConnection, testConnection } from '../../../core/connection/index.js'; -import { getErrorMessage, resolveScreenIdentity, buildRunContext } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; import { useConnection } from '../../hooks/index.js'; import { attempt } from '@logosdx/utils'; -import type { NoormDatabase } from '../../../core/shared/index.js'; -import type { Kysely } from 'kysely'; import type { SelectListItem } from '../../components/index.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'checking' | 'rerun-confirm' | 'running' | 'complete' | 'error'; @@ -42,8 +39,11 @@ function extractDirectories(files: string[], projectRoot: string, sqlPath: strin const dirs = new Set(); - // Always include the schema path itself as an option - dirs.add(sqlPath); + // Always include the schema path itself as an option. + // Normalize so a './sql'-style settings value collapses into the same + // entry as the relative()-derived dirs below — a verbatim './sql' seed + // never matches the count/selection filters and renders as "0 files". + dirs.add(normalize(sqlPath)); for (const file of files) { @@ -212,7 +212,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { const identity = resolveScreenIdentity(cryptoIdentity); - const context = buildRunContext({ + const context = await buildRunContext({ db: sharedDb, configName: activeConfigName, identity, projectRoot, activeConfig, stateManager, dialect: sharedDialect ?? undefined, @@ -260,110 +260,63 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { // Resolve identity const identity = resolveScreenIdentity(cryptoIdentity); - // Test connection - const testResult = await testConnection(activeConfig.connection); + const [, err] = await withScreenConnection( + activeConfig.connection, activeConfigName, + async (db) => { - if (!testResult.ok) { + // Check if cancelled during connection creation + if (cancelledRef.current) { - setError(`Connection failed: ${testResult.error}`); - setPhase('error'); - - return; + throw new Error('Execution cancelled'); - } - - // Check if cancelled during connection test - if (cancelledRef.current) { + } - setError('Execution cancelled'); - setPhase('error'); + const context = await buildRunContext({ + db, configName: activeConfigName, identity, + projectRoot, activeConfig, + stateManager, dialect: activeConfig.connection.dialect, + }); - return; + const options = { + force: force || forceRerun || globalModes.force, + dryRun: globalModes.dryRun, + abortOnError: true, + }; - } + await runFiles(context, selectedDirFiles, options); - // Create connection - const [conn, connErr] = await attempt(() => - createConnection(activeConfig.connection, activeConfigName), - ); + // Check if cancelled during execution + if (cancelledRef.current) { - if (connErr || !conn) { + throw new Error('Execution cancelled'); - setError(`Connection failed: ${connErr?.message ?? 'Unknown error'}`); - setPhase('error'); + } - return; + }, + // Store connection ref for cancellation + { + onConnect: (conn) => { - } + activeConnectionRef.current = conn; - // Store connection ref for cancellation - activeConnectionRef.current = conn; - - try { - - // Check if cancelled during connection creation - if (cancelledRef.current) { - - setError('Execution cancelled'); - setPhase('error'); - - return; - - } - - const db = conn.db as Kysely; - - const context = buildRunContext({ - db, configName: activeConfigName, identity, - projectRoot, activeConfig, - stateManager, dialect: conn.dialect, - }); - - const options = { - force: force || forceRerun || globalModes.force, - dryRun: globalModes.dryRun, - abortOnError: true, - }; - - await runFiles(context, selectedDirFiles, options); - - // Check if cancelled during execution - if (cancelledRef.current) { - - setError('Execution cancelled'); - setPhase('error'); - - return; - - } + }, + }, + ); - setPhase('complete'); + activeConnectionRef.current = null; - } - catch (err) { + if (err) { // Don't show error if cancelled (connection destroyed intentionally) - if (cancelledRef.current) { - - setError('Execution cancelled'); - - } - else { - - setError(getErrorMessage(err)); - - } - + setError(cancelledRef.current ? 'Execution cancelled' : getErrorMessage(err)); setPhase('error'); - } - finally { - - activeConnectionRef.current = null; - await conn.destroy(); + return; } + setPhase('complete'); + }, [activeConfig, activeConfigName, stateManager, selectedDirFiles, globalModes, forceRerun, resetProgress, projectRoot, cryptoIdentity]); // Cancel running execution diff --git a/src/tui/screens/run/RunExecScreen.tsx b/src/tui/screens/run/RunExecScreen.tsx index de8d9d39..9622f035 100644 --- a/src/tui/screens/run/RunExecScreen.tsx +++ b/src/tui/screens/run/RunExecScreen.tsx @@ -28,13 +28,9 @@ import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js import { Panel, Spinner, SelectList, type SelectListItem, Confirm, KeyHandler, useToast } from '../../components/index.js'; import { useRunProgress, useAsyncEffect } from '../../hooks/index.js'; import { discoverFiles, runFiles } from '../../../core/runner/index.js'; -import { createConnection, testConnection } from '../../../core/connection/index.js'; -import { getErrorMessage, resolveScreenIdentity, buildRunContext } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; -import type { NoormDatabase } from '../../../core/shared/index.js'; -import type { Kysely } from 'kysely'; - type Phase = 'loading' | 'picker' | 'confirm' | 'running' | 'complete' | 'error'; /** @@ -121,64 +117,37 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { // Resolve identity const identity = resolveScreenIdentity(cryptoIdentity); - // Test connection - const testResult = await testConnection(activeConfig.connection); - - if (!testResult.ok) { + const [, err] = await withScreenConnection( + activeConfig.connection, activeConfigName, + async (db) => { - setError(`Connection failed: ${testResult.error}`); - setPhase('error'); + const context = await buildRunContext({ + db, configName: activeConfigName, identity, + projectRoot, activeConfig, + stateManager, dialect: activeConfig.connection.dialect, + }); - return; + const options = { + force: globalModes.force, + dryRun: globalModes.dryRun, + }; - } + // Run all files as a single batch operation + await runFiles(context, filesToRun, options); - // Create connection - const [conn, connErr] = await attempt(() => - createConnection(activeConfig.connection, activeConfigName), + }, ); - if (connErr || !conn) { + if (err) { - setError(`Connection failed: ${connErr?.message ?? 'Unknown error'}`); + setError(getErrorMessage(err)); setPhase('error'); return; } - try { - - const db = conn.db as Kysely; - - const context = buildRunContext({ - db, configName: activeConfigName, identity, - projectRoot, activeConfig, - stateManager, dialect: conn.dialect, - }); - - const options = { - force: globalModes.force, - dryRun: globalModes.dryRun, - }; - - // Run all files as a single batch operation - await runFiles(context, filesToRun, options); - - setPhase('complete'); - - } - catch (err) { - - setError(getErrorMessage(err)); - setPhase('error'); - - } - finally { - - await conn.destroy(); - - } + setPhase('complete'); }, [activeConfig, activeConfigName, stateManager, selectedFiles, globalModes, resetProgress, projectRoot]); diff --git a/src/tui/screens/run/RunFileScreen.tsx b/src/tui/screens/run/RunFileScreen.tsx index 0676f2ff..a48ef6b3 100644 --- a/src/tui/screens/run/RunFileScreen.tsx +++ b/src/tui/screens/run/RunFileScreen.tsx @@ -22,13 +22,10 @@ import { Panel, Spinner, Confirm, SearchableList, KeyHandler, useToast } from '. import { useRunProgress, useAsyncEffect } from '../../hooks/index.js'; import { discoverFiles, runFile, checkFilesStatus } from '../../../core/runner/index.js'; import type { FilesStatusResult } from '../../../core/runner/index.js'; -import { createConnection, testConnection } from '../../../core/connection/index.js'; -import { getErrorMessage, resolveScreenIdentity, buildRunContext } from '../../utils/index.js'; +import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; import { useConnection } from '../../hooks/index.js'; import { attempt } from '@logosdx/utils'; -import type { NoormDatabase } from '../../../core/shared/index.js'; -import type { Kysely } from 'kysely'; import type { SelectListItem } from '../../components/index.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'checking' | 'rerun-confirm' | 'running' | 'complete' | 'error'; @@ -153,7 +150,7 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { const identity = resolveScreenIdentity(cryptoIdentity); - const context = buildRunContext({ + const context = await buildRunContext({ db: sharedDb, configName: activeConfigName, identity, projectRoot, activeConfig, stateManager, dialect: sharedDialect ?? undefined, @@ -201,109 +198,62 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { // Resolve identity const identity = resolveScreenIdentity(cryptoIdentity); - // Test connection - const testResult = await testConnection(activeConfig.connection); + const [, err] = await withScreenConnection( + activeConfig.connection, activeConfigName, + async (db) => { - if (!testResult.ok) { + // Check if cancelled during connection creation + if (cancelledRef.current) { - setError(`Connection failed: ${testResult.error}`); - setPhase('error'); - - return; - - } - - // Check if cancelled during connection test - if (cancelledRef.current) { - - setError('Execution cancelled'); - setPhase('error'); - - return; - - } - - // Create connection - const [conn, connErr] = await attempt(() => - createConnection(activeConfig.connection, activeConfigName), - ); - - if (connErr || !conn) { - - setError(`Connection failed: ${connErr?.message ?? 'Unknown error'}`); - setPhase('error'); - - return; - - } - - // Store connection ref for cancellation - activeConnectionRef.current = conn; + throw new Error('Execution cancelled'); - try { + } - // Check if cancelled during connection creation - if (cancelledRef.current) { + const context = await buildRunContext({ + db, configName: activeConfigName, identity, + projectRoot, activeConfig, + stateManager, dialect: activeConfig.connection.dialect, + }); - setError('Execution cancelled'); - setPhase('error'); + const options = { + force: force || forceRerun || globalModes.force, + dryRun: globalModes.dryRun, + }; - return; - - } + await runFile(context, selectedFile, options); - const db = conn.db as Kysely; + // Check if cancelled during execution + if (cancelledRef.current) { - const context = buildRunContext({ - db, configName: activeConfigName, identity, - projectRoot, activeConfig, - stateManager, dialect: conn.dialect, - }); + throw new Error('Execution cancelled'); - const options = { - force: force || forceRerun || globalModes.force, - dryRun: globalModes.dryRun, - }; + } - await runFile(context, selectedFile, options); + }, + // Store connection ref for cancellation + { + onConnect: (conn) => { - // Check if cancelled during execution - if (cancelledRef.current) { - - setError('Execution cancelled'); - setPhase('error'); - - return; + activeConnectionRef.current = conn; - } + }, + }, + ); - setPhase('complete'); + activeConnectionRef.current = null; - } - catch (err) { + if (err) { // Don't show error if cancelled (connection destroyed intentionally) - if (cancelledRef.current) { - - setError('Execution cancelled'); - - } - else { - - setError(getErrorMessage(err)); - - } - + setError(cancelledRef.current ? 'Execution cancelled' : getErrorMessage(err)); setPhase('error'); - } - finally { - - activeConnectionRef.current = null; - await conn.destroy(); + return; } + setPhase('complete'); + }, [activeConfig, activeConfigName, stateManager, selectedFile, globalModes, forceRerun, resetProgress, projectRoot, cryptoIdentity]); // Cancel running execution diff --git a/src/tui/screens/run/RunInspectScreen.tsx b/src/tui/screens/run/RunInspectScreen.tsx index a4e69bca..07da9820 100644 --- a/src/tui/screens/run/RunInspectScreen.tsx +++ b/src/tui/screens/run/RunInspectScreen.tsx @@ -15,21 +15,27 @@ import { Box, Text, useInput } from 'ink'; import { join, relative } from 'path'; import type { ReactElement } from 'react'; +import type { Kysely } from 'kysely'; import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useSettings, useAppContext } from '../../app-context.js'; import { Panel, Spinner, SearchableList } from '../../components/index.js'; -import { useAsyncEffect } from '../../hooks/index.js'; +import { useAsyncEffect, useConnection } from '../../hooks/index.js'; import { discoverFiles } from '../../../core/runner/index.js'; import { buildContext } from '../../../core/template/context.js'; import { processFile } from '../../../core/template/engine.js'; import { loadHelpers, type HelperLoadError } from '../../../core/template/helpers.js'; +import { getVaultKey, buildSecretsContext } from '../../../core/vault/index.js'; +import { loadIdentityMetadata, loadPrivateKey } from '../../../core/identity/storage.js'; import { attempt } from '@logosdx/utils'; import type { SelectListItem } from '../../components/index.js'; import type { TemplateContext } from '../../../core/template/types.js'; +import type { Dialect } from '../../../core/connection/types.js'; +import type { NoormDatabase } from '../../../core/shared/index.js'; +import type { StateManager } from '../../../core/state/index.js'; type Phase = 'loading' | 'picker' | 'inspecting' | 'expanded' | 'preview' | 'error'; @@ -237,6 +243,39 @@ function categorizeContext(ctx: TemplateContext, helperKeys: Set, helper } +/** + * Resolve `$.secrets` through all three tiers for the shared connection. + * + * Inspect must show what apply would actually see — a preview reading + * different secrets than `run file`/`run build` use is the exact silent + * divergence this fixes. Falls back to the local-only tiers when + * disconnected, no identity is on disk, or the vault key can't be + * decrypted; only `noorm vault *` screens treat that as an error. + */ +async function resolveRenderSecrets( + stateManager: StateManager, + configName: string, + db: Kysely | null, + dialect: Dialect | null, +): Promise> { + + if (!db || !dialect) return buildSecretsContext(stateManager, configName); + + const [vaultKey] = await attempt(async () => { + + const cryptoIdentity = await loadIdentityMetadata(); + const privateKey = cryptoIdentity ? await loadPrivateKey() : null; + + if (!cryptoIdentity || !privateKey) return null; + + return getVaultKey(db, cryptoIdentity.identityHash, privateKey, dialect); + + }); + + return buildSecretsContext(stateManager, configName, db, vaultKey, dialect); + +} + /** * Component that handles keyboard input for a specific focus scope. */ @@ -284,6 +323,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { const { back } = useRouter(); const { activeConfig, activeConfigName, stateManager } = useAppContext(); const { settings } = useSettings(); + const { db, dialect } = useConnection(); const [phase, setPhase] = useState('loading'); const [allFiles, setAllFiles] = useState([]); @@ -355,16 +395,18 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { const [results, err] = await attempt(async () => { - const [ctx, helperResult] = await Promise.all([ - buildContext(selectedFile, { - projectRoot, - config: activeConfig as unknown as Record, - secrets: stateManager.getAllSecrets(activeConfigName ?? ''), - globalSecrets: stateManager.getAllGlobalSecrets(), - }), + const [secrets, helperResult] = await Promise.all([ + resolveRenderSecrets(stateManager, activeConfigName ?? '', db, dialect), loadHelpers(templateDir, projectRoot), ]); + const ctx = await buildContext(selectedFile, { + projectRoot, + config: activeConfig as unknown as Record, + secrets, + globalSecrets: stateManager.getAllGlobalSecrets(), + }); + return { ctx, helperKeys: new Set(Object.keys(helperResult.helpers)), @@ -389,7 +431,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { } - }, [selectedFile, projectRoot, activeConfig, activeConfigName, stateManager]); + }, [selectedFile, projectRoot, activeConfig, activeConfigName, stateManager, db, dialect]); // Effect to load context when file changes useEffect(() => { @@ -421,10 +463,12 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { setPhase('loading'); setError(null); + const secrets = await resolveRenderSecrets(stateManager, activeConfigName ?? '', db, dialect); + const [result, err] = await attempt(() => processFile(selectedFile, { projectRoot, config: activeConfig as unknown as Record, - secrets: stateManager.getAllSecrets(activeConfigName ?? ''), + secrets, globalSecrets: stateManager.getAllGlobalSecrets(), })); @@ -445,7 +489,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { } - }, [selectedFile, projectRoot, activeConfig, activeConfigName, stateManager]); + }, [selectedFile, projectRoot, activeConfig, activeConfigName, stateManager, db, dialect]); // Handle refresh const handleRefresh = useCallback(() => { diff --git a/src/tui/screens/settings/SettingsStageEditScreen.tsx b/src/tui/screens/settings/SettingsStageEditScreen.tsx index 624aa610..6c6e7bf9 100644 --- a/src/tui/screens/settings/SettingsStageEditScreen.tsx +++ b/src/tui/screens/settings/SettingsStageEditScreen.tsx @@ -21,6 +21,7 @@ import { useRouter } from '../../router.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Form } from '../../components/index.js'; import { useSettingsOperation } from '../../hooks/index.js'; +import { validateStagePort } from '../../utils/index.js'; /** * SettingsStageEditScreen component. @@ -139,21 +140,7 @@ export function SettingsStageEditScreen({ params }: ScreenProps): ReactElement { type: 'text', defaultValue: defaults.port ? String(defaults.port) : '', placeholder: '5432', - validate: (value) => { - - if (typeof value !== 'string' || !value) return undefined; - - const port = parseInt(value, 10); - - if (isNaN(port) || port < 1 || port > 65535) { - - return 'Port must be 1-65535'; - - } - - return undefined; - - }, + validate: (value) => validateStagePort(typeof value === 'string' ? value : undefined), }, { key: 'database', diff --git a/src/tui/screens/vault/VaultRemoveScreen.tsx b/src/tui/screens/vault/VaultRemoveScreen.tsx index 2f536030..c4369b1c 100644 --- a/src/tui/screens/vault/VaultRemoveScreen.tsx +++ b/src/tui/screens/vault/VaultRemoveScreen.tsx @@ -60,7 +60,17 @@ export function VaultRemoveScreen({ params }: ScreenProps): ReactElement { const db = connRef.current.db; const connDialect = connRef.current.dialect; - const vaultKey = await getVaultKey(db as Kysely, identity.identityHash, privateKey, connDialect); + + const [vaultKey, vaultKeyErr] = await attempt(() => getVaultKey(db as Kysely, identity.identityHash, privateKey, connDialect)); + + if (vaultKeyErr) { + + setError(vaultKeyErr.message); + setPhase('ready'); + + return; + + } if (!vaultKey) { @@ -72,7 +82,16 @@ export function VaultRemoveScreen({ params }: ScreenProps): ReactElement { } // Check if exists - const exists = await vaultSecretExists(db as Kysely, secretKey, connDialect); + const [exists, existsErr] = await attempt(() => vaultSecretExists(db as Kysely, secretKey, connDialect)); + + if (existsErr) { + + setError(existsErr.message); + setPhase('ready'); + + return; + + } if (!exists) { diff --git a/src/tui/screens/vault/VaultSetScreen.tsx b/src/tui/screens/vault/VaultSetScreen.tsx index bac624cc..1475bb04 100644 --- a/src/tui/screens/vault/VaultSetScreen.tsx +++ b/src/tui/screens/vault/VaultSetScreen.tsx @@ -89,7 +89,17 @@ export function VaultSetScreen({ params }: ScreenProps): ReactElement { const db = connRef.current.db; const connDialect = connRef.current.dialect; - const vaultKey = await getVaultKey(db as Kysely, identity.identityHash, privateKey, connDialect); + + const [vaultKey, vaultKeyErr] = await attempt(() => getVaultKey(db as Kysely, identity.identityHash, privateKey, connDialect)); + + if (vaultKeyErr) { + + setError(vaultKeyErr.message); + setPhase('ready'); + + return; + + } if (!vaultKey) { diff --git a/src/tui/utils/config-validation.ts b/src/tui/utils/config-validation.ts index e86cd75f..0fde82b7 100644 --- a/src/tui/utils/config-validation.ts +++ b/src/tui/utils/config-validation.ts @@ -15,22 +15,10 @@ import type { ConnectionConfig, Dialect } from '../../core/connection/types.js'; import type { Config } from '../../core/config/types.js'; import type { ConfigAccess, Role } from '../../core/policy/index.js'; import { guarded } from '../../core/policy/index.js'; +import { DEFAULT_PORTS } from '../../core/connection/index.js'; +import { ConfigNameSchema, PortSchema } from '../../core/config/schema.js'; - -/** - * Default ports by dialect. - */ -export const DEFAULT_PORTS: Record = { - postgres: 5432, - mysql: 3306, - sqlite: 0, - mssql: 1433, -}; - -/** - * Config name pattern — letters, numbers, hyphens, underscores. - */ -const CONFIG_NAME_PATTERN = /^[a-z0-9_-]+$/i; +export { DEFAULT_PORTS }; /** * Validates a config name for format and optional uniqueness. @@ -51,11 +39,11 @@ export function validateConfigName( existingNames?: string[], ): string | undefined { - if (!value) return 'Name is required'; + const result = ConfigNameSchema.safeParse(value); - if (!CONFIG_NAME_PATTERN.test(value)) { + if (!result.success) { - return 'Only letters, numbers, hyphens, underscores'; + return result.error.issues[0]?.message ?? 'Invalid config name'; } @@ -85,12 +73,20 @@ export function validatePort(value: string | undefined): string | undefined { const port = parseInt(value, 10); - if (isNaN(port) || port < 1 || port > 65535) { + if (isNaN(port)) { return 'Port must be 1-65535'; } + const result = PortSchema.safeParse(port); + + if (!result.success) { + + return result.error.issues[0]?.message ?? 'Invalid port'; + + } + return undefined; } diff --git a/src/tui/utils/connection.ts b/src/tui/utils/connection.ts index fdbea3a3..4267c4a1 100644 --- a/src/tui/utils/connection.ts +++ b/src/tui/utils/connection.ts @@ -15,7 +15,7 @@ import { attempt } from '@logosdx/utils'; import type { Kysely } from 'kysely'; -import type { ConnectionConfig } from '../../core/connection/types.js'; +import type { ConnectionConfig, ConnectionResult } from '../../core/connection/types.js'; import type { NoormDatabase } from '../../core/shared/index.js'; import { createConnection, testConnection } from '../../core/connection/index.js'; @@ -38,6 +38,7 @@ export async function withScreenConnection( connectionConfig: ConnectionConfig, configName: string, fn: (db: Kysely) => Promise, + options?: { onConnect?: (conn: ConnectionResult) => void }, ): Promise<[T | null, Error | null]> { const testResult = await testConnection(connectionConfig); @@ -58,6 +59,8 @@ export async function withScreenConnection( } + options?.onConnect?.(conn); + const [result, err] = await attempt(async () => { const db = conn.db as Kysely; diff --git a/src/tui/utils/index.ts b/src/tui/utils/index.ts index 0c4e3f3d..58086792 100644 --- a/src/tui/utils/index.ts +++ b/src/tui/utils/index.ts @@ -27,3 +27,4 @@ export { type ConnectionDefaults, } from './config-validation.js'; export { getErrorMessage } from './error.js'; +export { validateStagePort } from './settings-validation.js'; diff --git a/src/tui/utils/run-context.ts b/src/tui/utils/run-context.ts index 3c5f5f5d..8dac011f 100644 --- a/src/tui/utils/run-context.ts +++ b/src/tui/utils/run-context.ts @@ -6,18 +6,22 @@ * * @example * ```typescript - * const context = buildRunContext({ db, configName, identity, projectRoot, activeConfig, stateManager }); + * const context = await buildRunContext({ db, configName, identity, projectRoot, activeConfig, stateManager }); * await runFile(context, selectedFile, options); * ``` */ +import { attempt } from '@logosdx/utils'; + import type { Kysely } from 'kysely'; import type { RunContext } from '../../core/runner/types.js'; import type { Dialect } from '../../core/connection/types.js'; import type { NoormDatabase } from '../../core/shared/index.js'; import type { Identity } from '../../core/identity/types.js'; +import { loadIdentityMetadata, loadPrivateKey } from '../../core/identity/storage.js'; import type { Config } from '../../core/config/types.js'; import type { StateManager } from '../../core/state/index.js'; +import { getVaultKey, buildSecretsContext } from '../../core/vault/index.js'; /** * Options for building a RunContext. @@ -32,6 +36,31 @@ export interface BuildRunContextOptions { dialect?: Dialect; } +/** + * Resolve the vault key for the current identity, if any. + * + * Degrades to `null` on any failure — no identity on disk, no vault + * access yet, or a decrypt failure all mean the vault tier is + * unavailable, not that the render should fail. A project with no + * vault at all must render exactly as it does today. + */ +async function resolveVaultKey(db: Kysely, dialect: Dialect): Promise { + + const [vaultKey] = await attempt(async () => { + + const cryptoIdentity = await loadIdentityMetadata(); + const privateKey = cryptoIdentity ? await loadPrivateKey() : null; + + if (!cryptoIdentity || !privateKey) return null; + + return getVaultKey(db, cryptoIdentity.identityHash, privateKey, dialect); + + }); + + return vaultKey ?? null; + +} + /** * Build a RunContext from screen dependencies. * @@ -40,16 +69,18 @@ export interface BuildRunContextOptions { * * @example * ```typescript - * const context = buildRunContext({ + * const context = await buildRunContext({ * db, configName: activeConfigName, identity, * projectRoot, activeConfig, stateManager, * }); * ``` */ -export function buildRunContext(options: BuildRunContextOptions): RunContext { +export async function buildRunContext(options: BuildRunContextOptions): Promise { const { db, configName, identity, projectRoot, activeConfig, stateManager, dialect } = options; + const vaultKey = dialect ? await resolveVaultKey(db, dialect) : null; + return { db, configName, @@ -59,7 +90,7 @@ export function buildRunContext(options: BuildRunContextOptions): RunContext { access: activeConfig.access, channel: 'user', config: activeConfig as unknown as Record, - secrets: stateManager.getAllSecrets(configName), + secrets: await buildSecretsContext(stateManager, configName, db, vaultKey, dialect), globalSecrets: stateManager.getAllGlobalSecrets(), }; diff --git a/src/tui/utils/settings-validation.ts b/src/tui/utils/settings-validation.ts new file mode 100644 index 00000000..b3e7627a --- /dev/null +++ b/src/tui/utils/settings-validation.ts @@ -0,0 +1,49 @@ +/** + * Settings form validation utilities. + * + * Shared validators for stage-defaults fields used by + * SettingsStageEditScreen. + * + * @example + * ```typescript + * const error = validateStagePort('99999'); + * ``` + */ +import { PortSchema } from '../../core/settings/schema.js'; + +/** + * Validates a stage-default port number string. + * + * Returns undefined for empty/missing values (the field is optional). + * Delegates the bound check to the settings `PortSchema` — the same + * schema that validates `StageDefaults.port` at save time — so the + * live-form validator can never drift from what's actually enforced. + * + * @example + * ```typescript + * validate: (value) => validateStagePort(typeof value === 'string' ? value : undefined) + * ``` + */ +export function validateStagePort(value: string | undefined): string | undefined { + + if (!value) return undefined; + + const port = parseInt(value, 10); + + if (isNaN(port)) { + + return 'Port must be 1-65535'; + + } + + const result = PortSchema.safeParse(port); + + if (!result.success) { + + return result.error.issues[0]?.message ?? 'Invalid port'; + + } + + return undefined; + +} diff --git a/tests/cli/change/history.test.ts b/tests/cli/change/history.test.ts new file mode 100644 index 00000000..f93b8c93 --- /dev/null +++ b/tests/cli/change/history.test.ts @@ -0,0 +1,137 @@ +/** + * cli: noorm change history — result on stdout, diagnostics on stderr (CP4, #52). + * + * Mirrors tests/cli/change/list.test.ts's harness. Before the CP4 fix, + * `change history --json` piped its NDJSON event stream ahead of the + * result on stdout — the reason the field report had to `tail -1` instead + * of piping straight into `jq`. These tests assert stdout parses as a + * single JSON document with no `tail -1`, and that the human-readable + * "0 records" line (an explicit non-silent statement) reaches stdout. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'changehistory'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm change history — output streams', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-change-history-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-change-history-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync( + join(tmpDir, '.noorm', 'settings.yml'), + 'paths:\n sql: ./sql\n changes: ./changes\n', + ); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active admin-role config, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runHistory(args: string[] = []) { + + return spawnSync('node', [CLI, 'change', 'history', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + it('prints an explicit "0 records" line to stdout, not silence, on an empty database', async () => { + + await seedConfig(); + + const result = runHistory(); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).not.toBe(''); + expect(result.stdout).toContain('0 records'); + + }); + + it('--json prints exactly one parseable JSON document to stdout, with no NDJSON noise ahead of it', async () => { + + await seedConfig(); + + const result = runHistory(['--json']); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual([]); + + }); + +}); diff --git a/tests/cli/change/list.test.ts b/tests/cli/change/list.test.ts new file mode 100644 index 00000000..b9745f2f --- /dev/null +++ b/tests/cli/change/list.test.ts @@ -0,0 +1,155 @@ +/** + * cli: noorm change list — result on stdout, diagnostics on stderr (CP4, #52). + * + * Mirrors tests/cli/db/create.test.ts's harness: driven as a subprocess + * against the compiled CLI (list.ts calls `process.exit`, which would kill + * an in-process test runner). Identity comes from `NOORM_IDENTITY_*` env + * vars, and the config fixture is written directly via `StateManager` since + * `config add`/`edit` are TUI-only. The target is a real (empty) SQLite + * file — `withContext` bootstraps tracking tables on connect, so no + * separate `db create` step is needed for a read-only list. + * + * Before the CP4 fix, `change list` produced no output at all on stdout in + * either mode: the table (or, on an empty database, nothing) went to + * stderr via `logger.info`, and `--json` piped its NDJSON event stream + * ahead of the payload onto stdout. These tests capture stdout and stderr + * separately (never merged with `2>&1`) so each stream's content is + * unambiguous. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'changelist'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm change list — output streams', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-change-list-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-change-list-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync( + join(tmpDir, '.noorm', 'settings.yml'), + 'paths:\n sql: ./sql\n changes: ./changes\n', + ); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active admin-role config, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runList(args: string[] = [], envOverrides: Record = {}) { + + return spawnSync('node', [CLI, 'change', 'list', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + } + + it('prints an explicit empty-state line to stdout, not silence, when there are no changes', async () => { + + await seedConfig(); + + const result = runList(); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).not.toBe(''); + expect(result.stdout).toMatch(/no changes/i); + + }); + + it('--json prints exactly one parseable JSON document to stdout, with no NDJSON noise ahead of it', async () => { + + await seedConfig(); + + const result = runList(['--json']); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual([]); + + }); + + it('genuine diagnostics (a failed context) still land on stderr, not stdout', async () => { + + const result = runList(['-c', 'does-not-exist']); + + expect(result.status).toBe(1); + expect(result.stderr).not.toBe(''); + expect(result.stdout).toBe(''); + + }); + +}); diff --git a/tests/cli/change/rm.test.ts b/tests/cli/change/rm.test.ts new file mode 100644 index 00000000..43944c3c --- /dev/null +++ b/tests/cli/change/rm.test.ts @@ -0,0 +1,167 @@ +/** + * cli: noorm change rm — role gate + isYesMode confirm (v1-44 CP-3). + * + * Mirrors tests/cli/db/reset.test.ts harness: driven as a subprocess + * against the compiled CLI (rm.ts calls process.exit, which would kill + * an in-process test runner). Identity comes from NOORM_IDENTITY_* env + * vars, and the config fixture is written directly via StateManager + * since config add/edit are TUI-only and cannot set an exact access + * role from the CLI. + * + * change rm is disk-only (no live DB connection needed), so the seeded + * config connection points at a throwaway sqlite path that is never + * opened by this command. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'rmgate'; +const CHANGE_NAME = '2024-04-17-sample'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm change rm — role gate + isYesMode confirm', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + let changePath: string; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-change-rm-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-change-rm-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync( + join(tmpDir, '.noorm', 'settings.yml'), + 'paths:\n sql: ./sql\n changes: ./changes\n', + ); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + changePath = join(tmpDir, 'changes', CHANGE_NAME); + mkdirSync(changePath, { recursive: true }); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active config at the given access role, bypassing the TUI-only config add/edit commands. */ + async function seedConfig(access: ConfigAccess): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runRm(args: string[] = [], envOverrides: Record = {}) { + + return spawnSync('node', [CLI, 'change', 'rm', CHANGE_NAME, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + } + + it('viewer-role active config denies deletion, disk untouched, even with --yes passed', async () => { + + await seedConfig({ user: 'viewer', mcp: 'admin' }); + + const result = runRm(['--yes']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('not allowed'); + expect(existsSync(changePath)).toBe(true); + + }); + + it('operator-role plus --yes succeeds, change directory deleted', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runRm(['--yes']); + + expect(result.status).toBe(0); + expect(existsSync(changePath)).toBe(false); + + }); + + it('operator-role without --yes and without NOORM_YES is blocked, disk untouched', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runRm(); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('Pass --yes'); + expect(existsSync(changePath)).toBe(true); + + }); + + it('admin-role plus NOORM_YES=1, no --yes flag, succeeds — proves the isYesMode fix', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runRm([], { NOORM_YES: '1' }); + + expect(result.status).toBe(0); + expect(existsSync(changePath)).toBe(false); + + }); + +}); diff --git a/tests/cli/ci/identity-enroll.test.ts b/tests/cli/ci/identity-enroll.test.ts index 948945e3..3df3ee87 100644 --- a/tests/cli/ci/identity-enroll.test.ts +++ b/tests/cli/ci/identity-enroll.test.ts @@ -15,6 +15,10 @@ import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { parseArgs } from 'citty'; + +import enrollCommand from '../../../src/cli/ci/identity/enroll.js'; +import { assertArgsDef } from '../citty-args.js'; const CLI = join(process.cwd(), 'dist/cli/index.js'); @@ -73,3 +77,30 @@ describe('cli: noorm ci identity enroll', () => { }); }); + +/** + * checkpoint 3 (v1-24-polish-batch): `'public-key'` was renamed to `publicKey`. + * The happy path needs a live DB (see the file header), so `--public-key`'s + * value never surfaces in an observable subprocess side effect before this + * checkpoint's flag-parsing risk area. Instead this exercises citty's real + * `parseArgs` against the command's actual `args` definition — the exact + * object citty hands to `run({ args })` — proving `--public-key` still lands + * on `publicKey` without needing a live DB connection. + */ +describe('cli: noorm ci identity enroll — camelCase arg parsing (checkpoint 3)', () => { + + it('parses --public-key into the renamed publicKey arg', () => { + + const argsDef = enrollCommand.args; + assertArgsDef(argsDef); + + const parsed = parseArgs( + ['--config', 'prod', '--name', 'CI Bot', '--email', 'ci@test.com', '--public-key', 'deadbeef'], + argsDef, + ); + + expect(parsed.publicKey).toBe('deadbeef'); + + }); + +}); diff --git a/tests/cli/citty-args.ts b/tests/cli/citty-args.ts new file mode 100644 index 00000000..dc657049 --- /dev/null +++ b/tests/cli/citty-args.ts @@ -0,0 +1,27 @@ +/** + * CLI Test Helpers. + * + * Shared assertions for tests that drive citty's real `parseArgs` against a + * command's actual `args` definition instead of a hand-rolled stand-in. + */ +import type { ArgsDef } from 'citty'; + +/** + * Asserts that a citty command's `args` property is a resolved ArgsDef + * object. Commands may declare `args` as an async factory, but `parseArgs` + * needs the resolved object, not the Promise or an unset value. + * + * @example + * const argsDef = someCommand.args; + * assertArgsDef(argsDef); + * const parsed = parseArgs(['--flag', 'value'], argsDef); + */ +export function assertArgsDef(value: unknown): asserts value is ArgsDef { + + if (!value || typeof value !== 'object' || value instanceof Promise) { + + throw new Error('command.args must be a resolved ArgsDef object'); + + } + +} diff --git a/tests/cli/citty-help.test.ts b/tests/cli/citty-help.test.ts index f682a316..7944bdeb 100644 --- a/tests/cli/citty-help.test.ts +++ b/tests/cli/citty-help.test.ts @@ -59,3 +59,44 @@ describe('cli: citty help interceptor', () => { }); }); + +describe('cli: citty help interceptor - breadcrumb depth', () => { + + it('should print the full parent breadcrumb for a 2-level-deep command (change add)', () => { + + const { stdout, code } = runCli(['change', 'add', '--help']); + + expect(code).toBe(0); + expect(stdout).toContain('USAGE noorm change add'); + + }); + + it('should print the full parent breadcrumb for a 3-level-deep command (db explore tables)', () => { + + const { stdout, code } = runCli(['db', 'explore', 'tables', '--help']); + + expect(code).toBe(0); + expect(stdout).toContain('USAGE noorm db explore tables'); + + }); + + it('should print the full parent breadcrumb for a 3-level-deep command (ci identity enroll)', () => { + + const { stdout, code } = runCli(['ci', 'identity', 'enroll', '--help']); + + expect(code).toBe(0); + expect(stdout).toContain('USAGE noorm ci identity enroll'); + + }); + + it('should print a single noorm segment for root --help, never doubled', () => { + + const { stdout, code } = runCli(['--help']); + + expect(code).toBe(0); + expect(stdout).toContain('USAGE noorm '); + expect(stdout).not.toContain('noorm noorm'); + + }); + +}); diff --git a/tests/cli/config-validation.test.ts b/tests/cli/config-validation.test.ts index 0ab92ad5..0a32f947 100644 --- a/tests/cli/config-validation.test.ts +++ b/tests/cli/config-validation.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'bun:test'; -import { buildAccessFromValues } from '../../src/tui/utils/config-validation.js'; +import { + buildAccessFromValues, + validateConfigName, + validatePort, +} from '../../src/tui/utils/config-validation.js'; describe('config-validation: buildAccessFromValues', () => { @@ -31,3 +35,67 @@ describe('config-validation: buildAccessFromValues', () => { }); }); + +describe('config-validation: validateConfigName', () => { + + it('rejects an empty name with a required-style message', () => { + + const error = validateConfigName(''); + + expect(error).not.toBeUndefined(); + expect(error?.toLowerCase()).toContain('required'); + + }); + + it('rejects names with invalid characters', () => { + + expect(validateConfigName('a b')).not.toBeUndefined(); + expect(validateConfigName('a!b')).not.toBeUndefined(); + + }); + + it('accepts names matching the allowed character set', () => { + + expect(validateConfigName('dev')).toBeUndefined(); + expect(validateConfigName('my-config_1')).toBeUndefined(); + + }); + + it('rejects a name already present in existingNames with the duplicate message', () => { + + expect(validateConfigName('dev', ['dev', 'prod'])).toBe('Config name already exists'); + + }); + +}); + +describe('config-validation: validatePort', () => { + + it('accepts an empty/undefined value as unset (optional field)', () => { + + expect(validatePort(undefined)).toBeUndefined(); + expect(validatePort('')).toBeUndefined(); + + }); + + it('rejects non-numeric input', () => { + + expect(validatePort('abc')).not.toBeUndefined(); + + }); + + it('rejects out-of-range ports at both boundaries', () => { + + expect(validatePort('0')).not.toBeUndefined(); + expect(validatePort('65536')).not.toBeUndefined(); + + }); + + it('accepts in-range ports at both boundaries', () => { + + expect(validatePort('1')).toBeUndefined(); + expect(validatePort('65535')).toBeUndefined(); + + }); + +}); diff --git a/tests/cli/config/add.test.ts b/tests/cli/config/add.test.ts new file mode 100644 index 00000000..48b876fe --- /dev/null +++ b/tests/cli/config/add.test.ts @@ -0,0 +1,43 @@ +/** + * cli: noorm config add — honest exit-1 stub. + * + * `config add` is TUI-only and can never run headlessly; it calls + * `process.exit`, so — like every other citty command test in this suite — + * it's driven as a subprocess against the compiled CLI (tests/cli/db/drop.test.ts's + * pattern) rather than invoked in-process. + */ +import { describe, it, expect } from 'bun:test'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); + +function runAdd(): ReturnType { + + return spawnSync('node', [CLI, 'config', 'add'], { + encoding: 'utf-8', + stdio: 'pipe', + }); + +} + +describe('cli: noorm config add — honest exit-1 stub', () => { + + it('exits 1', () => { + + const result = runAdd(); + + expect(result.status).toBe(1); + + }); + + it('writes "Interactive only" to stderr, not stdout', () => { + + const result = runAdd(); + + expect(result.stderr).toContain('Interactive only — run: noorm ui'); + expect(result.stdout).not.toContain('Interactive only'); + + }); + +}); diff --git a/tests/cli/config/edit.test.ts b/tests/cli/config/edit.test.ts new file mode 100644 index 00000000..3e95afdd --- /dev/null +++ b/tests/cli/config/edit.test.ts @@ -0,0 +1,62 @@ +/** + * cli: noorm config edit — honest exit-1 stub. + * + * `config edit` is TUI-only and can never run headlessly; it calls + * `process.exit`, so — like every other citty command test in this suite — + * it's driven as a subprocess against the compiled CLI (tests/cli/db/drop.test.ts's + * pattern) rather than invoked in-process. Covers both invocation shapes + * (with and without the positional `name` arg) to prove arg parsing is + * untouched by the stream/exit-code fix. + */ +import { describe, it, expect } from 'bun:test'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); + +function runEdit(args: string[] = []): ReturnType { + + return spawnSync('node', [CLI, 'config', 'edit', ...args], { + encoding: 'utf-8', + stdio: 'pipe', + }); + +} + +describe('cli: noorm config edit — honest exit-1 stub', () => { + + it('exits 1 without a positional name arg', () => { + + const result = runEdit(); + + expect(result.status).toBe(1); + + }); + + it('writes "Interactive only" to stderr, not stdout, without a positional name arg', () => { + + const result = runEdit(); + + expect(result.stderr).toContain('Interactive only — run: noorm ui'); + expect(result.stdout).not.toContain('Interactive only'); + + }); + + it('exits 1 with a positional name arg', () => { + + const result = runEdit(['myconfig']); + + expect(result.status).toBe(1); + + }); + + it('writes "Interactive only" to stderr, not stdout, with a positional name arg', () => { + + const result = runEdit(['myconfig']); + + expect(result.stderr).toContain('Interactive only — run: noorm ui'); + expect(result.stdout).not.toContain('Interactive only'); + + }); + +}); diff --git a/tests/cli/config/export.test.ts b/tests/cli/config/export.test.ts new file mode 100644 index 00000000..bf328490 --- /dev/null +++ b/tests/cli/config/export.test.ts @@ -0,0 +1,129 @@ +/** + * cli: noorm config export — output file mode hardening. + * + * `config export --output` calls `process.exit`, so — like every other + * citty command test in this suite — it's driven as a subprocess against + * the compiled CLI rather than invoked in-process (tests/cli/db/drop.test.ts's + * pattern). Identity comes from `NOORM_IDENTITY_*` env vars (also + * tests/cli/config/import.test.ts's pattern) so no `~/.noorm/identity.key` + * is ever touched; the config fixture is seeded directly via `StateManager` + * (tests/cli/db/drop.test.ts's pattern) since `config add`/`edit` are + * TUI-only. + * + * Regression under test: the exported file used to inherit the process + * umask (typically 0644), leaving plaintext credentials group/world + * readable. `export --output` must write at 0600. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, statSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'exportme'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm config export — output file mode', () => { + + let tmpDir: string; + let fakeHome: string; + let outputPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(async () => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-config-export-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-config-export-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + outputPath = join(tmpDir, 'exported-config.json'); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: join(tmpDir, 'target.db') }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + function runExport(args: string[] = []) { + + return spawnSync('node', [CLI, 'config', 'export', CONFIG_NAME, '--output', outputPath, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + it('writes the exported config file at mode 0600', () => { + + const result = runExport(); + + expect(result.status).toBe(0); + + const stat = statSync(outputPath); + expect(stat.mode & 0o777).toBe(0o600); + + }); + + it('round-trips the config content into the exported file', () => { + + const result = runExport(); + + expect(result.status).toBe(0); + + const content = readFileSync(outputPath, 'utf8'); + expect(content).toContain(CONFIG_NAME); + + }); + +}); diff --git a/tests/cli/config/rm.test.ts b/tests/cli/config/rm.test.ts new file mode 100644 index 00000000..5f2b8133 --- /dev/null +++ b/tests/cli/config/rm.test.ts @@ -0,0 +1,201 @@ +/** + * cli: noorm config delete command -- headless deletion + access policy gate. + * + * Driven as a subprocess against the compiled CLI (db/drop.test.ts's + * pattern) since the command calls process.exit. Identity comes from + * NOORM_IDENTITY_* env vars, and config fixtures are written directly + * via StateManager since config add/edit are TUI-only stubs. The + * locked-stage scenario also seeds a real settings.yml with a stage + * whose name matches the config name, so SettingsProvider.findStageForConfig + * auto-links it, exercising the real ConfigStageLockedError path -- not + * a mock. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'staging'; +const LOCKED_CONFIG_NAME = 'lockedcfg'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm config delete command -- access policy gate', () => { + + let tmpDir: string; + let fakeHome: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-config-delete-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-config-delete-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one config at the given access role, bypassing the TUI-only config add/edit commands. */ + async function seedConfig(name: string, access: ConfigAccess): Promise { + + const config: Config = { + name, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: join(tmpDir, `${name}.db`) }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(name, config); + + } + + /** Writes a stage in settings.yml matching stageName, locked so any config auto-linked to it (same-name config) cannot be deleted. */ + function writeLockedStage(stageName: string): void { + + writeFileSync( + join(tmpDir, '.noorm', 'settings.yml'), + `paths:\n sql: ./sql\nstages:\n ${stageName}:\n locked: true\n`, + ); + + } + + function runDelete(args: string[] = [], envOverrides: Record = {}) { + + return spawnSync('node', [CLI, 'config', 'rm', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + } + + async function configExists(name: string): Promise { + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + + return manager.getConfig(name) !== null; + + } + + it('deletes an admin config when --yes is passed', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + + const result = runDelete([CONFIG_NAME, '--yes']); + + expect(result.status).toBe(0); + expect(await configExists(CONFIG_NAME)).toBe(false); + + }); + + it('refuses an admin config without --yes or NOORM_YES, naming the confirmation phrase', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + + const result = runDelete([CONFIG_NAME]); + + expect(result.status).toBe(1); + const out = result.stdout + result.stderr; + expect(out).toContain(`yes-${CONFIG_NAME}`); + expect(out).toContain('Pass --yes to confirm'); + expect(await configExists(CONFIG_NAME)).toBe(true); + + }); + + it('deletes when NOORM_YES=1 is set without --yes', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + + const result = runDelete([CONFIG_NAME], { NOORM_YES: '1' }); + + expect(result.status).toBe(0); + expect(await configExists(CONFIG_NAME)).toBe(false); + + }); + + it('exits 1 with a clear message for an unknown config name, mutating nothing', async () => { + + const result = runDelete(['does-not-exist', '--yes']); + + expect(result.status).toBe(1); + const out = result.stdout + result.stderr; + expect(out).toContain('"does-not-exist" not found'); + + }); + + it('refuses to delete a config linked to a locked stage even with --yes, and leaves it intact', async () => { + + await seedConfig(LOCKED_CONFIG_NAME, { user: 'admin', mcp: 'admin' }); + writeLockedStage(LOCKED_CONFIG_NAME); + + const result = runDelete([LOCKED_CONFIG_NAME, '--yes']); + + expect(result.status).toBe(1); + const out = result.stdout + result.stderr; + expect(out).toContain('linked to locked stage'); + expect(out).toContain(LOCKED_CONFIG_NAME); + expect(await configExists(LOCKED_CONFIG_NAME)).toBe(true); + + }); + + it('denies a viewer with the policy blockedReason and leaves the config intact', async () => { + + await seedConfig(CONFIG_NAME, { user: 'viewer', mcp: 'admin' }); + + const result = runDelete([CONFIG_NAME, '--yes']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain( + `"config:rm" is not allowed on config "${CONFIG_NAME}" (role: viewer).`, + ); + expect(await configExists(CONFIG_NAME)).toBe(true); + + }); + +}); diff --git a/tests/cli/db/create.test.ts b/tests/cli/db/create.test.ts new file mode 100644 index 00000000..ca165728 --- /dev/null +++ b/tests/cli/db/create.test.ts @@ -0,0 +1,224 @@ +/** + * cli: noorm db create — fresh-create vs already-exists short-circuit. + * + * `db create` calls `process.exit`, so — like every other citty command test + * in this suite — it's driven as a subprocess against the compiled CLI + * rather than invoked in-process (an in-process call would kill the test + * runner on the first `process.exit`). Identity comes from `NOORM_IDENTITY_*` + * env vars (env-bootstrap.test.ts's pattern) so no `~/.noorm/identity.key` + * is ever touched, and the config fixture is written directly via + * `StateManager` (tests/cli/db/drop.test.ts's pattern) since `config + * add`/`edit` are TUI-only. The target "database" is a real SQLite file, so + * both the fresh-create path and the already-initialized short-circuit + * exercise real file/tracking-table state, not stubs. + * + * Unlike `db drop`, `db create` has no policy gate at all (no + * `checkConfigPolicy`/`assertPolicy` call in `src/cli/db/create.ts` or + * `createDb`) — a real asymmetry flagged as a finding, not fixed here. So + * this file seeds a role that would pass under any gate (`admin`/`admin`) + * and has no role-denial cases to mirror from `drop.test.ts`. + * + * A second finding surfaced while writing these tests — `createDb`'s + * `created` flag was deterministically `false` for SQLite targets even on a + * genuine fresh create. Fixed under ticket 35: SQLite existence is captured + * before the connectivity probe auto-creates the file, and the CLI threads its + * status into `createDb`. The two `created`-flag tests below pin that behavior. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import { checkDbStatus } from '../../../src/core/db/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'createme'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm db create — fresh vs already-exists', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-create-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-db-create-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active config at the given access role, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(access: ConfigAccess): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + return config; + + } + + function runCreate(args: string[] = [], envOverrides: Record = {}) { + + return spawnSync('node', [CLI, 'db', 'create', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + } + + it('creates the database and initializes tracking when the target does not exist yet', async () => { + + const config = await seedConfig({ user: 'admin', mcp: 'admin' }); + + expect(existsSync(dbPath)).toBe(false); + + const result = runCreate(['--json']); + + expect(result.status).toBe(0); + expect(existsSync(dbPath)).toBe(true); + + const status = await checkDbStatus(config.connection); + expect(status.trackingInitialized).toBe(true); + + const parsed = JSON.parse(result.stdout); + expect(parsed.trackingInitialized).toBe(true); + + }); + + it('created is true when the JSON output reports a genuinely fresh create', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + expect(existsSync(dbPath)).toBe(false); + + const result = runCreate(['--json']); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(parsed.created).toBe(true); + + }); + + it('created is false when the SQLite target file already existed before create ran', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + writeFileSync(dbPath, ''); + + const result = runCreate(['--json']); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(parsed.created).toBe(false); + + }); + + it('short-circuits without re-running createDb when the target already exists and is initialized', async () => { + + const config = await seedConfig({ user: 'admin', mcp: 'admin' }); + + const first = runCreate(['--json']); + expect(first.status).toBe(0); + + const status = await checkDbStatus(config.connection); + expect(status.exists).toBe(true); + expect(status.trackingInitialized).toBe(true); + + const mtimeBefore = statSync(dbPath).mtimeMs; + + const second = runCreate(['--json']); + + expect(second.status).toBe(0); + + const parsed = JSON.parse(second.stdout); + expect(parsed.alreadyExists).toBe(true); + expect(parsed.created).toBe(false); + + expect(statSync(dbPath).mtimeMs).toBe(mtimeBefore); + + }); + + it('targets the NOORM_CONNECTION_* database instead of the persisted config when both are set (#51)', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const envDbPath = join(tmpDir, 'env-target.db'); + + expect(existsSync(dbPath)).toBe(false); + expect(existsSync(envDbPath)).toBe(false); + + const result = runCreate(['--json'], { NOORM_CONNECTION_DATABASE: envDbPath }); + + expect(result.status).toBe(0); + expect(existsSync(envDbPath)).toBe(true); + expect(existsSync(dbPath)).toBe(false); + + const parsed = JSON.parse(result.stdout); + expect(parsed.database).toBe(envDbPath); + + const status = await checkDbStatus({ dialect: 'sqlite', database: envDbPath }); + expect(status.trackingInitialized).toBe(true); + + }); + +}); diff --git a/tests/cli/db/drop.test.ts b/tests/cli/db/drop.test.ts index 96f83be5..9d2c0a72 100644 --- a/tests/cli/db/drop.test.ts +++ b/tests/cli/db/drop.test.ts @@ -175,4 +175,19 @@ describe('cli: noorm db drop — access policy gate', () => { }); + it('targets the NOORM_CONNECTION_* database instead of the persisted config when both are set (#51)', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const envDbPath = join(tmpDir, 'env-target.db'); + writeFileSync(envDbPath, ''); + + const result = runDrop(['--yes'], { NOORM_CONNECTION_DATABASE: envDbPath }); + + expect(result.status).toBe(0); + expect(existsSync(envDbPath)).toBe(false); + expect(existsSync(dbPath)).toBe(true); + + }); + }); diff --git a/tests/cli/db/explore.test.ts b/tests/cli/db/explore.test.ts new file mode 100644 index 00000000..22b89abc --- /dev/null +++ b/tests/cli/db/explore.test.ts @@ -0,0 +1,130 @@ +/** + * cli: noorm db explore — result on stdout, diagnostics on stderr (CP4). + * + * Mirrors tests/cli/change/list.test.ts's harness. Before the CP4 fix, the + * "Database Overview" summary went to `logger.info`, which always routed + * to stderr in text mode — invisible to a CI step capturing stdout only. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'dbexplore'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm db explore — output streams', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-explore-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-db-explore-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active admin-role config, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runExplore(args: string[] = []) { + + return spawnSync('node', [CLI, 'db', 'explore', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + it('prints the overview summary to stdout, not stderr', async () => { + + await seedConfig(); + + const result = runExplore(); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Database Overview'); + + }); + + it('--json prints exactly one parseable JSON document to stdout', async () => { + + await seedConfig(); + + const result = runExplore(['--json']); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('tables'); + expect(parsed).toHaveProperty('views'); + + }); + +}); diff --git a/tests/cli/db/reset.test.ts b/tests/cli/db/reset.test.ts new file mode 100644 index 00000000..dcec2e02 --- /dev/null +++ b/tests/cli/db/reset.test.ts @@ -0,0 +1,257 @@ +/** + * cli: noorm db reset — CLI pre-gate + SDK yes-threading (v1-02-yes-flag CP-4). + * + * Mirrors tests/cli/db/drop.test.ts's harness: driven as a subprocess + * against the compiled CLI (reset/truncate/teardown all call + * `process.exit`, which would kill an in-process test runner). Identity + * comes from `NOORM_IDENTITY_*` env vars, and the config fixture is written + * directly via `StateManager` since `config add`/`edit` are TUI-only and + * can't set an exact `access` role from the CLI. + * + * Unlike `db drop` (a CLI-only role gate), `db reset`/`truncate`/`teardown` + * gate through the SDK's `checkProtectedConfig` — an operator-role config + * hits a `confirm` matrix cell there, resolved only by `options.yes`. These + * tests therefore also prove the `yes: isYesMode(args)` threading from + * `withContext` into `createContext` (spec C2/C3), not just `reset.ts`'s own + * pre-gate. The target is a real SQLite file, so `--yes` exercises the + * actual teardown/build path end-to-end, not a stub. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'resetme'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm db reset — pre-gate + yes threading', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-reset-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-db-reset-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active config at the given access role, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(access: ConfigAccess): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runReset(args: string[] = [], envOverrides: Record = {}) { + + return spawnSync('node', [CLI, 'db', 'reset', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + } + + it('blocks with the destructive-operation pre-gate when neither --yes nor NOORM_YES is set', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runReset(); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('This is a destructive operation. Pass --yes to confirm.'); + + }); + + it('passes the pre-gate and completes headlessly for an operator-role config with --yes', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runReset(['--yes']); + + const out = result.stdout + result.stderr; + expect(out).not.toContain('Pass --yes to confirm'); + expect(result.status).toBe(0); + + }); + + it('passes the pre-gate via NOORM_YES=1 alone, no --yes — the behavior CP-4 adds', async () => { + + // Base reset.ts checked bare `args.yes` only, so NOORM_YES=1 alone + // used to fail at this exact pre-gate. Reverting reset.ts's + // `isYesMode(args)` back to `args.yes` makes this fail (status 1, + // "Pass --yes to confirm"). + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runReset([], { NOORM_YES: '1' }); + + const out = result.stdout + result.stderr; + expect(out).not.toContain('Pass --yes to confirm'); + expect(result.status).toBe(0); + + }); + +}); + +describe('cli: noorm db truncate/teardown/reset — operator-role --yes headless success (ticket acceptance criterion)', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-yes-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-db-yes-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + async function seedConfig(access: ConfigAccess): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runDb(subcommand: string, args: string[] = []) { + + return spawnSync('node', [CLI, 'db', subcommand, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + // truncate/teardown declare `yes: sharedArgs.yes` but have no CLI + // pre-gate of their own (spec C3) — they rely entirely on withContext + // threading `yes: isYesMode(args)` into createContext, which + // checkProtectedConfig then consults for the operator-role `confirm` + // cell. If that threading regresses, these fail closed (exit 1, + // ProtectedConfigError), not open. + + it('noorm db truncate --yes succeeds headlessly for an operator-role config (no NOORM_YES)', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runDb('truncate', ['--yes']); + + expect(result.status).toBe(0); + + }); + + it('noorm db teardown --yes succeeds headlessly for an operator-role config (no NOORM_YES)', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runDb('teardown', ['--yes']); + + expect(result.status).toBe(0); + + }); + + it('noorm db reset --yes succeeds headlessly for an operator-role config (no NOORM_YES)', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runDb('reset', ['--yes']); + + expect(result.status).toBe(0); + + }); + +}); diff --git a/tests/cli/db/teardown.test.ts b/tests/cli/db/teardown.test.ts new file mode 100644 index 00000000..7ea79f8b --- /dev/null +++ b/tests/cli/db/teardown.test.ts @@ -0,0 +1,126 @@ +/** + * cli: noorm db teardown --dry-run (issue #49). + * + * `db teardown` had the same omission as `run build`: `sharedArgs.dryRun` + * was never spread into the command's args, and `ctx.noorm.db.teardown()` + * took no options at all — so `--dry-run` was a silent no-op and the + * command dropped every object regardless of the flag. + * + * Reuses the `run/_setup.ts` SQLite harness — an env-only project needs no + * identity setup, and the default access role (admin/admin) clears the + * `db:reset` confirm gate without `--yes`. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { sql } from 'kysely'; + +import { createConnection } from '../../../src/core/connection/factory.js'; +import { + cleanupProject, + extractJsonObject, + runCli, + setupProject, + TMP_BASE, + type TestProject, +} from '../run/_setup.js'; + +interface TeardownJson { + dropped: { tables: string[] }; + count: number; + dryRun?: boolean; +} + +async function tableExists(project: TestProject, tableName: string): Promise { + + const conn = await createConnection({ dialect: 'sqlite', database: project.dbPath }, '__test__'); + + const result = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName} + `.execute(conn.db); + + await conn.destroy(); + + return result.rows.length > 0; + +} + +describe('cli: noorm db teardown --dry-run', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + await writeFile( + join(project.dir, 'sql', '001_seed.sql'), + 'CREATE TABLE noorm_teardown_dryrun (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const build = runCli(project, ['run', 'build']); + expect(build.status).toBe(0); + expect(await tableExists(project, 'noorm_teardown_dryrun')).toBe(true); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + it('should leave every object intact', async () => { + + const result = runCli(project, ['db', 'teardown', '--dry-run']); + + expect(result.status).toBe(0); + expect(await tableExists(project, 'noorm_teardown_dryrun')).toBe(true); + + }); + + it('should list the table it would drop in the JSON payload, marked dryRun: true', async () => { + + const result = runCli(project, ['db', 'teardown', '--dry-run', '--json']); + const jsonStr = extractJsonObject(result.stdout); + + expect(jsonStr).not.toBeNull(); + + const parsed: TeardownJson = JSON.parse(jsonStr!); + + expect(parsed.dryRun).toBe(true); + expect(parsed.dropped.tables).toContain('noorm_teardown_dryrun'); + expect(await tableExists(project, 'noorm_teardown_dryrun')).toBe(true); + + }); + + it('should say so in human output', async () => { + + const result = runCli(project, ['db', 'teardown', '--dry-run']); + const out = (result.stdout + result.stderr).toLowerCase(); + + // The "would drop" verb alone conveys dry-run-ness — a separate + // "Dry run: no objects will be dropped" line was redundant with it + // and has been removed (v1/49-54 finding 3). + expect(out).toContain('would drop'); + expect(out).not.toContain('dry run:'); + + }); + + it('should actually drop the table on a sibling non-dry-run teardown', async () => { + + const result = runCli(project, ['db', 'teardown']); + + expect(result.status).toBe(0); + expect(await tableExists(project, 'noorm_teardown_dryrun')).toBe(false); + + }); + +}); diff --git a/tests/cli/db/transfer.test.ts b/tests/cli/db/transfer.test.ts new file mode 100644 index 00000000..fb530e8d --- /dev/null +++ b/tests/cli/db/transfer.test.ts @@ -0,0 +1,155 @@ +/** + * cli: noorm db transfer — passphrase floor guard. + * + * `db transfer` calls `process.exit`, so — like every other citty command + * test in this suite — it's driven as a subprocess against the compiled + * CLI rather than invoked in-process (tests/cli/db/drop.test.ts's pattern). + * The `.dtzx` guard fires before any connection or identity work, so these + * tests need no database and no `config`/`identity` fixture — a bare + * project directory is enough. `HOME` still points at a throwaway tmp dir + * so a stray discovery read never touches the developer's real `~/.noorm`. + * + * Regression under test: `--passphrase` accepted 1-character passphrases + * on `.dtzx` export, and a bare flag leaks via ps/shell history with no + * masked alternative. `MIN_PASSPHRASE_LENGTH` (12) is now enforced at this + * guard site before any connection work; non-interactive callers without + * the flag get a fast, actionable exit instead of hanging on a stdin read. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { parseArgs } from 'citty'; + +import transferCommand from '../../../src/cli/db/transfer.js'; +import { assertArgsDef } from '../citty-args.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm db transfer — passphrase floor', () => { + + let tmpDir: string; + let fakeHome: string; + let env: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-transfer-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-db-transfer-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + env = cleanEnvWithOverrides({ HOME: fakeHome }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + function runTransfer(args: string[]) { + + return spawnSync('node', [CLI, 'db', 'transfer', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env, + }); + + } + + it('rejects a .dtzx export with a passphrase shorter than the minimum', () => { + + const result = runTransfer(['--export', 'backup.dtzx', '--tables', 'users', '--passphrase', 'x']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('12 characters'); + + }); + + it('rejects a non-interactive .dtzx export with no --passphrase flag', () => { + + const result = runTransfer(['--export', 'backup.dtzx', '--tables', 'users']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('--passphrase'); + + }); + + it('parses --on-conflict into the renamed onConflict arg (invalid value echoes the parsed value)', () => { + + const result = runTransfer(['--export', 'backup.dt', '--tables', 'users', '--on-conflict', 'bogus']); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('Invalid --on-conflict value: "bogus"'); + + }); + +}); + +/** + * checkpoint 3 (v1-24-polish-batch): kebab-declared citty args (`on-conflict`, + * `batch-size`, `no-fk`, `no-identity`) were renamed to camelCase. These tests + * exercise citty's real `parseArgs` against the command's actual `args` + * definition — the exact object citty hands to `run({ args })` — so a broken + * rename (stale kebab key left in `args`, or an accessor that reads a name + * citty never populates) fails here without needing a live DB connection. + */ +describe('cli: noorm db transfer — camelCase arg parsing (checkpoint 3)', () => { + + it('parses --on-conflict and --batch-size into onConflict/batchSize', () => { + + const argsDef = transferCommand.args; + assertArgsDef(argsDef); + + const parsed = parseArgs(['--to', 'backup', '--on-conflict', 'skip', '--batch-size', '500'], argsDef); + + expect(parsed.onConflict).toBe('skip'); + expect(parsed.batchSize).toBe('500'); + + }); + + it('parses --no-fk and --no-identity without a CLI-arg-parsing error, matching the pre-existing citty negation bug', () => { + + const argsDef = transferCommand.args; + assertArgsDef(argsDef); + + const parsed = parseArgs(['--to', 'backup', '--no-fk', '--no-identity'], argsDef); + + // Pre-existing bug (out of scope for this checkpoint, not fixed here): citty + // unconditionally strips any `--no-` token and negates a flag literally + // named ``, so `--no-fk`/`--no-identity` have never set `noFk`/`noIdentity` — + // they negate unrelated `fk`/`identity` flags instead. This asserts the rename + // left that behavior identically unchanged (still broken, not newly broken). + expect(parsed.noFk).toBeUndefined(); + expect(parsed.noIdentity).toBeUndefined(); + expect(parsed.fk).toBe(false); + expect(parsed.identity).toBe(false); + + }); + +}); diff --git a/tests/cli/global-flags.test.ts b/tests/cli/global-flags.test.ts new file mode 100644 index 00000000..bff6b04f --- /dev/null +++ b/tests/cli/global-flags.test.ts @@ -0,0 +1,221 @@ +/** + * cli: root-level flag placement + * + * A flag goes on the command that uses it — placing it before the + * subcommand is an error, exactly like `--config`/`--force` already + * behaved. The sole exception is `-c`/`--cwd`: it is consumed before + * dispatch (it sets the working directory everything else resolves + * against), so it genuinely is the CLI's own flag, and it stays hoisted. + * + * `--dry-run`/`--json`/`--yes` used to be hoisted to any position + * (`extractGlobalFlags`, CP1 of v1/49-54). That asymmetry with + * `--config`/`--force` was reversed: these tests assert the reversal + * instead of the old both-positions contract. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { sql } from 'kysely'; + +import { createConnection } from '../../src/core/connection/factory.js'; +import { + CLI, + TMP_BASE, + cleanupProject, + extractJsonObject, + runCli, + setupProject, + type TestProject, +} from './run/_setup.js'; + +/** Whether a table exists in the project's SQLite database — ground truth independent of anything the CLI printed. */ +async function tableExists(project: TestProject, tableName: string): Promise { + + const conn = await createConnection({ dialect: 'sqlite', database: project.dbPath }, '__test__'); + + const result = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName} + `.execute(conn.db); + + await conn.destroy(); + + return result.rows.length > 0; + +} + +describe('cli: root-level flag placement', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + describe('--json', () => { + + it('works after the subcommand', () => { + + const result = runCli(project, ['config', 'list', '--json']); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ configs: [] }); + + }); + + it('errors before the subcommand instead of being silently dropped', () => { + + const result = runCli(project, ['--json', 'config', 'list']); + + expect(result.status).not.toBe(0); + expect(result.stdout).not.toContain('configs'); + expect(result.stderr).toContain("'--json'"); + expect(result.stderr).toContain('after the subcommand'); + expect(result.stderr).toContain('noorm ... --json'); + + }); + + }); + + describe('--yes', () => { + + it('errors before the subcommand instead of being silently dropped', () => { + + const result = spawnSync('node', [CLI, '--yes', 'sql', 'repl'], { + cwd: project.dir, + input: '', + encoding: 'utf-8', + env: { ...process.env, ...project.env, NOORM_YES: '' }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("'--yes'"); + expect(result.stderr).toContain('after the subcommand'); + expect(result.stderr).toContain('noorm ... --yes'); + + }); + + }); + + describe('--dry-run', () => { + + it('never touches the database when misplaced before the subcommand', async () => { + + await writeFile( + `${project.dir}/sql/create.sql`, + 'CREATE TABLE noorm_dry_run_t1 (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + // The important case: if a leading `--dry-run` were silently dropped + // (the pre-fix defect this whole checkpoint used to guard against), + // this would run the CREATE TABLE for real while reporting an error. + const leading = runCli(project, ['--dry-run', 'run', 'build']); + + expect(leading.status).not.toBe(0); + expect(leading.stderr).toContain("'--dry-run'"); + expect(await tableExists(project, 'noorm_dry_run_t1')).toBe(false); + + const trailing = runCli(project, ['run', 'build', '--dry-run', '--json']); + const trailingJson = JSON.parse(extractJsonObject(trailing.stdout)!); + + expect(trailing.status).toBe(0); + expect(trailingJson.status).toBe('success'); + expect(await tableExists(project, 'noorm_dry_run_t1')).toBe(false); + + }); + + }); + + describe('an unrecognized flag before the subcommand', () => { + + it('exits non-zero and names the correct invocation instead of silently dropping it', () => { + + const result = runCli(project, ['--bogus', 'config', 'list']); + + expect(result.status).not.toBe(0); + expect(result.stdout).not.toContain('configs'); + expect(result.stderr).toContain("'--bogus'"); + expect(result.stderr).toContain('after the subcommand'); + + }); + + }); + + describe('-c/--cwd', () => { + + it('still works at root and its value is never mistaken for the subcommand name', () => { + + const result = spawnSync('node', [CLI, '-c', project.dir, 'config', 'list'], { + cwd: TMP_BASE, + encoding: 'utf-8', + env: { ...process.env }, + }); + + // If `project.dir` were mistaken for the subcommand, citty would + // report it as an unknown command rather than run `config list`. + expect(result.status).toBe(0); + expect(result.stdout).not.toContain('Unknown command'); + + }); + + it('composes with a flag placed correctly on the subcommand', () => { + + const result = spawnSync('node', [CLI, '-c', project.dir, 'config', 'list', '--json'], { + cwd: TMP_BASE, + encoding: 'utf-8', + env: { ...process.env }, + }); + + expect(result.status).toBe(0); + expect(JSON.parse((result.stdout ?? '').trim())).toEqual({ configs: [] }); + + }); + + }); + + describe('--help / --version', () => { + + it('--help works before the subcommand', () => { + + const result = runCli(project, ['--help']); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('noorm'); + + }); + + it('--help works after the subcommand', () => { + + const result = runCli(project, ['config', '--help']); + + expect(result.status).toBe(0); + expect(result.stdout.toLowerCase()).toContain('config'); + + }); + + it('--version works before the subcommand', () => { + + const result = runCli(project, ['--version']); + + expect(result.status).toBe(0); + expect(result.stdout.trim().length).toBeGreaterThan(0); + + }); + + }); + +}); diff --git a/tests/cli/hooks/useObserver.test.tsx b/tests/cli/hooks/useObserver.test.tsx index 1473c7f9..a185c999 100644 --- a/tests/cli/hooks/useObserver.test.tsx +++ b/tests/cli/hooks/useObserver.test.tsx @@ -1,7 +1,7 @@ /** * Observer hooks tests. * - * Tests useOnEvent, useOnceEvent, useEmit, and useEventPromise. + * Tests useOnEvent, useOnceEvent, and useEmit. */ import { afterEach, describe, it, expect } from 'bun:test'; import { render } from 'ink-testing-library'; @@ -14,7 +14,6 @@ import { useOnEvent, useOnceEvent, useEmit, - useEventPromise, } from '../../../src/tui/hooks/useObserver.js'; /** @@ -390,141 +389,4 @@ describe('cli: hooks/useObserver', () => { }); - describe('useEventPromise', () => { - - it('should start in pending state', () => { - - function PromiseUser() { - - const [value, error, pending] = useEventPromise('build:complete'); - - return ( - - pending:{String(pending)}|value:{value ? 'yes' : 'no'}|error: - {error ? 'yes' : 'no'} - - ); - - } - - const { lastFrame, unmount } = render(); - - expect(lastFrame()).toContain('pending:true'); - expect(lastFrame()).toContain('value:no'); - expect(lastFrame()).toContain('error:no'); - - unmount(); - - }); - - it('should resolve with value when event fires', async () => { - - function PromiseUser() { - - const [value, _error, pending] = useEventPromise('build:complete'); - - return ( - - pending:{String(pending)}|status:{value?.status ?? 'none'} - - ); - - } - - const { lastFrame, unmount } = render(); - - await new Promise((r) => setTimeout(r, 10)); - - observer.emit('build:complete', { - status: 'success', - filesRun: 5, - filesSkipped: 2, - filesFailed: 0, - durationMs: 1234, - }); - - await new Promise((r) => setTimeout(r, 10)); - - expect(lastFrame()).toContain('pending:false'); - expect(lastFrame()).toContain('status:success'); - - unmount(); - - }); - - it('should allow cancellation', async () => { - - function CancellableUser() { - - const [value, _error, pending, cancel] = useEventPromise('build:complete'); - - useEffect(() => { - - const timer = setTimeout(() => cancel(), 20); - - return () => clearTimeout(timer); - - }, [cancel]); - - return ( - - pending:{String(pending)}|value:{value ? 'yes' : 'no'} - - ); - - } - - const { lastFrame, unmount } = render(); - - await new Promise((r) => setTimeout(r, 50)); - - // After cancellation, the subscription is removed but pending stays true - // (no event was received to resolve it) — this is @logosdx/react behavior - expect(lastFrame()).toContain('pending:true'); - expect(lastFrame()).toContain('value:no'); - - unmount(); - - }); - - it('should cleanup on unmount', async () => { - - let resolveCount = 0; - - function PromiseUser() { - - const [value] = useEventPromise('build:complete'); - - useEffect(() => { - - if (value) resolveCount++; - - }, [value]); - - return waiting; - - } - - const { unmount } = render(); - - await new Promise((r) => setTimeout(r, 10)); - - unmount(); - - observer.emit('build:complete', { - status: 'success', - filesRun: 0, - filesSkipped: 0, - filesFailed: 0, - durationMs: 0, - }); - - await new Promise((r) => setTimeout(r, 10)); - - expect(resolveCount).toBe(0); - - }); - - }); - }); diff --git a/tests/cli/insecure-flag.test.ts b/tests/cli/insecure-flag.test.ts new file mode 100644 index 00000000..be85b42c --- /dev/null +++ b/tests/cli/insecure-flag.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for the `--insecure` / `NOORM_INSECURE` checksum-verification escape + * hatch's arg/env resolution. + * + * Scoped to `isInsecureMode`'s truthy/falsy parsing only — mirrors + * `tests/cli/yes-flag.test.ts`'s coverage of `isYesMode`. The binary-swap + * path this flag ultimately gates (`installViaBinary`) is deliberately not + * exercised here: swapping over the test runner's own `process.execPath` + * is out of scope (see `tests/core/update/updater.test.ts`), and the + * mismatch-always-throws invariant it protects is already proven in + * `tests/core/update/checksum.test.ts`. This file only proves the flag + * resolves the same way `--yes`/`NOORM_YES` does. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { isInsecureMode } from '../../src/cli/_utils.js'; + +describe('cli: isInsecureMode helper', () => { + + let originalInsecure: string | undefined; + + beforeEach(() => { + + originalInsecure = process.env['NOORM_INSECURE']; + delete process.env['NOORM_INSECURE']; + + }); + + afterEach(() => { + + if (originalInsecure === undefined) { + + delete process.env['NOORM_INSECURE']; + + } + else { + + process.env['NOORM_INSECURE'] = originalInsecure; + + } + + }); + + it('returns true when args.insecure is true', () => { + + expect(isInsecureMode({ insecure: true })).toBe(true); + + }); + + it('returns true when NOORM_INSECURE=1', () => { + + process.env['NOORM_INSECURE'] = '1'; + + expect(isInsecureMode({})).toBe(true); + + }); + + it('returns true when NOORM_INSECURE=true', () => { + + process.env['NOORM_INSECURE'] = 'true'; + + expect(isInsecureMode({})).toBe(true); + + }); + + it('returns false when NOORM_INSECURE=0', () => { + + process.env['NOORM_INSECURE'] = '0'; + + expect(isInsecureMode({})).toBe(false); + + }); + + it('returns false when NOORM_INSECURE=false (case-insensitive)', () => { + + process.env['NOORM_INSECURE'] = 'False'; + + expect(isInsecureMode({})).toBe(false); + + }); + + it('returns false when NOORM_INSECURE is empty string', () => { + + process.env['NOORM_INSECURE'] = ''; + + expect(isInsecureMode({})).toBe(false); + + }); + + it('returns false when neither the flag nor the env var is set', () => { + + expect(isInsecureMode({})).toBe(false); + + }); + + it('--insecure flag wins over NOORM_INSECURE=0', () => { + + process.env['NOORM_INSECURE'] = '0'; + + expect(isInsecureMode({ insecure: true })).toBe(true); + + }); + +}); diff --git a/tests/cli/lazy-startup.test.ts b/tests/cli/lazy-startup.test.ts new file mode 100644 index 00000000..2ab104de --- /dev/null +++ b/tests/cli/lazy-startup.test.ts @@ -0,0 +1,143 @@ +/** + * Static import-graph check for lazy CLI startup. + * + * Walks only top-level ImportDeclaration/ExportDeclaration nodes via + * the TypeScript AST -- deliberately not a regex over source text -- so + * dynamic import() call expressions (which live inside statement + * bodies, e.g. inside run()) are structurally excluded from the walk + * rather than pattern-matched around. Proves headless invocations never + * statically reach Ink, React, or the TUI. + */ +import { describe, it, expect } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +import ts from 'typescript'; + +const REPO_ROOT = process.cwd(); + +/** + * Collects the module specifiers of a file's top-level static imports: + * import ... from 'x' and re-export forms (export {a} from 'x', + * export * from 'x'). Local export function ... / export const ... + * declarations have no moduleSpecifier and are skipped. + */ +function extractStaticSpecifiers(filePath: string): string[] { + + const source = readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const specifiers: string[] = []; + + for (const statement of sourceFile.statements) { + + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + + specifiers.push(statement.moduleSpecifier.text); + + } + + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { + + specifiers.push(statement.moduleSpecifier.text); + + } + + } + + return specifiers; + +} + +/** + * Resolves a relative import specifier against the importing file's + * directory to its source file, mirroring this repo's NodeNext + * convention (source imports carry a .js extension that maps to a + * .ts/.tsx file at authoring time). + */ +function resolveRelative(fromFile: string, specifier: string): string { + + const withoutExt = specifier.endsWith('.js') ? specifier.slice(0, -3) : specifier; + const base = resolve(dirname(fromFile), withoutExt); + + if (existsSync(base + '.ts')) return base + '.ts'; + if (existsSync(base + '.tsx')) return base + '.tsx'; + + throw new Error('Cannot resolve "' + specifier + '" from ' + fromFile + ': neither ' + base + '.ts nor ' + base + '.tsx exists'); + +} + +/** + * BFS over the static import graph reachable from rootFile. Only + * relative specifiers (./..) are followed; bare package specifiers + * are recorded but not recursed into -- there's no need to walk into + * node_modules to prove a package is (or isn't) reachable. + */ +function staticReachable(rootFile: string): { files: Set; bareSpecifiers: Set } { + + const files = new Set(); + const bareSpecifiers = new Set(); + const queue = [rootFile]; + + while (queue.length > 0) { + + const current = queue.shift()!; + + if (files.has(current)) continue; + files.add(current); + + for (const specifier of extractStaticSpecifiers(current)) { + + if (specifier.startsWith('.')) { + + queue.push(resolveRelative(current, specifier)); + + } + else if (!specifier.startsWith('/') && !specifier.startsWith('node:')) { + + bareSpecifiers.add(specifier); + + } + + } + + } + + return { files, bareSpecifiers }; + +} + +describe('cli: lazy startup - static import graph', () => { + + it('headless entry point never statically reaches ink, react, or the tui', () => { + + const { files, bareSpecifiers } = staticReachable(join(REPO_ROOT, 'src/cli/index.ts')); + + expect(bareSpecifiers.has('ink')).toBe(false); + expect(bareSpecifiers.has('react')).toBe(false); + + const tuiRoot = join(REPO_ROOT, 'src/tui') + '/'; + const reachesTui = [...files].some((file) => file.startsWith(tuiRoot)); + + expect(reachesTui).toBe(false); + + }); + + it('ui.ts does not statically import ink or react', () => { + + const specifiers = extractStaticSpecifiers(join(REPO_ROOT, 'src/cli/ui.ts')); + + expect(specifiers).not.toContain('ink'); + expect(specifiers).not.toContain('react'); + + }); + + it('sql/repl.ts does not statically import ink or react', () => { + + const specifiers = extractStaticSpecifiers(join(REPO_ROOT, 'src/cli/sql/repl.ts')); + + expect(specifiers).not.toContain('ink'); + expect(specifiers).not.toContain('react'); + + }); + +}); diff --git a/tests/cli/lock/status.test.ts b/tests/cli/lock/status.test.ts new file mode 100644 index 00000000..8d620686 --- /dev/null +++ b/tests/cli/lock/status.test.ts @@ -0,0 +1,130 @@ +/** + * cli: noorm lock status — result on stdout, diagnostics on stderr (CP4). + * + * Mirrors tests/cli/change/list.test.ts's harness. Before the CP4 fix, + * "No active lock" (and the locked-by summary) went to `logger.info`, + * which always routed to stderr in text mode — invisible to a CI step + * capturing stdout only. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'lockstatus'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm lock status — output streams', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-lock-status-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-lock-status-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active admin-role config, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runStatus(args: string[] = []) { + + return spawnSync('node', [CLI, 'lock', 'status', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + it('prints "No active lock" to stdout, not stderr, when unlocked', async () => { + + await seedConfig(); + + const result = runStatus(); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('No active lock'); + + }); + + it('--json prints exactly one parseable JSON document to stdout', async () => { + + await seedConfig(); + + const result = runStatus(['--json']); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(parsed).toEqual({ isLocked: false, lock: null }); + + }); + +}); diff --git a/tests/cli/postinstall-source-checkout.test.ts b/tests/cli/postinstall-source-checkout.test.ts new file mode 100644 index 00000000..9a0d794b --- /dev/null +++ b/tests/cli/postinstall-source-checkout.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for the npm postinstall binary download (`packages/cli/scripts/postinstall.js`). + * + * The script is spawned as a real `node` process rather than imported: it is + * plain ESM shipped in the npm tarball (the repo does not set `allowJs`), and + * spawning exercises the exact invocation a package manager performs. + * + * The invariant under test is a split one, and both halves matter: + * + * - Inside this monorepo the release binary must NEVER be downloaded. The + * script pins its download to the release tag matching packages/cli's + * version, which in a source checkout is routinely unreleased (any commit + * after a version bump, and every commit before that version is cut). CI + * builds the CLI from source and never executes the downloaded binary, so + * the download is dead weight that can only ever break `bun install`. + * + * - For an npm consumer the download must still happen. A skip check that is + * too broad would silently install the wrapper with no binary behind it, + * which is a worse failure than the one it fixes -- it surfaces at first + * use rather than at install time. + * + * Both cases run against a fabricated directory tree rather than the real + * repo, so neither depends on whether packages/cli/bin happens to be populated + * on the machine running the tests. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtemp, mkdir, rm, writeFile, copyFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; + +const REAL_SCRIPT = resolve(import.meta.dir, '../../packages/cli/scripts/postinstall.js'); + +// Matched loosely (case-insensitive substring) so the test pins the behavior, +// not the exact prose of the console message. +const SKIP_MARKER = 'source checkout'; +const DOWNLOAD_MARKER = 'Downloading noorm'; + +let workDir: string; + +/** + * Writes a package tree containing a copy of the real postinstall script and + * returns the directory the script lands in. + * + * @param layout directory path, relative to workDir, that plays the role of + * packages/cli -- i.e. where package.json and scripts/postinstall.js go. + * @param rootPkg package.json to write two levels above `layout`, or null to + * leave that level empty (the npm-consumer case). + */ +const fabricate = async (layout: string, rootPkg: Record | null) => { + + const pkgDir = join(workDir, layout); + + await mkdir(join(pkgDir, 'scripts'), { recursive: true }); + await copyFile(REAL_SCRIPT, join(pkgDir, 'scripts', 'postinstall.js')); + + await writeFile( + join(pkgDir, 'package.json'), + JSON.stringify({ name: '@noormdev/cli', version: '1.0.0-alpha.39' }), + ); + + if (rootPkg) { + + await writeFile( + join(pkgDir, '..', '..', 'package.json'), + JSON.stringify(rootPkg), + ); + + } + + return pkgDir; + +}; + +/** + * Runs the fabricated script to completion and returns its output. + * + * Only safe for cases that terminate without network access -- see + * `runUntilDownload` for the consumer case, which does reach the network. + */ +const run = async (pkgDir: string) => { + + const proc = Bun.spawn(['node', join(pkgDir, 'scripts', 'postinstall.js')], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NOORM_INSECURE: '' }, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout, stderr, exitCode, output: stdout + stderr }; + +}; + +/** + * Runs the script only until it announces the download, then kills it. + * + * The consumer path deliberately hits GitHub Releases, which a test must not + * wait on. Reading up to the announcement is enough to prove the skip check + * did not swallow this case, and killing early keeps the test hermetic: the + * assertion holds whether or not the machine has network. + */ +const runUntilDownload = async (pkgDir: string) => { + + const proc = Bun.spawn(['node', join(pkgDir, 'scripts', 'postinstall.js')], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, NOORM_INSECURE: '' }, + }); + + let seen = ''; + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + + while (!seen.includes(DOWNLOAD_MARKER)) { + + const { done, value } = await reader.read(); + + if (done) break; + + seen += decoder.decode(value, { stream: true }); + + } + + proc.kill(); + await proc.exited; + + return seen; + +}; + +beforeAll(async () => { + + workDir = await mkdtemp(join(tmpdir(), 'noorm-postinstall-')); + +}); + +afterAll(async () => { + + await rm(workDir, { recursive: true, force: true }); + +}); + +describe('postinstall: binary download', () => { + + it('skips the download inside the noorm monorepo source checkout', async () => { + + const pkgDir = await fabricate('repo/packages/cli', { + name: '@noormdev/main', + workspaces: ['packages/*'], + }); + + const { output, exitCode } = await run(pkgDir); + + expect(output.toLowerCase()).toContain(SKIP_MARKER); + expect(output).not.toContain(DOWNLOAD_MARKER); + + // The whole point: a source checkout must not be able to fail an install. + expect(exitCode).toBe(0); + + expect(existsSync(join(pkgDir, 'bin', 'noorm'))).toBe(false); + + }); + + it('still downloads for an npm consumer outside the monorepo', async () => { + + const pkgDir = await fabricate('consumer/node_modules/@noormdev/cli', null); + + const seen = await runUntilDownload(pkgDir); + + expect(seen).toContain(DOWNLOAD_MARKER); + expect(seen.toLowerCase()).not.toContain(SKIP_MARKER); + + }); + + it('does not mistake an unrelated parent package for the monorepo root', async () => { + + const pkgDir = await fabricate('other/packages/cli', { + name: '@someoneelse/monorepo', + workspaces: ['packages/*'], + }); + + const seen = await runUntilDownload(pkgDir); + + expect(seen).toContain(DOWNLOAD_MARKER); + expect(seen.toLowerCase()).not.toContain(SKIP_MARKER); + + }); + +}); diff --git a/tests/cli/run/build.test.ts b/tests/cli/run/build.test.ts index 7d451d5d..d71e2e6c 100644 --- a/tests/cli/run/build.test.ts +++ b/tests/cli/run/build.test.ts @@ -1,15 +1,23 @@ /** - * cli: noorm run build — per-file error and skip surfacing in batch summaries. + * cli: noorm run build — per-file error and skip surfacing in batch summaries, + * and --dry-run honoring (issue #49). * * `run build` executes every file under `paths.sql/`. When any file * fails or is skipped, the summary alone is not enough — users need * to know *which* file and *why*. These tests verify that information * appears in both human output and the JSON `files[]` array. + * + * The --dry-run tests guard against #49's regression: the flag was declared + * in `sharedArgs` but never spread into `build`'s citty `args`, so + * `args.dryRun` was always `undefined` and a "preview" build silently + * applied every file to the target database. */ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { sql } from 'kysely'; +import { createConnection } from '../../../src/core/connection/factory.js'; import { cleanupProject, extractJsonObject, @@ -19,6 +27,25 @@ import { type TestProject, } from './_setup.js'; +/** + * Whether a table exists in the project's SQLite database — the ground + * truth for "did the build actually execute", independent of anything + * the CLI printed. + */ +async function tableExists(project: TestProject, tableName: string): Promise { + + const conn = await createConnection({ dialect: 'sqlite', database: project.dbPath }, '__test__'); + + const result = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName} + `.execute(conn.db); + + await conn.destroy(); + + return result.rows.length > 0; + +} + interface FileResult { filepath: string; status: string; @@ -32,6 +59,7 @@ interface BatchResult { filesRun: number; filesSkipped: number; filesFailed: number; + dryRun?: boolean; } describe('cli: noorm run build — per-file error and skip surfacing', () => { @@ -113,13 +141,227 @@ describe('cli: noorm run build — per-file error and skip surfacing', () => { }); - // Note: skip-on-re-run is not testable for build/dir/files because the - // runner's `executeFiles` flow creates an upfront 'pending' execution - // record before calling needsRun, which then finds the pending row - // (newest by id) and re-runs the file. This is a latent runner bug - // independent of the observability work in this slice — see handoff - // notes. The skip-reason display logic is exercised end-to-end by - // tests/cli/run/file.test.ts, which uses the runFile flow that - // bypasses createFileRecords. +}); + +describe('cli: noorm run build — skip-on-rerun (CP10, v1/49-54)', () => { + + // Previously untestable: `executeFiles` creates an upfront 'pending' + // execution record for every file before `needsRun` ever runs, and + // `needsRun` picked the newest row by id -- always that same pending + // record -- so it always read as 'new'. Checksum-based skipping was + // structurally unreachable for build/dir/files. Fixed by excluding the + // running operation's own rows from the `needsRun` lookup. + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + it('should skip an unchanged file on a second build', async () => { + + await writeFile( + join(project.dir, 'sql', '001_skip.sql'), + 'CREATE TABLE noorm_build_skip_a (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const first = runCli(project, ['run', 'build']); + expect(first.status).toBe(0); + + const second = runCli(project, ['run', 'build', '--json']); + expect(second.status).toBe(0); + + const jsonStr = extractJsonObject(second.stdout); + expect(jsonStr).not.toBeNull(); + + const parsed: BatchResult = JSON.parse(jsonStr!); + + expect(parsed.filesSkipped).toBe(1); + expect(parsed.filesRun).toBe(0); + expect(parsed.files[0]!.status).toBe('skipped'); + expect(parsed.files[0]!.skipReason).toBe('unchanged'); + + }); + + it('should say so in human output on the second build', async () => { + + await writeFile( + join(project.dir, 'sql', '001_skip.sql'), + 'CREATE TABLE noorm_build_skip_b (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const first = runCli(project, ['run', 'build']); + expect(first.status).toBe(0); + + const second = runCli(project, ['run', 'build']); + const out = second.stdout + second.stderr; + + expect(second.status).toBe(0); + expect(out).toContain('skipped'); + expect(out).toContain('unchanged'); + + }); + + it('should re-run an unchanged file when --force is passed', async () => { + + await writeFile( + join(project.dir, 'sql', '001_force.sql'), + 'CREATE TABLE IF NOT EXISTS noorm_build_force (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const first = runCli(project, ['run', 'build']); + expect(first.status).toBe(0); + + // Unforced: the unchanged file is skipped. + const second = runCli(project, ['run', 'build', '--json']); + const secondParsed: BatchResult = JSON.parse(extractJsonObject(second.stdout)!); + expect(secondParsed.filesSkipped).toBe(1); + expect(secondParsed.filesRun).toBe(0); + + // Forced: the same unchanged file re-runs instead of skipping. + const third = runCli(project, ['run', 'build', '--force', '--json']); + const thirdParsed: BatchResult = JSON.parse(extractJsonObject(third.stdout)!); + expect(thirdParsed.filesRun).toBe(1); + expect(thirdParsed.filesSkipped).toBe(0); + + }); + + it('should re-run a file whose content changed since the last build', async () => { + + const filePath = join(project.dir, 'sql', '001_changed.sql'); + await writeFile( + filePath, + 'CREATE TABLE noorm_build_changed_a (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const first = runCli(project, ['run', 'build']); + expect(first.status).toBe(0); + + // A content change is a different checksum, distinct from the + // upfront-pending-row bug CP10 fixes. + await writeFile( + filePath, + 'CREATE TABLE noorm_build_changed_b (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const second = runCli(project, ['run', 'build', '--json']); + expect(second.status).toBe(0); + + const parsed: BatchResult = JSON.parse(extractJsonObject(second.stdout)!); + + expect(parsed.filesRun).toBe(1); + expect(parsed.filesSkipped).toBe(0); + expect(await tableExists(project, 'noorm_build_changed_b')).toBe(true); + + }); + +}); + +describe('cli: noorm run build --dry-run', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + it('should create zero database objects', async () => { + + await writeFile( + join(project.dir, 'sql', '001_dryrun.sql'), + 'CREATE TABLE noorm_build_dryrun_a (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = runCli(project, ['run', 'build', '--dry-run']); + + expect(result.status).toBe(0); + expect(await tableExists(project, 'noorm_build_dryrun_a')).toBe(false); + + }); + + it('should create the table on a sibling non-dry-run build', async () => { + + await writeFile( + join(project.dir, 'sql', '001_live.sql'), + 'CREATE TABLE noorm_build_dryrun_b (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = runCli(project, ['run', 'build']); + + expect(result.status).toBe(0); + expect(await tableExists(project, 'noorm_build_dryrun_b')).toBe(true); + + }); + + it('should mark the JSON payload with dryRun: true', async () => { + + await writeFile( + join(project.dir, 'sql', '001_dryrun.sql'), + 'CREATE TABLE noorm_build_dryrun_c (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = runCli(project, ['run', 'build', '--dry-run', '--json']); + const jsonStr = extractJsonObject(result.stdout); + + expect(jsonStr).not.toBeNull(); + + const parsed: BatchResult = JSON.parse(jsonStr!); + + expect(parsed.dryRun).toBe(true); + expect(parsed.status).toBe('success'); + expect(await tableExists(project, 'noorm_build_dryrun_c')).toBe(false); + + }); + + it('should say so in human output', async () => { + + await writeFile( + join(project.dir, 'sql', '001_dryrun.sql'), + 'CREATE TABLE noorm_build_dryrun_d (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = runCli(project, ['run', 'build', '--dry-run']); + const out = (result.stdout + result.stderr).toLowerCase(); + + expect(out).toContain('dry'); + + }); }); diff --git a/tests/cli/run/change-rewind.test.ts b/tests/cli/run/change-rewind.test.ts new file mode 100644 index 00000000..343cf1ed --- /dev/null +++ b/tests/cli/run/change-rewind.test.ts @@ -0,0 +1,109 @@ +/** + * cli: noorm change rewind — exit code on partial failure. + * + * `change rewind` reverts applied changes most-recent-first, aborting on + * the first failed revert (`abortOnError` defaults to true). When some + * reverts succeed and at least one fails, `ChangeManager.rewind()` returns + * `status: 'partial'` — the schema is left in a mixed state. Every sibling + * batch command (`run`/`revert`/`ff`/`next`) maps `status === 'success' ? 0 + * : 2`; these tests guard `rewind` against the same contract. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + cleanupProject, + runCli, + setupProject, + TMP_BASE, + type TestProject, +} from './_setup.js'; + +async function makeChange( + project: TestProject, + name: string, + changeSql: string, + revertSql: string, +): Promise { + + const changeDir = join(project.dir, 'changes', name, 'change'); + const revertDir = join(project.dir, 'changes', name, 'revert'); + + await mkdir(changeDir, { recursive: true }); + await mkdir(revertDir, { recursive: true }); + await writeFile(join(changeDir, '001.sql'), changeSql, 'utf-8'); + await writeFile(join(revertDir, '001.sql'), revertSql, 'utf-8'); + +} + +describe('cli: noorm change rewind — exit code on partial failure', () => { + + let project: TestProject; + + beforeAll(async () => { + + await mkdir(TMP_BASE, { recursive: true }); + + }); + + beforeEach(async () => { + + project = await setupProject(); + + }); + + afterEach(async () => { + + await cleanupProject(project); + + }); + + it('should exit 2 and log the failure when a rewind partially fails', async () => { + + // Later-applied change reverts cleanly; earlier-applied change's + // revert SQL errors. Rewind reverts most-recent-first, so the good + // revert runs (executed++) before the bad one aborts (failed++), + // yielding status 'partial'. + await makeChange( + project, + '2025-01-01-first', + 'CREATE TABLE t1 (id INTEGER PRIMARY KEY);\n', + 'SELECT * FROM nonexistent_table_xyz;\n', + ); + await makeChange( + project, + '2025-01-02-second', + 'CREATE TABLE t2 (id INTEGER PRIMARY KEY);\n', + 'DROP TABLE t2;\n', + ); + + expect(runCli(project, ['change', 'run', '2025-01-01-first']).status).toBe(0); + expect(runCli(project, ['change', 'run', '2025-01-02-second']).status).toBe(0); + + const result = runCli(project, ['change', 'rewind', '2025-01-01-first']); + const out = result.stdout + result.stderr; + + expect(result.status).toBe(2); + expect(out.toLowerCase()).toContain('failed'); + + }); + + it('should exit 0 when a rewind fully succeeds', async () => { + + await makeChange( + project, + '2025-01-01-only', + 'CREATE TABLE t3 (id INTEGER PRIMARY KEY);\n', + 'DROP TABLE t3;\n', + ); + + expect(runCli(project, ['change', 'run', '2025-01-01-only']).status).toBe(0); + + const result = runCli(project, ['change', 'rewind', '2025-01-01-only']); + + expect(result.status).toBe(0); + + }); + +}); diff --git a/tests/cli/run/preview-inspect-vault-probe.test.ts b/tests/cli/run/preview-inspect-vault-probe.test.ts new file mode 100644 index 00000000..9a2526ba --- /dev/null +++ b/tests/cli/run/preview-inspect-vault-probe.test.ts @@ -0,0 +1,154 @@ +/** + * cli: `run preview` / `run inspect` stay fast with no reachable database + * and say so rather than silently showing only local secrets (v1/49-54 + * finding 1). CP6 wired both commands to probe the vault tier through a + * brand-new connection; without a fail-fast override that probe inherited + * `createConnection`'s 3x/1s-backoff retry policy (~6-7s) on a + * documented-offline command, and degraded silently on failure. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import { StateManager } from '../../../src/core/state/manager.js'; +import { generateKeyPair } from '../../../src/core/identity/index.js'; +import type { Config } from '../../../src/core/config/types.js'; + +import { TMP_BASE, cleanupProject, extractJsonObject, runCli, type TestProject } from './_setup.js'; + +/** + * A project with a stored, active config pointing at a database that will + * never accept a connection. Nothing listens on the chosen port — a fast, + * certain ECONNREFUSED, not a slow network timeout — so any elapsed-time + * assertion is measuring retry/backoff, not the OS's own connect timeout. + */ +async function setupUnreachableProject(): Promise { + + const testId = randomUUID().slice(0, 8); + const dir = join(TMP_BASE, `cli-run-vault-probe-${testId}`); + const sqlDir = join(dir, 'sql'); + + await mkdir(sqlDir, { recursive: true }); + await writeFile(join(sqlDir, 'greet.sql.tmpl'), "select 'hi' as val;\n"); + + const { privateKey } = generateKeyPair(); + + const config: Config = { + name: 'unreachable', + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { + dialect: 'postgres', + host: '127.0.0.1', + port: 59999, + database: 'nope', + }, + }; + + // Written directly through StateManager (not via the CLI, which has no + // headless `config add`) so the subprocess spawned below finds a + // stored, active config without any interactive setup. + const state = new StateManager(dir, { privateKey }); + await state.load(); + await state.setConfig(config.name, config); + await state.setActiveConfig(config.name); + + return { + dir, + dbPath: '', + env: { + // Matches the key state.enc was encrypted with, so the CLI + // subprocess can decrypt it without touching ~/.noorm. + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'Probe Tester', + NOORM_IDENTITY_EMAIL: 'probe@example.com', + }, + }; + +} + +describe('cli: run preview / run inspect stay fast offline (v1/49-54 finding 1)', () => { + + let project: TestProject | undefined; + + afterEach(async () => { + + if (project) await cleanupProject(project); + project = undefined; + + }); + + it('run preview returns quickly and notes the vault tier was not consulted (text)', async () => { + + project = await setupUnreachableProject(); + + const start = Date.now(); + const result = runCli(project, ['run', 'preview', 'sql/greet.sql.tmpl']); + const elapsed = Date.now() - start; + + // Comfortably under the ~6-7s the default retry/backoff would add, + // comfortably above a bare CLI invocation so this isn't flaky + // under a loaded CI runner. + expect(elapsed).toBeLessThan(4000); + expect(result.status).toBe(0); + + // The rendered SQL still reaches stdout, undisturbed and pipeable. + expect(result.stdout).toContain("select 'hi' as val;"); + + // The notice is a diagnostic, not part of the pipeable result. + expect(result.stderr).toMatch(/vault/i); + expect(result.stderr).not.toContain('undefined'); + + }); + + it('run preview reports the same notice in --json mode', async () => { + + project = await setupUnreachableProject(); + + const result = runCli(project, ['run', 'preview', 'sql/greet.sql.tmpl', '--json']); + + expect(result.status).toBe(0); + + const jsonText = extractJsonObject(result.stdout); + expect(jsonText).not.toBeNull(); + + const parsed = JSON.parse(jsonText!) as { vaultProbeFailed?: boolean; notice?: string }; + expect(parsed.vaultProbeFailed).toBe(true); + expect(parsed.notice).toMatch(/vault/i); + + }); + + it('run inspect returns quickly and notes the vault tier was not consulted (text)', async () => { + + project = await setupUnreachableProject(); + + const start = Date.now(); + const result = runCli(project, ['run', 'inspect', 'sql/greet.sql.tmpl']); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(4000); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/vault/i); + + }); + + it('run inspect reports the same notice in --json mode', async () => { + + project = await setupUnreachableProject(); + + const result = runCli(project, ['run', 'inspect', 'sql/greet.sql.tmpl', '--json']); + + expect(result.status).toBe(0); + + const jsonText = extractJsonObject(result.stdout); + expect(jsonText).not.toBeNull(); + + const parsed = JSON.parse(jsonText!) as { vaultProbeFailed?: boolean; notice?: string }; + expect(parsed.vaultProbeFailed).toBe(true); + expect(parsed.notice).toMatch(/vault/i); + + }); + +}); diff --git a/tests/cli/screens/config/ConfigEditScreen.test.tsx b/tests/cli/screens/config/ConfigEditScreen.test.tsx new file mode 100644 index 00000000..5e20777d --- /dev/null +++ b/tests/cli/screens/config/ConfigEditScreen.test.tsx @@ -0,0 +1,198 @@ +/** + * ConfigEditScreen tests. + * + * Proves the rename-path delete is wired to pass a `SettingsProvider` into + * `StateManager.deleteConfig` — belt-and-suspenders with the core-seam guard + * (`assertCanDeleteConfig`), which is what actually enforces the locked-stage + * block and is already covered by iteration 1's `state/manager.test.ts`. + * + * A full render assertion of the surfaced error text was tried and dropped: + * the Form's fixed-height `overflowY="hidden"` container (10 fields at the + * 24-row ink-testing-library default terminal) clips the bottom status-error + * row before it reaches `lastFrame()`, making a text assertion flaky/false- + * negative independent of the wiring. Spying on the mock call args is + * deterministic and still proves the wiring is load-bearing: revert the + * `settingsProvider` argument and this test goes red. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { RouterProvider } from '../../../../src/tui/router.js'; +import { AppContextProvider } from '../../../../src/tui/app-context.js'; +import { ToastProvider } from '../../../../src/tui/components/index.js'; +import { ConfigEditScreen } from '../../../../src/tui/screens/config/ConfigEditScreen.js'; +import { SettingsProvider } from '../../../../src/core/config/resolver.js'; + +// Pre-import actual modules for restoration +const actualCore = await import('../../../../src/core/index.js'); +const actualIdentity = await import('../../../../src/core/identity/index.js'); +const actualConnectionFactory = await import('../../../../src/core/connection/factory.js'); + +function makeConfig(name: string) { + + return { + name, + type: 'local' as const, + isTest: false, + access: { user: 'admin' as const, mcp: 'admin' as const }, + connection: { + dialect: 'postgres' as const, + host: 'localhost', + port: 5432, + database: `${name}_db`, + user: 'admin', + password: 'secret', + }, + }; + +} + +const createMockStateManager = (configName: string, config: ReturnType | null) => ({ + load: vi.fn().mockResolvedValue(undefined), + getActiveConfig: vi.fn().mockReturnValue(null), + getActiveConfigName: vi.fn().mockReturnValue(null), + listConfigs: vi.fn().mockReturnValue([]), + getConfig: vi.fn((name: string) => (name === configName ? config : null)), + deleteConfig: vi.fn().mockResolvedValue(undefined), + setConfig: vi.fn().mockResolvedValue(undefined), + setActiveConfig: vi.fn().mockResolvedValue(undefined), + hasPrivateKey: vi.fn().mockReturnValue(true), + isLoaded: true, +}); + +const createMockSettingsManager = (stages: Record) => ({ + load: vi.fn().mockResolvedValue({ version: '0.1.0' }), + isLoaded: true, + settings: { version: '0.1.0' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn((name: string) => stages[name]), +}); + +// Mutable so each test can swap in its own fixture; the mock.module factory +// closures read these at call time (matches init-flow.test.tsx). +let mockStateManager = createMockStateManager('prod', makeConfig('prod')); +let mockSettingsManager = createMockSettingsManager({ prod: { locked: true } }); + +mock.module('../../../../src/core/index.js', () => ({ + observer: actualCore.observer, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +mock.module('../../../../src/core/connection/factory.js', () => ({ + ...actualConnectionFactory, + testConnection: vi.fn().mockResolvedValue({ ok: true }), +})); + +function TestWrapper({ children }: { children: React.ReactNode }) { + + return ( + + + + {children} + + + + ); + +} + +describe('cli: ConfigEditScreen', () => { + + beforeEach(() => { + + vi.clearAllMocks(); + actualCore.observer.clear(); + + }); + + afterEach(() => { + + actualCore.observer.clear(); + + }); + + afterAll(() => { + + mock.module('../../../../src/core/index.js', () => actualCore); + mock.module('../../../../src/core/identity/index.js', () => actualIdentity); + mock.module('../../../../src/core/connection/factory.js', () => actualConnectionFactory); + + }); + + it('should not fire a "hooks order changed" warning across the async-load boundary', async () => { + + mockStateManager = createMockStateManager('prod', makeConfig('prod')); + mockSettingsManager = createMockSettingsManager({ prod: { locked: true } }); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = render( + + + , + ); + + // AppContextProvider's autoLoad kicks off async stateManager/config + // resolution; the initial render (config unresolved) takes the + // early-return branch, then a later render (config resolved) reaches + // the bottom of the component - the exact transition that changes + // hook count if useStdout is called after the returns. + await new Promise((r) => setTimeout(r, 200)); + + const hooksOrderWarning = consoleErrorSpy.mock.calls.some( + (call) => typeof call[0] === 'string' && call[0].includes('change in the order of Hooks'), + ); + + expect(hooksOrderWarning).toBe(false); + + consoleErrorSpy.mockRestore(); + unmount(); + + }); + + it('should pass a SettingsProvider into the rename-path deleteConfig call', async () => { + + mockStateManager = createMockStateManager('prod', makeConfig('prod')); + mockSettingsManager = createMockSettingsManager({ prod: { locked: true } }); + + const { stdin, unmount } = render( + + + , + ); + + await new Promise((r) => setTimeout(r, 150)); + + // Rename "prod" -> "prod2" (name field is active by default) and + // submit via Enter, which TextInput routes straight to handleSubmit. + stdin.write('2'); + await new Promise((r) => setTimeout(r, 50)); + stdin.write('\r'); + + await new Promise((r) => setTimeout(r, 200)); + + expect(mockStateManager.deleteConfig).toHaveBeenCalledTimes(1); + + const [deletedName, settingsProviderArg] = mockStateManager.deleteConfig.mock.calls[0] as [ + string, + unknown, + ]; + + expect(deletedName).toBe('prod'); + expect(settingsProviderArg).toBeInstanceOf(SettingsProvider); + + unmount(); + + }); + +}); diff --git a/tests/cli/screens/config/ConfigRemoveScreen.test.tsx b/tests/cli/screens/config/ConfigRemoveScreen.test.tsx new file mode 100644 index 00000000..42003a72 --- /dev/null +++ b/tests/cli/screens/config/ConfigRemoveScreen.test.tsx @@ -0,0 +1,166 @@ +/** + * ConfigRemoveScreen tests. + * + * Verifies the locked-stage guard surfaces as a blocked panel naming the + * locking stage, and that an unlocked-stage config still deletes normally + * (no over-blocking). + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { RouterProvider } from '../../../../src/tui/router.js'; +import { AppContextProvider } from '../../../../src/tui/app-context.js'; +import { ToastProvider } from '../../../../src/tui/components/index.js'; +import { ConfigRemoveScreen } from '../../../../src/tui/screens/config/ConfigRemoveScreen.js'; + +// Pre-import actual modules for restoration +const actualCore = await import('../../../../src/core/index.js'); +const actualIdentity = await import('../../../../src/core/identity/index.js'); + +/** + * Builds a config fixture with an `access` role that always passes the + * `config:rm` policy check (admin), so tests exercise the lock guard in + * isolation from the policy-denied branch. + */ +function makeConfig(name: string) { + + return { + name, + type: 'local' as const, + isTest: false, + access: { user: 'admin' as const, mcp: 'admin' as const }, + connection: { + dialect: 'postgres' as const, + host: 'localhost', + port: 5432, + database: `${name}_db`, + }, + }; + +} + +const createMockStateManager = (configName: string, config: ReturnType | null) => ({ + load: vi.fn().mockResolvedValue(undefined), + getActiveConfig: vi.fn().mockReturnValue(null), + getActiveConfigName: vi.fn().mockReturnValue(null), + listConfigs: vi.fn().mockReturnValue([]), + getConfig: vi.fn((name: string) => (name === configName ? config : null)), + deleteConfig: vi.fn().mockResolvedValue(undefined), + setActiveConfig: vi.fn().mockResolvedValue(undefined), + hasPrivateKey: vi.fn().mockReturnValue(true), + isLoaded: true, +}); + +const createMockSettingsManager = (stages: Record) => ({ + load: vi.fn().mockResolvedValue({ version: '0.1.0' }), + isLoaded: true, + settings: { version: '0.1.0' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn((name: string) => stages[name]), +}); + +// Mutable so each test can swap in its own fixture; the mock.module +// factory closures read these at call time (matches init-flow.test.tsx). +let mockStateManager = createMockStateManager('prod', makeConfig('prod')); +let mockSettingsManager = createMockSettingsManager({}); + +mock.module('../../../../src/core/index.js', () => ({ + observer: actualCore.observer, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +function TestWrapper({ children }: { children: React.ReactNode }) { + + return ( + + + + {children} + + + + ); + +} + +describe('cli: ConfigRemoveScreen', () => { + + beforeEach(() => { + + vi.clearAllMocks(); + actualCore.observer.clear(); + // Skips confirm-phrase prompts so tests land on the plain "Are you + // sure" branch instead of ProtectedConfirm's type-to-confirm UI. + process.env['NOORM_YES'] = '1'; + + }); + + afterEach(() => { + + actualCore.observer.clear(); + delete process.env['NOORM_YES']; + + }); + + afterAll(() => { + + mock.module('../../../../src/core/index.js', () => actualCore); + mock.module('../../../../src/core/identity/index.js', () => actualIdentity); + + }); + + it('should render a blocked panel naming the locking stage for a locked-stage config', async () => { + + mockStateManager = createMockStateManager('prod', makeConfig('prod')); + mockSettingsManager = createMockSettingsManager({ prod: { locked: true } }); + + const { lastFrame, unmount } = render( + + + , + ); + + await new Promise((r) => setTimeout(r, 50)); + + const frame = lastFrame(); + + expect(frame).toContain('locked stage'); + expect(frame).toContain('prod'); + expect(frame).not.toContain('Are you sure'); + + unmount(); + + }); + + it('should render the normal confirmation for an unlocked-stage config', async () => { + + mockStateManager = createMockStateManager('dev', makeConfig('dev')); + mockSettingsManager = createMockSettingsManager({ dev: { locked: false } }); + + const { lastFrame, unmount } = render( + + + , + ); + + await new Promise((r) => setTimeout(r, 50)); + + const frame = lastFrame(); + + expect(frame).toContain('Are you sure'); + expect(frame).not.toContain('locked stage'); + + unmount(); + + }); + +}); diff --git a/tests/cli/settings-validation.test.ts b/tests/cli/settings-validation.test.ts new file mode 100644 index 00000000..ca10b089 --- /dev/null +++ b/tests/cli/settings-validation.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'bun:test'; + +import { validateStagePort } from '../../src/tui/utils/settings-validation.js'; + +describe('settings-validation: validateStagePort', () => { + + it('accepts an empty/undefined value as unset (optional field)', () => { + + expect(validateStagePort(undefined)).toBeUndefined(); + expect(validateStagePort('')).toBeUndefined(); + + }); + + it('rejects non-numeric input', () => { + + expect(validateStagePort('abc')).not.toBeUndefined(); + + }); + + it('rejects out-of-range ports at both boundaries', () => { + + expect(validateStagePort('0')).not.toBeUndefined(); + expect(validateStagePort('65536')).not.toBeUndefined(); + + }); + + it('accepts in-range ports at both boundaries', () => { + + expect(validateStagePort('1')).toBeUndefined(); + expect(validateStagePort('65535')).toBeUndefined(); + + }); + +}); diff --git a/tests/core/change/executor-retry.test.ts b/tests/core/change/executor-retry.test.ts new file mode 100644 index 00000000..918fe550 --- /dev/null +++ b/tests/core/change/executor-retry.test.ts @@ -0,0 +1,287 @@ +/** + * Change executor retry tests. + * + * Verifies per-file skip on retry (CP1 of v1-17-change-retry): a change + * that fails partway through can be fixed and retried without re-running + * files that already succeeded. New file (not executor.test.ts) to avoid + * a merge collision with tickets 08/34's parallel worktrees. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { Kysely, SqliteDialect } from 'kysely'; +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; + +import { executeChange } from '../../../src/core/change/executor.js'; +import { ChangeHistory } from '../../../src/core/change/history.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { resetLockManager } from '../../../src/core/lock/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { Change, ChangeContext } from '../../../src/core/change/types.js'; + +describe('change: executor retry', () => { + + let db: Kysely; + let tempDir: string; + let changesDir: string; + let sqlDir: string; + + const testIdentity = { name: 'Test User', email: 'test@example.com', source: 'config' as const }; + + /** + * Create a test change on disk. + */ + async function createTestChange( + name: string, + files: Array<{ name: string; content: string }>, + ): Promise { + + const changePath = join(changesDir, name); + const changeFilesDir = join(changePath, 'change'); + await mkdir(changeFilesDir, { recursive: true }); + + const changeFiles = []; + + for (const file of files) { + + const filePath = join(changeFilesDir, file.name); + await writeFile(filePath, file.content); + + changeFiles.push({ + filename: file.name, + path: filePath, + type: 'sql' as const, + }); + + } + + return { + name, + path: changePath, + date: null, + description: name, + changeFiles, + revertFiles: [], + hasChangelog: false, + }; + + } + + /** + * Build a test context. + */ + function buildContext(): ChangeContext { + + return { + db, + configName: 'test', + identity: testIdentity, + projectRoot: tempDir, + changesDir, + sqlDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'sqlite', + }; + + } + + beforeEach(async () => { + + // Reset singleton lock manager between tests + resetLockManager(); + + // Create temp directory for test fixtures + tempDir = await mkdtemp(join(tmpdir(), 'noorm-executor-retry-test-')); + changesDir = join(tempDir, 'changes'); + sqlDir = join(tempDir, 'sql'); + + await mkdir(changesDir, { recursive: true }); + await mkdir(sqlDir, { recursive: true }); + + // Create in-memory SQLite database using Kysely directly + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + // Bootstrap the noorm tracking tables + await v1.up(db as Kysely, 'sqlite'); + + }); + + afterEach(async () => { + + // Reset lock manager + resetLockManager(); + + // Clean up database connection + await db.destroy(); + + // Clean up temp directory + await rm(tempDir, { recursive: true, force: true }); + + }); + + it('should execute only the fixed file on retry, leaving the succeeded file untouched', async () => { + + // File A always succeeds. File B has a syntactically invalid + // statement (misspelled keyword) so it fails at parse time — + // NOT a duplicate-CREATE-TABLE constraint violation, which is + // documented as flaky in bun:sqlite on CI (see executor.test.ts). + const change = await createTestChange('retry-fix-and-rerun', [ + { name: '001_a.sql', content: 'CREATE TABLE retry_test_a (id INTEGER PRIMARY KEY)' }, + { name: '002_b.sql', content: 'CREATE TALBE retry_test_b (id INTEGER PRIMARY KEY)' }, + ]); + + const context = buildContext(); + + // First attempt: A succeeds, B fails with a syntax error + const result1 = await executeChange(context, change); + + expect(result1.status).toBe('failed'); + expect(result1.files).toHaveLength(2); + expect(result1.files[0]?.status).toBe('success'); + expect(result1.files[1]?.status).toBe('failed'); + + // Fix B on disk + const fileB = change.changeFiles[1]!; + await writeFile(fileB.path, 'CREATE TABLE retry_test_b (id INTEGER PRIMARY KEY)'); + + // Retry: only B should actually execute; A is skipped + const result2 = await executeChange(context, change); + + expect(result2.status).toBe('success'); + expect(result2.files).toHaveLength(2); + + const fileA = change.changeFiles[0]!; + const aResult = result2.files.find((f) => f.filepath === fileA.path); + const bResult = result2.files.find((f) => f.filepath === fileB.path); + + expect(aResult?.status).toBe('skipped'); + expect(bResult?.status).toBe('success'); + + // A's SQL was never re-submitted: exactly one success row for A + // across both operations (op1: success, op2: skipped). + const relA = relative(tempDir, fileA.path); + const aExecutions = await db + .selectFrom('__noorm_executions__') + .selectAll() + .where('filepath', '=', relA) + .orderBy('id', 'asc') + .execute(); + + expect(aExecutions).toHaveLength(2); + expect(aExecutions[0]?.status).toBe('success'); + expect(aExecutions[1]?.status).toBe('skipped'); + + // Cross-check via ChangeHistory's own query surface + const history = new ChangeHistory(db, 'test'); + + const op1Files = await history.getFileHistory(result1.operationId!); + expect(op1Files.find((f) => f.filepath === relA)?.status).toBe('success'); + + const op2Files = await history.getFileHistory(result2.operationId!); + expect(op2Files.find((f) => f.filepath === relA)?.status).toBe('skipped'); + expect(op2Files.find((f) => f.filepath === relative(tempDir, fileB.path))?.status).toBe('success'); + + const statuses = await history.getAllStatuses(); + expect(statuses.get('retry-fix-and-rerun')?.status).toBe('success'); + + }); + + it('should keep a succeeded file skipped across a THIRD attempt, not just a second', async () => { + + // File A always succeeds. File B fails on attempts 1 and 2 (syntax + // error, not fixed yet), then is fixed before attempt 3. This + // reproduces the third-attempt regression: needsRunFile must not + // find attempt 2's `skipped` row for A (written by the per-file + // skip path) and mistake it for a "never reached" skip that needs + // re-running — A's real success is two operations back. + const change = await createTestChange('retry-three-attempts', [ + { name: '001_a.sql', content: 'CREATE TABLE retry3_test_a (id INTEGER PRIMARY KEY)' }, + { name: '002_b.sql', content: 'CREATE TALBE retry3_test_b (id INTEGER PRIMARY KEY)' }, + ]); + + const context = buildContext(); + const fileB = change.changeFiles[1]!; + + // op1: A succeeds, B fails (syntax error) + const result1 = await executeChange(context, change); + expect(result1.status).toBe('failed'); + expect(result1.files[0]?.status).toBe('success'); + expect(result1.files[1]?.status).toBe('failed'); + + // op2: retry with B still broken on disk — A must be skipped, B fails again + const result2 = await executeChange(context, change); + expect(result2.status).toBe('failed'); + expect(result2.files[0]?.status).toBe('skipped'); + expect(result2.files[1]?.status).toBe('failed'); + + // Fix B on disk + await writeFile(fileB.path, 'CREATE TABLE retry3_test_b (id INTEGER PRIMARY KEY)'); + + // op3: retry — A must STILL be skipped (its success is two + // operations back), B now succeeds + const result3 = await executeChange(context, change); + expect(result3.status).toBe('success'); + expect(result3.files[0]?.status).toBe('skipped'); + expect(result3.files[1]?.status).toBe('success'); + + // A's SQL was submitted exactly once across all three attempts: + // one success row, and every other row skipped. On the buggy + // code, op3 re-runs A (finding op2's ambiguous `skipped` row), + // producing a second success (or a failure) instead. + const fileA = change.changeFiles[0]!; + const relA = relative(tempDir, fileA.path); + const aExecutions = await db + .selectFrom('__noorm_executions__') + .selectAll() + .where('filepath', '=', relA) + .orderBy('id', 'asc') + .execute(); + + expect(aExecutions).toHaveLength(3); + expect(aExecutions.filter((e) => e.status === 'success')).toHaveLength(1); + expect(aExecutions[0]?.status).toBe('success'); + expect(aExecutions[1]?.status).toBe('skipped'); + expect(aExecutions[2]?.status).toBe('skipped'); + + }); + + it('should re-run every file when force is true, bypassing per-file skip', async () => { + + // Idempotent statement — safe to execute twice without a + // constraint violation, so the assertion doesn't depend on the + // documented bun:sqlite error-propagation flakiness. + const change = await createTestChange('retry-force', [ + { name: '001_idempotent.sql', content: 'CREATE TABLE IF NOT EXISTS retry_force_test (id INTEGER PRIMARY KEY)' }, + ]); + + const context = buildContext(); + + // First run: succeeds, records a success row + const result1 = await executeChange(context, change); + expect(result1.status).toBe('success'); + expect(result1.files[0]?.status).toBe('success'); + + // Second run without force: change-level needsRun already skips + // the whole change (checksum unchanged, status success) before + // executeFiles is even called + const result2 = await executeChange(context, change); + expect(result2.status).toBe('success'); + expect(result2.files).toHaveLength(0); + + // Third run with force: per-file skip must be bypassed even + // though file A has a prior success record with a matching + // checksum — force short-circuits needsRunFile before any DB + // lookup, so the file actually re-runs (not 'skipped') + const result3 = await executeChange(context, change, { force: true }); + expect(result3.files).toHaveLength(1); + expect(result3.files[0]?.status).toBe('success'); + + }); + +}); diff --git a/tests/core/change/history.test.ts b/tests/core/change/history.test.ts new file mode 100644 index 00000000..e0b4973b --- /dev/null +++ b/tests/core/change/history.test.ts @@ -0,0 +1,176 @@ +/** + * Change history tests. + * + * Pins Date hydration at the history-adapter boundary (`hydrateDate`) and + * verifies the real in-memory SQLite driver returns hydrated `Date` + * instances end to end, mirroring the executor test harness (see + * tests/core/change/executor.test.ts). + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, SqliteDialect } from 'kysely'; +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; + +import { ChangeHistory, hydrateDate } from '../../../src/core/change/history.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { resetLockManager } from '../../../src/core/lock/index.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; + +describe('change: history — hydrateDate', () => { + + // Pin a non-UTC zone for this block: on a UTC-TZ host (e.g. CI runners, + // which default to TZ=UTC), a naive `new Date(rawString)` regression + // would produce the SAME output as the correct UTC-aware parse, since + // there is no offset to shift by -- so the UTC-correctness assertion + // below would pass even with the bug reintroduced. Forcing a non-UTC + // offset makes the test fail deterministically on any host. + let originalTz: string | undefined; + + beforeAll(() => { + + originalTz = process.env.TZ; + process.env.TZ = 'America/New_York'; + + }); + + afterAll(() => { + + if (originalTz === undefined) { + + delete process.env.TZ; + + } + else { + + process.env.TZ = originalTz; + + } + + }); + + it('should parse a SQLite raw string as UTC, not local time', () => { + + // The regression this test must catch: a naive new Date(rawString) + // parses SQLite offset-less CURRENT_TIMESTAMP text as local time, + // silently shifting the result by the host UTC offset. Empirically + // verified pair from the spec (host TZ America/New_York, -240min). + const hydrated = hydrateDate('2026-07-12 09:02:59'); + + expect(hydrated).toBeInstanceOf(Date); + expect(hydrated?.toISOString()).toBe('2026-07-12T09:02:59.000Z'); + + }); + + it('should pass a Date through unchanged (pg/mysql/mssql shape)', () => { + + const original = new Date('2026-07-12T09:02:59.000Z'); + const hydrated = hydrateDate(original); + + expect(hydrated).toBe(original); + + }); + + it('should return null for null input', () => { + + expect(hydrateDate(null)).toBeNull(); + + }); + + it('should return null for undefined input', () => { + + expect(hydrateDate(undefined)).toBeNull(); + + }); + +}); + +describe('change: history — real SQLite driver hydration', () => { + + let db: Kysely; + let tempDir: string; + + beforeEach(async () => { + + resetLockManager(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-history-test-')); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + }); + + afterEach(async () => { + + resetLockManager(); + + await db.destroy(); + + await rm(tempDir, { recursive: true, force: true }); + + }); + + /** + * Run and finalize a single successful operation against the real + * in-memory bun:sqlite driver. + */ + async function recordOperation(history: ChangeHistory, name: string): Promise { + + const operationId = await history.createOperation({ + name, + direction: 'change', + executedBy: 'test@example.com', + }); + + const err = await history.finalizeOperation(operationId, 'success', 'checksum', 1); + + expect(err).toBeNull(); + + } + + it('should return Date instances from getStatus, getAllStatuses, getHistory, and getUnifiedHistory', async () => { + + const history = new ChangeHistory(db, 'test', 'sqlite'); + + await recordOperation(history, 'change-one'); + await recordOperation(history, 'change-two'); + + const status = await history.getStatus('change-one'); + + expect(status?.appliedAt).toBeInstanceOf(Date); + + const allStatuses = await history.getAllStatuses(); + + expect(allStatuses.get('change-one')?.appliedAt).toBeInstanceOf(Date); + expect(allStatuses.get('change-two')?.appliedAt).toBeInstanceOf(Date); + + const records = await history.getHistory(); + + expect(records.length).toBeGreaterThan(0); + + for (const record of records) { + + expect(record.executedAt).toBeInstanceOf(Date); + + } + + const unified = await history.getUnifiedHistory(); + + expect(unified.length).toBeGreaterThan(0); + + for (const record of unified) { + + expect(record.executedAt).toBeInstanceOf(Date); + + } + + }); + +}); diff --git a/tests/core/change/manager.test.ts b/tests/core/change/manager.test.ts new file mode 100644 index 00000000..dff1fcbd --- /dev/null +++ b/tests/core/change/manager.test.ts @@ -0,0 +1,415 @@ +/** + * Change manager tests. + * + * Pins `ChangeManager`'s public API directly against a real in-memory + * SQLite database, matching `tests/core/change/executor.test.ts`'s harness + * pattern. Covers revert, rewind, next, and remove — the highest-risk + * batch/mutation surface per QL-test-04's "at minimum" framing. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, SqliteDialect, sql } from 'kysely'; +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; + +import { ChangeManager } from '../../../src/core/change/manager.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { resetLockManager } from '../../../src/core/lock/index.js'; +import { ChangeNotAppliedError } from '../../../src/core/change/types.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { ChangeContext } from '../../../src/core/change/types.js'; + +describe('change: manager', () => { + + let db: Kysely; + let tempDir: string; + let changesDir: string; + let sqlDir: string; + + const testIdentity = { name: 'Test User', email: 'test@example.com', source: 'config' as const }; + + /** + * Create a test change on disk with both change/ and revert/ folders, + * so `manager.run`/`manager.revert` can load it by name via parseChange. + */ + async function createTestChange( + name: string, + changeFiles: Array<{ name: string; content: string }>, + revertFiles: Array<{ name: string; content: string }> = [], + ): Promise { + + const changePath = join(changesDir, name); + const changeFilesDir = join(changePath, 'change'); + const revertFilesDir = join(changePath, 'revert'); + + await mkdir(changeFilesDir, { recursive: true }); + await mkdir(revertFilesDir, { recursive: true }); + + for (const file of changeFiles) { + + await writeFile(join(changeFilesDir, file.name), file.content); + + } + + for (const file of revertFiles) { + + await writeFile(join(revertFilesDir, file.name), file.content); + + } + + } + + /** + * Build a test context. + */ + function buildContext(): ChangeContext { + + return { + db, + configName: 'test', + identity: testIdentity, + projectRoot: tempDir, + changesDir, + sqlDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'sqlite', + }; + + } + + beforeEach(async () => { + + resetLockManager(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-manager-test-')); + changesDir = join(tempDir, 'changes'); + sqlDir = join(tempDir, 'sql'); + + await mkdir(changesDir, { recursive: true }); + await mkdir(sqlDir, { recursive: true }); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + }); + + afterEach(async () => { + + resetLockManager(); + + await db.destroy(); + + await rm(tempDir, { recursive: true, force: true }); + + }); + + describe('revert', () => { + + it('should execute the revert SQL against the DB and flip history to reverted', async () => { + + await createTestChange( + 'revert-drops-table', + [{ name: '001_create.sql', content: 'CREATE TABLE revert_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE revert_target' }], + ); + + const manager = new ChangeManager(buildContext()); + + const runResult = await manager.run('revert-drops-table'); + expect(runResult.status).toBe('success'); + + const revertResult = await manager.revert('revert-drops-table'); + + expect(revertResult.status).toBe('success'); + expect(revertResult.files.length).toBeGreaterThan(0); + + // The revert SQL actually ran, not just recorded as successful. + const tableRows = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'revert_target' + `.execute(db); + + expect(tableRows.rows).toHaveLength(0); + + const history = await manager.getHistory('revert-drops-table'); + const changeRecord = history.find((h) => h.direction === 'change'); + const revertRecord = history.find((h) => h.direction === 'revert'); + + expect(changeRecord?.status).toBe('reverted'); + expect(revertRecord?.status).toBe('success'); + + }); + + it('should skip (not error) when reverting an already-reverted change', async () => { + + await createTestChange( + 'double-revert', + [{ name: '001_create.sql', content: 'CREATE TABLE double_revert_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE double_revert_target' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('double-revert'); + + const first = await manager.revert('double-revert'); + expect(first.status).toBe('success'); + + const second = await manager.revert('double-revert'); + + expect(second).toMatchObject({ status: 'success', files: [] }); + + }); + + it('should throw ChangeNotAppliedError when reverting a change that was never applied', async () => { + + await createTestChange( + 'never-applied', + [{ name: '001_create.sql', content: 'CREATE TABLE never_applied_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE never_applied_target' }], + ); + + const manager = new ChangeManager(buildContext()); + + await expect(manager.revert('never-applied')).rejects.toThrow(ChangeNotAppliedError); + + }); + + }); + + describe('rewind', () => { + + it('should revert the single applied change with rewind(1)', async () => { + + await createTestChange( + 'rewind-one', + [{ name: '001_create.sql', content: 'CREATE TABLE rewind_one_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE rewind_one_target' }], + ); + + const manager = new ChangeManager(buildContext()); + await manager.run('rewind-one'); + + const result = await manager.rewind(1); + + expect(result.status).toBe('success'); + expect(result.executed).toBe(1); + + }); + + it('should revert the single applied change with rewind(name)', async () => { + + await createTestChange( + 'rewind-by-name', + [{ name: '001_create.sql', content: 'CREATE TABLE rewind_name_target (id INTEGER PRIMARY KEY)' }], + [{ name: '001_drop.sql', content: 'DROP TABLE rewind_name_target' }], + ); + + const manager = new ChangeManager(buildContext()); + await manager.run('rewind-by-name'); + + const result = await manager.rewind('rewind-by-name'); + + expect(result.status).toBe('success'); + expect(result.executed).toBe(1); + + }); + + it('should return the not-found failure when rewind(name) matches no applied change', async () => { + + await createTestChange('applied-change', [ + { name: '001.sql', content: 'CREATE TABLE not_found_target (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + await manager.run('applied-change'); + + const result = await manager.rewind('does-not-exist'); + + expect(result).toMatchObject({ status: 'failed', failed: 1, changes: [] }); + + }); + + it('should be a no-op when there are 0 applied changes', async () => { + + const manager = new ChangeManager(buildContext()); + + const result = await manager.rewind(1); + + expect(result).toMatchObject({ status: 'success', executed: 0, changes: [] }); + + }); + + it('should revert the two most-recently-applied changes in order with rewind(2)', async () => { + + await createTestChange( + '2025-01-01-first', + [{ name: '001.sql', content: 'CREATE TABLE rewind2_first (id INTEGER PRIMARY KEY)' }], + [{ name: '001.sql', content: 'DROP TABLE rewind2_first' }], + ); + await createTestChange( + '2025-01-02-second', + [{ name: '001.sql', content: 'CREATE TABLE rewind2_second (id INTEGER PRIMARY KEY)' }], + [{ name: '001.sql', content: 'DROP TABLE rewind2_second' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('2025-01-01-first'); + await manager.run('2025-01-02-second'); + + const result = await manager.rewind(2); + + expect(result.status).toBe('success'); + expect(result.executed).toBe(2); + expect(result.changes[0]?.name).toBe('2025-01-02-second'); + expect(result.changes[1]?.name).toBe('2025-01-01-first'); + + }); + + it('should revert until and including the older of two applied changes with rewind(name)', async () => { + + await createTestChange( + '2025-01-01-first', + [{ name: '001.sql', content: 'CREATE TABLE rewind_older_first (id INTEGER PRIMARY KEY)' }], + [{ name: '001.sql', content: 'DROP TABLE rewind_older_first' }], + ); + await createTestChange( + '2025-01-02-second', + [{ name: '001.sql', content: 'CREATE TABLE rewind_older_second (id INTEGER PRIMARY KEY)' }], + [{ name: '001.sql', content: 'DROP TABLE rewind_older_second' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('2025-01-01-first'); + await manager.run('2025-01-02-second'); + + const result = await manager.rewind('2025-01-01-first'); + + expect(result.status).toBe('success'); + expect(result.executed).toBe(2); + + const list = await manager.list(); + const byName = new Map(list.map((cs) => [cs.name, cs.status])); + + expect(byName.get('2025-01-01-first')).toBe('reverted'); + expect(byName.get('2025-01-02-second')).toBe('reverted'); + + }); + + // Two changes applied back-to-back land the same second-precision + // `executed_at` (SQLite CURRENT_TIMESTAMP default) — a routine tie, + // not an edge case (e.g. `change ff` applying several pending changes + // in one process tick). Only the history table's autoincrement id + // records true apply order, so rewind's sort must break ties on it + // descending (highest id = most recently applied = reverted first). + // Applying in name order makes list()'s name-sorted array equal the + // chronological apply order forward, while the correct revert order + // is that array reversed -- exactly what a missing tiebreak gets + // wrong (stable sort keeps forward/name order on a tie). + it('should revert tied appliedAt changes in id-descending apply order with rewind(2)', async () => { + + await createTestChange( + '2025-02-01-first', + [{ name: '001.sql', content: 'CREATE TABLE rewind_tie_first (id INTEGER PRIMARY KEY)' }], + [{ name: '001.sql', content: 'DROP TABLE rewind_tie_first' }], + ); + await createTestChange( + '2025-02-02-second', + [{ name: '001.sql', content: 'CREATE TABLE rewind_tie_second (id INTEGER PRIMARY KEY)' }], + [{ name: '001.sql', content: 'DROP TABLE rewind_tie_second' }], + ); + + const manager = new ChangeManager(buildContext()); + + await manager.run('2025-02-01-first'); + await manager.run('2025-02-02-second'); + + const result = await manager.rewind(2); + + expect(result.status).toBe('success'); + expect(result.executed).toBe(2); + expect(result.changes[0].name).toBe('2025-02-02-second'); + expect(result.changes[1].name).toBe('2025-02-01-first'); + + }); + + }); + + describe('next', () => { + + it('should apply exactly `count` pending changes in order, leaving the rest pending', async () => { + + await createTestChange('2025-01-01-first', [ + { name: '001.sql', content: 'CREATE TABLE next_first (id INTEGER PRIMARY KEY)' }, + ]); + await createTestChange('2025-01-02-second', [ + { name: '001.sql', content: 'CREATE TABLE next_second (id INTEGER PRIMARY KEY)' }, + ]); + await createTestChange('2025-01-03-third', [ + { name: '001.sql', content: 'CREATE TABLE next_third (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + + const result = await manager.next(2); + + expect(result.status).toBe('success'); + expect(result.executed).toBe(2); + + const list = await manager.list(); + const byName = new Map(list.map((cs) => [cs.name, cs.status])); + + expect(byName.get('2025-01-01-first')).toBe('success'); + expect(byName.get('2025-01-02-second')).toBe('success'); + expect(byName.get('2025-01-03-third')).toBe('pending'); + + }); + + }); + + describe('remove', () => { + + it('should delete both disk and db records when remove({disk: true, db: true})', async () => { + + await createTestChange('remove-both', [ + { name: '001.sql', content: 'CREATE TABLE remove_both_target (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + await manager.run('remove-both'); + + await manager.remove('remove-both', { disk: true, db: true }); + + expect(existsSync(join(changesDir, 'remove-both'))).toBe(false); + expect(await manager.getHistory('remove-both')).toEqual([]); + + }); + + it('should delete only db records when remove({db: true}), leaving disk untouched', async () => { + + await createTestChange('remove-db-only', [ + { name: '001.sql', content: 'CREATE TABLE remove_db_only_target (id INTEGER PRIMARY KEY)' }, + ]); + + const manager = new ChangeManager(buildContext()); + await manager.run('remove-db-only'); + + await manager.remove('remove-db-only', { db: true }); + + expect(existsSync(join(changesDir, 'remove-db-only'))).toBe(true); + expect(await manager.getHistory('remove-db-only')).toEqual([]); + + }); + + }); + +}); diff --git a/tests/core/change/scaffold.test.ts b/tests/core/change/scaffold.test.ts index db2fe272..58e6f12b 100644 --- a/tests/core/change/scaffold.test.ts +++ b/tests/core/change/scaffold.test.ts @@ -7,6 +7,10 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; import path from 'node:path'; import { mkdir, rm, readdir, readFile, stat } from 'node:fs/promises'; + +import { attempt } from '@logosdx/utils'; +import { Kysely, SqliteDialect } from 'kysely'; + import { createChange, addFile, @@ -14,7 +18,13 @@ import { renameFile, deleteChange, } from '../../../src/core/change/scaffold.js'; +import { parseChange } from '../../../src/core/change/parser.js'; +import { executeChange } from '../../../src/core/change/executor.js'; import { ChangeValidationError } from '../../../src/core/change/types.js'; +import type { ChangeContext } from '../../../src/core/change/types.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; const TMP_DIR = path.join(process.cwd(), 'tmp/change-scaffold-test'); @@ -54,14 +64,46 @@ describe('change: scaffold', () => { expect(result.description).toBe('add-users'); expect(result.date).toBeInstanceOf(Date); - expect(result.changeFiles).toHaveLength(0); - expect(result.revertFiles).toHaveLength(0); + + // #53: an empty change/revert pair is worse than inert - parseChange + // rejects it and the caller sees "change not found" instead of the + // real problem (needs editing). createChange now scaffolds a stub in + // each folder so the changeset is runnable (once edited) from the start. + expect(result.changeFiles).toHaveLength(1); + expect(result.revertFiles).toHaveLength(1); + expect(result.changeFiles[0]?.filename).toBe('001_add-users.sql'); + expect(result.revertFiles[0]?.filename).toBe('001_add-users.sql'); + expect(result.changeFiles[0]?.filename).toMatch(/^(\d{3})_(.+)\.sql$/); // Verify folder structure const entries = await readdir(result.path); expect(entries).toContain('change'); expect(entries).toContain('revert'); + const changeEntries = await readdir(path.join(result.path, 'change')); + const revertEntries = await readdir(path.join(result.path, 'revert')); + expect(changeEntries).toContain('001_add-users.sql'); + expect(revertEntries).toContain('001_add-users.sql'); + + // Stub content names what belongs in each file, and differs between + // change/ and revert/ so it isn't ambiguous which folder is which. + const changeContent = await readFile(result.changeFiles[0]!.path, 'utf-8'); + const revertContent = await readFile(result.revertFiles[0]!.path, 'utf-8'); + expect(changeContent).toContain('apply this change'); + expect(revertContent).toContain('undo this change'); + expect(changeContent).not.toBe(revertContent); + + }); + + it('should scaffold a change that parseChange loads instead of rejecting', async () => { + + const result = await createChange(createDir, { description: 'add-users' }); + + const parsed = await parseChange(result.path); + + expect(parsed.changeFiles).toHaveLength(1); + expect(parsed.revertFiles).toHaveLength(1); + }); it('should create change with custom date', async () => { @@ -110,6 +152,62 @@ describe('change: scaffold', () => { }); + describe('createChange -> executeChange (issue #53)', () => { + + const runDir = path.join(TMP_DIR, 'add-then-run'); + + beforeEach(async () => { + + await mkdir(runDir, { recursive: true }); + + }); + + afterEach(async () => { + + await rm(runDir, { recursive: true, force: true }); + + }); + + it('should report empty-SQL, not "change not found", for a freshly added change', async () => { + + const created = await createChange(runDir, { description: 'add-users' }); + + // Mirrors #loadChange (src/core/change/manager.ts): parse the change + // straight off disk the way `change run` does. + const parsed = await parseChange(created.path); + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + const context: ChangeContext = { + db, + configName: 'test', + identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, + projectRoot: runDir, + changesDir: runDir, + sqlDir: runDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'sqlite', + }; + + const [, err] = await attempt(() => executeChange(context, parsed)); + + expect(err).toBeInstanceOf(ChangeValidationError); + expect(err?.message).toContain('empty or contain only template placeholders'); + expect(err?.message).not.toContain('not found'); + + await db.destroy(); + + }); + + }); + describe('addFile', () => { const addDir = path.join(TMP_DIR, 'add-file'); @@ -133,17 +231,19 @@ describe('change: scaffold', () => { it('should add SQL file to change/ folder', async () => { + // createChange already scaffolds 001_file-test.sql; addFile continues + // the sequence from there. const updated = await addFile(change, 'change', { name: 'create-users-table', type: 'sql', content: 'CREATE TABLE users (id INT);', }); - expect(updated.changeFiles).toHaveLength(1); - expect(updated.changeFiles[0]?.filename).toBe('001_create-users-table.sql'); + expect(updated.changeFiles).toHaveLength(2); + expect(updated.changeFiles[1]?.filename).toBe('002_create-users-table.sql'); // Verify file content - const content = await readFile(updated.changeFiles[0]!.path, 'utf-8'); + const content = await readFile(updated.changeFiles[1]!.path, 'utf-8'); expect(content).toBe('CREATE TABLE users (id INT);'); }); @@ -156,8 +256,8 @@ describe('change: scaffold', () => { content: 'DROP TABLE users;', }); - expect(updated.revertFiles).toHaveLength(1); - expect(updated.revertFiles[0]?.filename).toBe('001_drop-users-table.sql'); + expect(updated.revertFiles).toHaveLength(2); + expect(updated.revertFiles[1]?.filename).toBe('002_drop-users-table.sql'); }); @@ -178,9 +278,11 @@ describe('change: scaffold', () => { type: 'sql', }); - expect(updated.changeFiles[0]?.filename).toBe('001_first.sql'); - expect(updated.changeFiles[1]?.filename).toBe('002_second.sql'); - expect(updated.changeFiles[2]?.filename).toBe('003_third.sql'); + // Index 0 is the stub createChange scaffolds; addFile picks up at 002. + expect(updated.changeFiles[0]?.filename).toBe('001_file-test.sql'); + expect(updated.changeFiles[1]?.filename).toBe('002_first.sql'); + expect(updated.changeFiles[2]?.filename).toBe('003_second.sql'); + expect(updated.changeFiles[3]?.filename).toBe('004_third.sql'); }); @@ -192,10 +294,10 @@ describe('change: scaffold', () => { paths: ['tables/users.sql', 'views/active_users.sql'], }); - expect(updated.changeFiles[0]?.filename).toBe('001_schema-refs.txt'); - expect(updated.changeFiles[0]?.type).toBe('txt'); + expect(updated.changeFiles[1]?.filename).toBe('002_schema-refs.txt'); + expect(updated.changeFiles[1]?.type).toBe('txt'); - const content = await readFile(updated.changeFiles[0]!.path, 'utf-8'); + const content = await readFile(updated.changeFiles[1]!.path, 'utf-8'); expect(content).toBe('tables/users.sql\nviews/active_users.sql\n'); }); @@ -223,7 +325,7 @@ describe('change: scaffold', () => { date: new Date(2025, 2, 1), }); - // Add some files + // Add some files (001_remove-test.sql is createChange's scaffolded stub) change = await addFile(change, 'change', { name: 'first', type: 'sql' }); change = await addFile(change, 'change', { name: 'second', type: 'sql' }); change = await addFile(change, 'change', { name: 'third', type: 'sql' }); @@ -238,16 +340,16 @@ describe('change: scaffold', () => { it('should remove file from change', async () => { - const updated = await removeFile(change, 'change', '002_second.sql'); + const updated = await removeFile(change, 'change', '003_second.sql'); - expect(updated.changeFiles).toHaveLength(2); + expect(updated.changeFiles).toHaveLength(3); expect( - updated.changeFiles.find((f) => f.filename === '002_second.sql'), + updated.changeFiles.find((f) => f.filename === '003_second.sql'), ).toBeUndefined(); // Verify file is deleted const entries = await readdir(path.join(change.path, 'change')); - expect(entries).not.toContain('002_second.sql'); + expect(entries).not.toContain('003_second.sql'); }); @@ -274,6 +376,7 @@ describe('change: scaffold', () => { date: new Date(2025, 3, 1), }); + // 001_rename-test.sql is createChange's scaffolded stub; this lands at 002. change = await addFile(change, 'change', { name: 'old-name', type: 'sql', @@ -290,17 +393,18 @@ describe('change: scaffold', () => { it('should rename file preserving sequence number', async () => { - const updated = await renameFile(change, 'change', '001_old-name.sql', 'new-name'); + const updated = await renameFile(change, 'change', '002_old-name.sql', 'new-name'); - expect(updated.changeFiles[0]?.filename).toBe('001_new-name.sql'); + const renamed = updated.changeFiles.find((f) => f.filename === '002_new-name.sql'); + expect(renamed).toBeDefined(); // Verify file exists with new name const entries = await readdir(path.join(change.path, 'change')); - expect(entries).toContain('001_new-name.sql'); - expect(entries).not.toContain('001_old-name.sql'); + expect(entries).toContain('002_new-name.sql'); + expect(entries).not.toContain('002_old-name.sql'); // Verify content preserved - const content = await readFile(updated.changeFiles[0]!.path, 'utf-8'); + const content = await readFile(renamed!.path, 'utf-8'); expect(content).toBe('SELECT 1;'); }); diff --git a/tests/core/change/tracker.test.ts b/tests/core/change/tracker.test.ts new file mode 100644 index 00000000..fa2c4eb9 --- /dev/null +++ b/tests/core/change/tracker.test.ts @@ -0,0 +1,181 @@ +/** + * Change tracker tests. + * + * Pins `ChangeTracker.canRevert`/`markAsReverted` state-machine rules + * against a real in-memory SQLite database. No dependency on `rewind()`'s + * sort, so unlike some `manager.test.ts` cases these are unaffected by + * ticket 34. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { Kysely, SqliteDialect } from 'kysely'; +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; + +import { ChangeTracker } from '../../../src/core/change/tracker.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import type { NoormDatabase, OperationStatus, Direction } from '../../../src/core/shared/index.js'; + +describe('change: tracker', () => { + + let db: Kysely; + let tracker: ChangeTracker; + + /** + * Insert a `__noorm_change__` row directly, bypassing `ChangeHistory`, + * so each test can pin an exact status without running a real change. + */ + async function seedChangeRecord(record: { + name: string; + status: OperationStatus; + direction?: Direction; + configName?: string; + }): Promise { + + await db + .insertInto('__noorm_change__') + .values({ + name: record.name, + change_type: 'change', + direction: record.direction ?? 'change', + status: record.status, + config_name: record.configName ?? 'test', + executed_by: 'test@example.com', + }) + .execute(); + + } + + beforeEach(async () => { + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + tracker = new ChangeTracker(db, 'test'); + + }); + + afterEach(async () => { + + await db.destroy(); + + }); + + describe('canRevert', () => { + + it('should return not-applied when no change-direction record exists', async () => { + + const result = await tracker.canRevert('never-applied', false); + + expect(result).toEqual({ canRevert: false, reason: 'not applied' }); + + }); + + it('should allow revert for a success record', async () => { + + await seedChangeRecord({ name: 'change-success', status: 'success' }); + + const result = await tracker.canRevert('change-success', false); + + expect(result).toEqual({ canRevert: true, status: 'success' }); + + }); + + it('should allow revert for a failed record (partial applies are still revertable)', async () => { + + await seedChangeRecord({ name: 'change-failed', status: 'failed' }); + + const result = await tracker.canRevert('change-failed', false); + + expect(result).toEqual({ canRevert: true, status: 'failed' }); + + }); + + it('should deny revert for a pending record', async () => { + + await seedChangeRecord({ name: 'change-pending', status: 'pending' }); + + const result = await tracker.canRevert('change-pending', false); + + expect(result).toEqual({ canRevert: false, reason: 'not applied yet', status: 'pending' }); + + }); + + it('should deny revert for an already-reverted record', async () => { + + await seedChangeRecord({ name: 'change-reverted', status: 'reverted' }); + + const result = await tracker.canRevert('change-reverted', false); + + expect(result).toEqual({ canRevert: false, reason: 'already reverted', status: 'reverted' }); + + }); + + it('should deny revert for a stale record (schema was torn down)', async () => { + + await seedChangeRecord({ name: 'change-stale', status: 'stale' }); + + const result = await tracker.canRevert('change-stale', false); + + expect(result).toEqual({ canRevert: false, reason: 'schema was torn down', status: 'stale' }); + + }); + + it('should bypass status checks when force is true, but not manufacture a missing record', async () => { + + await seedChangeRecord({ name: 'change-pending-forced', status: 'pending' }); + + const forced = await tracker.canRevert('change-pending-forced', true); + + expect(forced).toEqual({ canRevert: true, status: 'pending' }); + + const forcedMissing = await tracker.canRevert('never-applied-forced', true); + + expect(forcedMissing).toEqual({ canRevert: false, reason: 'not applied' }); + + }); + + }); + + describe('markAsReverted', () => { + + it('should flip only the most recent change-direction record to reverted', async () => { + + // Earlier row simulates a first attempt that failed; later row simulates a + // retry that succeeded. The earlier row is seeded with a status other than + // 'reverted' (the value markAsReverted writes) on purpose -- if a future + // refactor broadens the update to match by name instead of by id, the earlier + // row would incorrectly flip to 'reverted' too, and this assertion catches it. + // Seeding it as already-'reverted' would not: the post-call value would look + // identical whether the row was left untouched or rewritten to the same value. + await seedChangeRecord({ name: 'reapplied-change', status: 'failed' }); + await seedChangeRecord({ name: 'reapplied-change', status: 'success' }); + + await tracker.markAsReverted('reapplied-change'); + + const rows = await db + .selectFrom('__noorm_change__') + .select(['id', 'status']) + .where('name', '=', 'reapplied-change') + .where('direction', '=', 'change') + .orderBy('id', 'asc') + .execute(); + + expect(rows).toHaveLength(2); + expect(rows[0]?.status).toBe('failed'); + expect(rows[1]?.status).toBe('reverted'); + + }); + + it('should be a silent no-op when no change-direction record exists', async () => { + + await expect(tracker.markAsReverted('never-applied')).resolves.toBeUndefined(); + + }); + + }); + +}); diff --git a/tests/core/config/env.test.ts b/tests/core/config/env.test.ts index a72ad992..b3ff41cc 100644 --- a/tests/core/config/env.test.ts +++ b/tests/core/config/env.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { getEnvConfig, } from '../../../src/core/config/index.js'; -import { getEnvConfigName, isCi, shouldOutputJson, shouldSkipConfirmations } from '../../../src/core/environment.js'; +import { getEnvConfigName, isCi, isEnvTruthy, shouldSkipConfirmations } from '../../../src/core/environment.js'; describe('config: env', () => { @@ -375,29 +375,119 @@ describe('config: env', () => { }); + it('should return true when NOORM_YES=TRUE (case-insensitive, unified truthiness)', () => { + + process.env['NOORM_YES'] = 'TRUE'; + + expect(shouldSkipConfirmations()).toBe(true); + + }); + + it('should return true when NOORM_YES=yes (unified truthiness)', () => { + + process.env['NOORM_YES'] = 'yes'; + + expect(shouldSkipConfirmations()).toBe(true); + + }); + + it('should return true for an arbitrary non-empty value (unified truthiness widens beyond 1/true)', () => { + + process.env['NOORM_YES'] = 'on'; + + expect(shouldSkipConfirmations()).toBe(true); + + }); + + it('should return false when NOORM_YES=0', () => { + + process.env['NOORM_YES'] = '0'; + + expect(shouldSkipConfirmations()).toBe(false); + + }); + + it('should return false when NOORM_YES=false', () => { + + process.env['NOORM_YES'] = 'false'; + + expect(shouldSkipConfirmations()).toBe(false); + + }); + + it('should return false when NOORM_YES=FALSE (case-insensitive)', () => { + + process.env['NOORM_YES'] = 'FALSE'; + + expect(shouldSkipConfirmations()).toBe(false); + + }); + + it('should return false when NOORM_YES is empty string', () => { + + process.env['NOORM_YES'] = ''; + + expect(shouldSkipConfirmations()).toBe(false); + + }); + }); - describe('shouldOutputJson', () => { + describe('isEnvTruthy', () => { - it('should return false when not set', () => { + it('should return false for undefined', () => { - expect(shouldOutputJson()).toBe(false); + expect(isEnvTruthy(undefined)).toBe(false); }); - it('should return true when NOORM_JSON=1', () => { + it('should return false for empty string', () => { - process.env['NOORM_JSON'] = '1'; + expect(isEnvTruthy('')).toBe(false); - expect(shouldOutputJson()).toBe(true); + }); + + it('should return false for "0"', () => { + + expect(isEnvTruthy('0')).toBe(false); }); - it('should return true when NOORM_JSON=true', () => { + it('should return false for "false" in any letter case', () => { - process.env['NOORM_JSON'] = 'true'; + expect(isEnvTruthy('false')).toBe(false); + expect(isEnvTruthy('FALSE')).toBe(false); + expect(isEnvTruthy('False')).toBe(false); + + }); + + it('should return true for "1"', () => { + + expect(isEnvTruthy('1')).toBe(true); + + }); + + it('should return true for "true" in any letter case', () => { + + expect(isEnvTruthy('true')).toBe(true); + expect(isEnvTruthy('TRUE')).toBe(true); + expect(isEnvTruthy('True')).toBe(true); + + }); + + it('should return true for "yes"', () => { + + expect(isEnvTruthy('yes')).toBe(true); + + }); + + it('should return true for any other non-empty string, including "no"/"off"', () => { - expect(shouldOutputJson()).toBe(true); + expect(isEnvTruthy('no')).toBe(true); + expect(isEnvTruthy('off')).toBe(true); + expect(isEnvTruthy('00')).toBe(true); + expect(isEnvTruthy(' ')).toBe(true); + expect(isEnvTruthy(' 0')).toBe(true); }); diff --git a/tests/core/config/schema.test.ts b/tests/core/config/schema.test.ts index 458e1823..ad1aa705 100644 --- a/tests/core/config/schema.test.ts +++ b/tests/core/config/schema.test.ts @@ -399,4 +399,107 @@ describe('config: schema validation', () => { }); + describe('database name validation (ConnectionSchema refine)', () => { + + const dangerousChars: Array<[label: string, char: string]> = [ + ['double quote', '"'], + ['single quote', '\''], + ['backtick', '`'], + ['opening bracket', '['], + ['closing bracket', ']'], + ['semicolon', ';'], + ['NUL control char', '\x00'], + ['unit separator control char', '\x1f'], + ['DEL control char', '\x7f'], + ]; + + const serverDialects = ['postgres', 'mysql', 'mssql'] as const; + + for (const dialect of serverDialects) { + + describe(dialect, () => { + + for (const [label, char] of dangerousChars) { + + it(`should reject a database name containing a ${label}`, () => { + + const config = createValidConfig({ + connection: { dialect, database: `db${char}name`, host: 'localhost' }, + }); + + expect(() => validateConfig(config)).toThrow(ConfigValidationError); + expect(() => validateConfig(config)).toThrow( + 'Database name must not contain quotes, backticks, brackets, semicolons, or control characters', + ); + + }); + + } + + it('should accept normal database names (dots, dashes, spaces, underscores, unicode)', () => { + + const validNames = ['myapp', 'my-app', 'my.app', 'my app', 'my_app', 'café_db']; + + for (const database of validNames) { + + const config = createValidConfig({ + connection: { dialect, database, host: 'localhost' }, + }); + + expect(() => validateConfig(config)).not.toThrow(); + + } + + }); + + it('should report the field as connection.database', () => { + + const config = createValidConfig({ + connection: { dialect, database: 'bad"name', host: 'localhost' }, + }); + + try { + + validateConfig(config); + + } + catch (err) { + + expect(err).toBeInstanceOf(ConfigValidationError); + expect((err as ConfigValidationError).field).toBe('connection.database'); + + } + + }); + + }); + + } + + describe('sqlite (exempt)', () => { + + it('should accept :memory:', () => { + + const config = createValidConfig({ + connection: { dialect: 'sqlite', database: ':memory:' }, + }); + + expect(() => validateConfig(config)).not.toThrow(); + + }); + + it('should accept a file path containing brackets and quotes', () => { + + const config = createValidConfig({ + connection: { dialect: 'sqlite', database: './data/app[1]\'s.db' }, + }); + + expect(() => validateConfig(config)).not.toThrow(); + + }); + + }); + + }); + }); diff --git a/tests/core/config/validate.test.ts b/tests/core/config/validate.test.ts new file mode 100644 index 00000000..78ba2a5a --- /dev/null +++ b/tests/core/config/validate.test.ts @@ -0,0 +1,72 @@ +/** + * Config validate-algorithm tests. + * + * `validateConfigChecks` is the single source for the three-check + * sequence (connection, name/database presence, host presence for + * non-sqlite) shared by `cli/config/validate.ts` and + * `ConfigValidateScreen.tsx`. + */ +import { describe, it, expect } from 'bun:test'; + +import { validateConfigChecks } from '../../../src/core/config/validate.js'; +import type { Config } from '../../../src/core/config/types.js'; + +/** + * Create a valid test config, mirroring `tests/core/config/resolver.test.ts`. + */ +function createConfig(overrides: Partial = {}): Config { + + return { + name: 'test', + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { + dialect: 'sqlite', + database: ':memory:', + }, + ...overrides, + }; + +} + +describe('config: validateConfigChecks', () => { + + it('passes all checks for a valid sqlite config', async () => { + + const config = createConfig(); + + const { checks, valid } = await validateConfigChecks(config); + + expect(valid).toBe(true); + expect(checks.map((c) => c.key)).toEqual(['connection', 'name', 'database']); + expect(checks.every((c) => c.status === 'success')).toBe(true); + + }); + + it('fails the host check for a non-sqlite config with no host set', async () => { + + const config = createConfig({ + connection: { + dialect: 'postgres', + database: 'testdb', + // Invalid port forces a fast, deterministic connection failure + // (Node's socket layer rejects synchronously) instead of the + // ECONNREFUSED retry/backoff path, which would make this test + // slow and environment-dependent. + port: 999999, + }, + }); + + const { checks, valid } = await validateConfigChecks(config); + + expect(valid).toBe(false); + expect(checks.map((c) => c.key)).toEqual(['connection', 'name', 'database', 'host']); + + const hostCheck = checks.find((c) => c.key === 'host'); + expect(hostCheck?.status).toBe('error'); + expect(hostCheck?.detail).toBe('Not set'); + + }); + +}); diff --git a/tests/core/connection/defaults.test.ts b/tests/core/connection/defaults.test.ts new file mode 100644 index 00000000..8251e8f0 --- /dev/null +++ b/tests/core/connection/defaults.test.ts @@ -0,0 +1,50 @@ +/** + * DEFAULT_PORTS single-source tests. + * + * Proves `same-server.ts` and the TUI's re-export barrel read the + * canonical constant in `core/connection/defaults.ts` rather than an + * independently declared copy that could drift. Dialect factories + * (`postgres.ts`/`mysql.ts`/`mssql.ts`) are covered by the checkpoint's + * `rg` proof (no literal port values survive under + * `src/core/connection/dialects/`) — not duplicated here, since exercising + * them would require a live/simulated DB connection. + */ +import { describe, it, expect } from 'bun:test'; + +import { DEFAULT_PORTS } from '../../../src/core/connection/index.js'; +import { getDefaultPort } from '../../../src/core/transfer/same-server.js'; +import { DEFAULT_PORTS as tuiDefaultPorts } from '../../../src/tui/utils/config-validation.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; + +describe('connection: DEFAULT_PORTS single source', () => { + + it('has exactly one canonical value per dialect', () => { + + expect(DEFAULT_PORTS).toEqual({ + postgres: 5432, + mysql: 3306, + sqlite: 0, + mssql: 1433, + }); + + }); + + it('same-server.getDefaultPort reads the canonical constant, not a local copy', () => { + + const dialects: Dialect[] = ['postgres', 'mysql', 'sqlite', 'mssql']; + + for (const dialect of dialects) { + + expect(getDefaultPort(dialect)).toBe(DEFAULT_PORTS[dialect]); + + } + + }); + + it('the TUI utils barrel re-exports the same DEFAULT_PORTS object, not a redeclared copy', () => { + + expect(tuiDefaultPorts).toBe(DEFAULT_PORTS); + + }); + +}); diff --git a/tests/core/connection/factory.test.ts b/tests/core/connection/factory.test.ts index e8d8294f..17abbcd3 100644 --- a/tests/core/connection/factory.test.ts +++ b/tests/core/connection/factory.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; import { sql } from 'kysely'; import { createConnection, testConnection } from '../../../src/core/connection/index.js'; import type { ConnectionConfig } from '../../../src/core/connection/index.js'; +import { observer } from '../../../src/core/observer.js'; describe('connection: factory', () => { @@ -83,6 +84,98 @@ describe('connection: factory', () => { }); + describe('retryOptions (v1/49-54 finding 1 — best-effort probe fails fast)', () => { + + it('should not apply the default retry/backoff when retryOptions overrides to a single attempt', async () => { + + // Nothing listens here — a fast, certain ECONNREFUSED, not a + // slow network timeout. The default policy (3 attempts, ~2s + // apart) would take ~6-7s; overriding to one attempt with no + // delay must skip that wait entirely. + const config: ConnectionConfig = { + dialect: 'postgres', + host: '127.0.0.1', + port: 59999, + database: 'nope', + }; + + const start = Date.now(); + + await expect( + createConnection(config, '__probe__', { retries: 1, delay: 0 }), + ).rejects.toThrow(); + + const elapsed = Date.now() - start; + + // The default policy measured 6252ms here, so 4500 still fails + // loudly if the override stops working. Deliberately not tighter: + // a bare refused connection takes milliseconds, and the slack is + // for a loaded runner, not for the behaviour under test. + expect(elapsed).toBeLessThan(4500); + + }); + + }); + + describe('connection:open target (CP9.5, v1/49-54)', () => { + + // `NoormEvents['connection:open']` (src/core/observer.ts) still + // declares only configName/dialect -- widening that shared type is + // out of this checkpoint's scope. The extra fields exist on the + // runtime payload regardless, so assert on the raw shape rather + // than the (currently narrower) declared event type. + + it('should carry host, port, and database, removing the "which database" ambiguity #51 reported', async () => { + + const config: ConnectionConfig = { + dialect: 'sqlite', + database: ':memory:', + host: 'db.internal', + port: 5432, + }; + + const events: Array> = []; + const cleanup = observer.on('connection:open', (data) => events.push(data as Record)); + + const conn = await createConnection(config, 'test-target-config'); + connections.push(conn); + cleanup(); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + configName: 'test-target-config', + dialect: 'sqlite', + host: 'db.internal', + port: 5432, + database: ':memory:', + }); + + }); + + it('should never carry credentials', async () => { + + const config: ConnectionConfig = { + dialect: 'sqlite', + database: ':memory:', + user: 'should-not-appear', + password: 'super-secret-password', + }; + + const events: Array> = []; + const cleanup = observer.on('connection:open', (data) => events.push(data as Record)); + + const conn = await createConnection(config, 'test-no-creds'); + connections.push(conn); + cleanup(); + + expect(events[0]).not.toHaveProperty('user'); + expect(events[0]).not.toHaveProperty('password'); + expect(JSON.stringify(events[0])).not.toContain('super-secret-password'); + + }); + + }); + describe('testConnection', () => { it('should return ok: true for valid SQLite connection', async () => { diff --git a/tests/core/db/dialects/mssql.test.ts b/tests/core/db/dialects/mssql.test.ts new file mode 100644 index 00000000..75f4bdc8 --- /dev/null +++ b/tests/core/db/dialects/mssql.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'bun:test'; + +import { + buildCreateDatabaseSql, + buildDropDatabaseSql, +} from '../../../../src/core/db/dialects/mssql.js'; + +describe('db: mssql dialect', () => { + + describe('buildCreateDatabaseSql', () => { + + it('should generate CREATE DATABASE with a bracket-quoted identifier', () => { + + const sql = buildCreateDatabaseSql('myapp'); + + expect(sql).toBe('CREATE DATABASE [myapp]'); + + }); + + it('should escape embedded closing brackets (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my]app'); + + expect(sql).toBe('CREATE DATABASE [my]]app]'); + + }); + + it('should keep an embedded double quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my"app'); + + expect(sql).toBe('CREATE DATABASE [my"app]'); + + }); + + it('should keep an embedded backtick literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my`app'); + + expect(sql).toBe('CREATE DATABASE [my`app]'); + + }); + + it('should keep an embedded single quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my\'app'); + + expect(sql).toBe('CREATE DATABASE [my\'app]'); + + }); + + it('should neutralize a semicolon injection payload as a single escaped identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('x]; DROP DATABASE other; --'); + + expect(sql).toBe('CREATE DATABASE [x]]; DROP DATABASE other; --]'); + + }); + + }); + + describe('buildDropDatabaseSql', () => { + + function expectedBatch(literal: string, identifier: string): string { + + return [ + `IF EXISTS(SELECT 1 FROM sys.databases WHERE name = '${literal}')`, + 'BEGIN', + ` ALTER DATABASE ${identifier} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;`, + ` DROP DATABASE ${identifier};`, + 'END', + ].join('\n'); + + } + + it('should generate the IF EXISTS / BEGIN / ALTER+DROP / END batch with bracket-quoted identifiers', () => { + + const sql = buildDropDatabaseSql('myapp'); + + expect(sql).toBe(expectedBatch('myapp', '[myapp]')); + + }); + + it('should double an embedded single quote in the string literal only (adversarial)', () => { + + const sql = buildDropDatabaseSql('my\'app'); + + expect(sql).toBe(expectedBatch('my\'\'app', '[my\'app]')); + + }); + + it('should escape an embedded closing bracket in the identifiers only (adversarial)', () => { + + const sql = buildDropDatabaseSql('my]app'); + + expect(sql).toBe(expectedBatch('my]app', '[my]]app]')); + + }); + + it('should keep an embedded double quote literal untouched (adversarial)', () => { + + const sql = buildDropDatabaseSql('my"app'); + + expect(sql).toBe(expectedBatch('my"app', '[my"app]')); + + }); + + it('should keep an embedded backtick literal untouched (adversarial)', () => { + + const sql = buildDropDatabaseSql('my`app'); + + expect(sql).toBe(expectedBatch('my`app', '[my`app]')); + + }); + + it('cannot be broken out of by a semicolon + quote injection payload (adversarial)', () => { + + const sql = buildDropDatabaseSql('x\'; DROP DATABASE other; --'); + + expect(sql).toBe( + expectedBatch('x\'\'; DROP DATABASE other; --', '[x\'; DROP DATABASE other; --]'), + ); + + }); + + it('escapes the quote and the bracket independently when both are present (adversarial)', () => { + + // Literal context (single quote) and identifier context (bracket) + // each escape only their own delimiter — one context's fix-up + // must not bleed into the other. + const sql = buildDropDatabaseSql('my\']db'); + + expect(sql).toBe(expectedBatch('my\'\']db', '[my\']]db]')); + + }); + + }); + +}); diff --git a/tests/core/db/dialects/mysql.test.ts b/tests/core/db/dialects/mysql.test.ts new file mode 100644 index 00000000..5002f33c --- /dev/null +++ b/tests/core/db/dialects/mysql.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'bun:test'; + +import { + buildCreateDatabaseSql, + buildDropDatabaseSql, +} from '../../../../src/core/db/dialects/mysql.js'; + +describe('db: mysql dialect', () => { + + describe('buildCreateDatabaseSql', () => { + + it('should generate CREATE DATABASE IF NOT EXISTS with a backtick-quoted identifier', () => { + + const sql = buildCreateDatabaseSql('myapp'); + + expect(sql).toBe('CREATE DATABASE IF NOT EXISTS `myapp`'); + + }); + + it('should escape embedded backticks (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my`app'); + + expect(sql).toBe('CREATE DATABASE IF NOT EXISTS `my``app`'); + + }); + + it('should keep an embedded double quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my"app'); + + expect(sql).toBe('CREATE DATABASE IF NOT EXISTS `my"app`'); + + }); + + it('should keep an embedded bracket literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my]app'); + + expect(sql).toBe('CREATE DATABASE IF NOT EXISTS `my]app`'); + + }); + + it('should keep an embedded single quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my\'app'); + + expect(sql).toBe('CREATE DATABASE IF NOT EXISTS `my\'app`'); + + }); + + it('should neutralize a semicolon injection payload as a single escaped identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('x`; DROP DATABASE other; --'); + + expect(sql).toBe('CREATE DATABASE IF NOT EXISTS `x``; DROP DATABASE other; --`'); + + }); + + }); + + describe('buildDropDatabaseSql', () => { + + it('should generate DROP DATABASE IF EXISTS with a backtick-quoted identifier', () => { + + const sql = buildDropDatabaseSql('myapp'); + + expect(sql).toBe('DROP DATABASE IF EXISTS `myapp`'); + + }); + + it('should escape embedded backticks (adversarial)', () => { + + const sql = buildDropDatabaseSql('my`app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS `my``app`'); + + }); + + it('should keep an embedded double quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('my"app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS `my"app`'); + + }); + + it('should keep an embedded bracket literal inside the quoted identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('my]app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS `my]app`'); + + }); + + it('should keep an embedded single quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('my\'app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS `my\'app`'); + + }); + + it('should neutralize a semicolon injection payload as a single escaped identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('x`; DROP DATABASE other; --'); + + expect(sql).toBe('DROP DATABASE IF EXISTS `x``; DROP DATABASE other; --`'); + + }); + + }); + +}); diff --git a/tests/core/db/dialects/postgres.test.ts b/tests/core/db/dialects/postgres.test.ts new file mode 100644 index 00000000..34f7028f --- /dev/null +++ b/tests/core/db/dialects/postgres.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'bun:test'; + +import { + buildCreateDatabaseSql, + buildDropDatabaseSql, +} from '../../../../src/core/db/dialects/postgres.js'; + +describe('db: postgres dialect', () => { + + describe('buildCreateDatabaseSql', () => { + + it('should generate CREATE DATABASE with a quoted identifier', () => { + + const sql = buildCreateDatabaseSql('myapp'); + + expect(sql).toBe('CREATE DATABASE "myapp"'); + + }); + + it('should escape embedded double quotes (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my"app'); + + expect(sql).toBe('CREATE DATABASE "my""app"'); + + }); + + it('should keep an embedded backtick literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my`app'); + + expect(sql).toBe('CREATE DATABASE "my`app"'); + + }); + + it('should keep an embedded bracket literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my]app'); + + expect(sql).toBe('CREATE DATABASE "my]app"'); + + }); + + it('should keep an embedded single quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('my\'app'); + + expect(sql).toBe('CREATE DATABASE "my\'app"'); + + }); + + it('should neutralize a semicolon injection payload as a single escaped identifier (adversarial)', () => { + + const sql = buildCreateDatabaseSql('x"; DROP DATABASE other; --'); + + expect(sql).toBe('CREATE DATABASE "x""; DROP DATABASE other; --"'); + + }); + + }); + + describe('buildDropDatabaseSql', () => { + + it('should generate DROP DATABASE IF EXISTS with a quoted identifier', () => { + + const sql = buildDropDatabaseSql('myapp'); + + expect(sql).toBe('DROP DATABASE IF EXISTS "myapp"'); + + }); + + it('should escape embedded double quotes (adversarial)', () => { + + const sql = buildDropDatabaseSql('my"app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS "my""app"'); + + }); + + it('should keep an embedded backtick literal inside the quoted identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('my`app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS "my`app"'); + + }); + + it('should keep an embedded bracket literal inside the quoted identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('my]app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS "my]app"'); + + }); + + it('should keep an embedded single quote literal inside the quoted identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('my\'app'); + + expect(sql).toBe('DROP DATABASE IF EXISTS "my\'app"'); + + }); + + it('should neutralize a semicolon injection payload as a single escaped identifier (adversarial)', () => { + + const sql = buildDropDatabaseSql('x"; DROP DATABASE other; --'); + + expect(sql).toBe('DROP DATABASE IF EXISTS "x""; DROP DATABASE other; --"'); + + }); + + }); + +}); diff --git a/tests/core/dt/crypto.test.ts b/tests/core/dt/crypto.test.ts index 0bdcfe5a..f613fd41 100644 --- a/tests/core/dt/crypto.test.ts +++ b/tests/core/dt/crypto.test.ts @@ -2,13 +2,21 @@ * Passphrase-based encryption tests. * * Covers encryptWithPassphrase(), decryptWithPassphrase() round-trip, - * wrong passphrase errors, and tamper detection. + * wrong passphrase errors, tamper detection, and the passphrase floor. */ import { describe, it, expect } from 'bun:test'; +import { + createCipheriv, + randomBytes, + pbkdf2Sync, +} from 'node:crypto'; + import { encryptWithPassphrase, decryptWithPassphrase, + MIN_PASSPHRASE_LENGTH, } from '../../../src/core/dt/crypto.js'; +import type { DtEncryptedPayload } from '../../../src/core/dt/types.js'; describe('dt: crypto', () => { @@ -29,7 +37,7 @@ describe('dt: crypto', () => { it('should round-trip binary data', () => { const data = Buffer.from([0x00, 0x01, 0xff, 0xfe, 0x80, 0x7f]); - const passphrase = 'binary-test'; + const passphrase = 'binary-test-min12'; const payload = encryptWithPassphrase(data, passphrase); const decrypted = decryptWithPassphrase(payload, passphrase); @@ -41,7 +49,7 @@ describe('dt: crypto', () => { it('should round-trip large data', () => { const data = Buffer.alloc(100_000, 'x'); - const passphrase = 'large-test'; + const passphrase = 'large-test-min12'; const payload = encryptWithPassphrase(data, passphrase); const decrypted = decryptWithPassphrase(payload, passphrase); @@ -53,7 +61,7 @@ describe('dt: crypto', () => { it('should produce base64-encoded payload fields', () => { const data = Buffer.from('test'); - const payload = encryptWithPassphrase(data, 'pass'); + const payload = encryptWithPassphrase(data, 'pass-min-12chars'); expect(typeof payload.salt).toBe('string'); expect(typeof payload.iv).toBe('string'); @@ -71,7 +79,7 @@ describe('dt: crypto', () => { it('should generate unique salt and IV per encryption', () => { const data = Buffer.from('same data'); - const passphrase = 'same-pass'; + const passphrase = 'same-pass-min12'; const p1 = encryptWithPassphrase(data, passphrase); const p2 = encryptWithPassphrase(data, passphrase); @@ -106,7 +114,7 @@ describe('dt: crypto', () => { it('should detect tampered ciphertext', () => { const data = Buffer.from('secret'); - const payload = encryptWithPassphrase(data, 'pass'); + const payload = encryptWithPassphrase(data, 'pass-min-12chars'); const tampered = { ...payload, @@ -115,7 +123,7 @@ describe('dt: crypto', () => { expect(() => { - decryptWithPassphrase(tampered, 'pass'); + decryptWithPassphrase(tampered, 'pass-min-12chars'); }).toThrow(); @@ -124,7 +132,7 @@ describe('dt: crypto', () => { it('should detect tampered authTag', () => { const data = Buffer.from('secret'); - const payload = encryptWithPassphrase(data, 'pass'); + const payload = encryptWithPassphrase(data, 'pass-min-12chars'); const tampered = { ...payload, @@ -133,7 +141,7 @@ describe('dt: crypto', () => { expect(() => { - decryptWithPassphrase(tampered, 'pass'); + decryptWithPassphrase(tampered, 'pass-min-12chars'); }).toThrow(); @@ -142,7 +150,7 @@ describe('dt: crypto', () => { it('should detect tampered IV', () => { const data = Buffer.from('secret'); - const payload = encryptWithPassphrase(data, 'pass'); + const payload = encryptWithPassphrase(data, 'pass-min-12chars'); const tampered = { ...payload, @@ -151,7 +159,7 @@ describe('dt: crypto', () => { expect(() => { - decryptWithPassphrase(tampered, 'pass'); + decryptWithPassphrase(tampered, 'pass-min-12chars'); }).toThrow(); @@ -159,4 +167,75 @@ describe('dt: crypto', () => { }); + describe('MIN_PASSPHRASE_LENGTH floor', () => { + + it('should throw on encrypt with a 1-character passphrase', () => { + + const data = Buffer.from('secret'); + + expect(() => { + + encryptWithPassphrase(data, 'a'); + + }).toThrow(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`); + + }); + + it('should throw on encrypt with an 11-character passphrase', () => { + + const data = Buffer.from('secret'); + const passphrase = 'a'.repeat(MIN_PASSPHRASE_LENGTH - 1); + + expect(() => { + + encryptWithPassphrase(data, passphrase); + + }).toThrow(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`); + + }); + + it('should succeed on encrypt with a 12-character passphrase', () => { + + const data = Buffer.from('secret'); + const passphrase = 'a'.repeat(MIN_PASSPHRASE_LENGTH); + + expect(() => { + + encryptWithPassphrase(data, passphrase); + + }).not.toThrow(); + + }); + + it('should decrypt a legacy payload encrypted pre-floor with a short passphrase', () => { + + // Builds the payload the same way encryptWithPassphrase does, + // bypassing its floor check, to prove decrypt still opens + // archives from older versions encrypted with short passphrases. + const passphrase = 'short'; + const data = Buffer.from('legacy secret'); + + const salt = randomBytes(32); + const iv = randomBytes(16); + const key = pbkdf2Sync(passphrase, salt, 100_000, 32, 'sha256'); + + const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); + const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]); + const authTag = cipher.getAuthTag(); + + const legacyPayload: DtEncryptedPayload = { + salt: salt.toString('base64'), + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + ciphertext: ciphertext.toString('base64'), + }; + + const decrypted = decryptWithPassphrase(legacyPayload, passphrase); + + expect(decrypted.toString('utf8')).toBe('legacy secret'); + + }); + + }); + }); diff --git a/tests/core/dt/integration.test.ts b/tests/core/dt/integration.test.ts index 2746e0fe..6a1b5268 100644 --- a/tests/core/dt/integration.test.ts +++ b/tests/core/dt/integration.test.ts @@ -383,7 +383,7 @@ describe('dt: integration', () => { const content = 'Sensitive article content. '.repeat(50); const rows = [{ id: 1, Content: content }]; - const result = await roundTrip(schema, rows, '.dtzx', 'postgres', undefined, 'my-secret'); + const result = await roundTrip(schema, rows, '.dtzx', 'postgres', undefined, 'my-secret-passphrase'); expect(result).toHaveLength(1); expect(result[0]!.Content).toBe(content); diff --git a/tests/core/dt/reader.test.ts b/tests/core/dt/reader.test.ts index a2c016ab..167279f7 100644 --- a/tests/core/dt/reader.test.ts +++ b/tests/core/dt/reader.test.ts @@ -159,6 +159,30 @@ describe('dt: reader', () => { }); + it('should reject promptly with the underlying ENOENT for a nonexistent .dtz path', async () => { + + const filepath = path.join(testDir, 'does-not-exist.dtz'); + const reader = new DtReader({ filepath }); + + // Bun's own per-test timeout (2s) is the hang detector: if the + // source stream's 'error' event stops being forwarded into the + // gunzip stream, this test times out instead of the promise + // ever rejecting -- never waits out the old 15s hang. + await expect(reader.open()).rejects.toThrow(/ENOENT/); + + }, 2000); + + it('should reject via gunzip error for a corrupt (non-gzip) .dtz file', async () => { + + const filepath = path.join(testDir, 'corrupt.dtz'); + await Bun.write(filepath, 'not gzip bytes'); + + const reader = new DtReader({ filepath }); + + await expect(reader.open()).rejects.toThrow(); + + }, 2000); + }); // ----------------------------------------------------------------------- @@ -169,7 +193,7 @@ describe('dt: reader', () => { it('should require passphrase', async () => { - const filepath = await writeFixture('.dtzx', 'secret'); + const filepath = await writeFixture('.dtzx', 'secret-min-12chars'); const reader = new DtReader({ filepath }); await expect(reader.open()).rejects.toThrow('Passphrase required'); @@ -178,8 +202,8 @@ describe('dt: reader', () => { it('should read encrypted file with correct passphrase', async () => { - const filepath = await writeFixture('.dtzx', 'secret'); - const reader = new DtReader({ filepath, passphrase: 'secret' }); + const filepath = await writeFixture('.dtzx', 'secret-min-12chars'); + const reader = new DtReader({ filepath, passphrase: 'secret-min-12chars' }); await reader.open(); expect(reader.schema!.v).toBe(1); @@ -202,7 +226,7 @@ describe('dt: reader', () => { it('should fail with wrong passphrase', async () => { - const filepath = await writeFixture('.dtzx', 'secret'); + const filepath = await writeFixture('.dtzx', 'secret-min-12chars'); const reader = new DtReader({ filepath, passphrase: 'wrong' }); await expect(reader.open()).rejects.toThrow(); diff --git a/tests/core/dt/writer.test.ts b/tests/core/dt/writer.test.ts index faddc830..1e3b6909 100644 --- a/tests/core/dt/writer.test.ts +++ b/tests/core/dt/writer.test.ts @@ -183,7 +183,7 @@ describe('dt: writer', () => { it('should write an encrypted payload', async () => { const filepath = path.join(testDir, 'test.dtzx'); - const writer = new DtWriter({ filepath, schema, passphrase: 'secret' }); + const writer = new DtWriter({ filepath, schema, passphrase: 'secret-min-12chars' }); await writer.open(); writer.writeRow([1, 'alice']); @@ -206,7 +206,7 @@ describe('dt: writer', () => { it('should track bytesWritten', async () => { const filepath = path.join(testDir, 'test.dtzx'); - const writer = new DtWriter({ filepath, schema, passphrase: 'secret' }); + const writer = new DtWriter({ filepath, schema, passphrase: 'secret-min-12chars' }); await writer.open(); writer.writeRow([1, 'alice']); diff --git a/tests/core/environment.test.ts b/tests/core/environment.test.ts new file mode 100644 index 00000000..76af7e10 --- /dev/null +++ b/tests/core/environment.test.ts @@ -0,0 +1,142 @@ +/** + * Environment detection tests. + * + * NOORM_DEBUG truthiness rule (isEnvTruthy, shared with NOORM_YES): empty, + * unset, `'0'`, or `'false'` (any case) disables; any other non-empty value + * enables. Regression coverage for the bug where `NOORM_DEBUG=0` used to + * *enable* debug output at raw-truthiness call sites (observer.ts spy, + * connection/manager.ts logging) because any non-empty string, including + * `'0'`, is truthy in JS. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { join } from 'node:path'; + +import { isDebug } from '../../src/core/environment.js'; + +describe('environment: isDebug', () => { + + const original = process.env['NOORM_DEBUG']; + + afterEach(() => { + + if (original === undefined) { + + delete process.env['NOORM_DEBUG']; + + } + else { + + process.env['NOORM_DEBUG'] = original; + + } + + }); + + it('should disable when NOORM_DEBUG is unset', () => { + + delete process.env['NOORM_DEBUG']; + + expect(isDebug()).toBe(false); + + }); + + it('should disable when NOORM_DEBUG is empty', () => { + + process.env['NOORM_DEBUG'] = ''; + + expect(isDebug()).toBe(false); + + }); + + it('should disable when NOORM_DEBUG=0', () => { + + process.env['NOORM_DEBUG'] = '0'; + + expect(isDebug()).toBe(false); + + }); + + it('should disable when NOORM_DEBUG=false in any letter case', () => { + + process.env['NOORM_DEBUG'] = 'false'; + expect(isDebug()).toBe(false); + + process.env['NOORM_DEBUG'] = 'FALSE'; + expect(isDebug()).toBe(false); + + }); + + it('should enable when NOORM_DEBUG=1', () => { + + process.env['NOORM_DEBUG'] = '1'; + + expect(isDebug()).toBe(true); + + }); + + it('should enable when NOORM_DEBUG=true', () => { + + process.env['NOORM_DEBUG'] = 'true'; + + expect(isDebug()).toBe(true); + + }); + + it('should enable when NOORM_DEBUG=yes', () => { + + process.env['NOORM_DEBUG'] = 'yes'; + + expect(isDebug()).toBe(true); + + }); + +}); + +describe('environment: NOORM_DEBUG module-scope evaluation (observer spy)', () => { + + /** + * observer.ts reads `isDebug()` once at module load time to wire the + * ObserverEngine `spy` option (`export const observer = new + * ObserverEngine(...)`), so mutating process.env after this test file's + * own import of observer.ts would not re-evaluate it. Each value is + * checked in a fresh subprocess so the module loads against the exact + * env it should observe. Uses `bun -e` against the TS source directly + * (no build step) rather than the built CLI binary that other + * subprocess CLI tests spawn via `spawnSync('node', [dist/cli/index.js])` + * — observer.ts isn't a CLI entrypoint, so there's no dist artifact to + * invoke. + */ + async function hasSpyForDebugValue(value: string): Promise { + + const proc = Bun.spawn({ + cmd: [ + 'bun', '-e', + 'const { observer } = await import(\'./src/core/observer.ts\');' + + 'console.log(JSON.stringify(observer.$facts().hasSpy));', + ], + cwd: join(import.meta.dir, '../..'), + env: { ...process.env, NOORM_DEBUG: value }, + stdout: 'pipe', + stderr: 'inherit', + }); + + const stdout = await new Response(proc.stdout).text(); + await proc.exited; + + return JSON.parse(stdout.trim()); + + } + + it('should not enable the observer spy when NOORM_DEBUG=0', async () => { + + expect(await hasSpyForDebugValue('0')).toBe(false); + + }); + + it('should enable the observer spy when NOORM_DEBUG=1', async () => { + + expect(await hasSpyForDebugValue('1')).toBe(true); + + }); + +}); diff --git a/tests/core/identity/storage-key-permission-guard.test.ts b/tests/core/identity/storage-key-permission-guard.test.ts new file mode 100644 index 00000000..2d13e188 --- /dev/null +++ b/tests/core/identity/storage-key-permission-guard.test.ts @@ -0,0 +1,85 @@ +/** + * core/identity: loadPrivateKey — key-permission guard wiring. + * + * `validateKeyPermissions()` derives PRIVATE_KEY_PATH from `homedir()` at + * module import time, so it can't be redirected in-process to a fake + * `~/.noorm`. Each case spawns a fresh `bun -e` subprocess against the + * built dist output with `HOME` repointed at a throwaway tmp dir, matching + * the subprocess-isolation idiom used by tests/cli/db/transfer.test.ts. + * + * Regression under test: `validateKeyPermissions()` was exported dead code + * with zero callers — a world-readable identity.key on disk was silently + * accepted by `loadPrivateKey()`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const STORAGE_MODULE = join(process.cwd(), 'dist/core/identity/storage.js'); + +const LOAD_PRIVATE_KEY_SCRIPT = ` + import(${JSON.stringify(STORAGE_MODULE)}).then(async (m) => { + const key = await m.loadPrivateKey(); + process.stdout.write('OK:' + key); + }).catch((err) => { + process.stderr.write('ERR:' + err.message); + process.exit(1); + }); +`; + +describe('identity: storage (loadPrivateKey permission guard)', () => { + + let fakeHome: string; + let keyPath: string; + + beforeEach(() => { + + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-key-guard-home-')); + mkdirSync(join(fakeHome, '.noorm'), { recursive: true }); + keyPath = join(fakeHome, '.noorm', 'identity.key'); + + }); + + afterEach(() => { + + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + function runLoadPrivateKey() { + + return spawnSync('bun', ['-e', LOAD_PRIVATE_KEY_SCRIPT], { + encoding: 'utf-8', + env: { ...process.env, HOME: fakeHome }, + }); + + } + + it('rejects a world-readable private key', () => { + + writeFileSync(keyPath, 'a'.repeat(96), { mode: 0o644 }); + chmodSync(keyPath, 0o644); + + const result = runLoadPrivateKey(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('chmod 600'); + + }); + + it('resolves a private key with secure permissions', () => { + + const key = 'a'.repeat(96); + writeFileSync(keyPath, key, { mode: 0o600 }); + chmodSync(keyPath, 0o600); + + const result = runLoadPrivateKey(); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(`OK:${key}`); + + }); + +}); diff --git a/tests/core/identity/storage.test.ts b/tests/core/identity/storage.test.ts index 25976c61..f2b50f86 100644 --- a/tests/core/identity/storage.test.ts +++ b/tests/core/identity/storage.test.ts @@ -10,6 +10,7 @@ import { getPrivateKeyPath, getPublicKeyPath, getNoormHomePath, + validateKeyPermissions, } from '../../../src/core/identity/storage.js'; import { generateKeyPair } from '../../../src/core/identity/crypto.js'; @@ -168,4 +169,56 @@ describe('identity: storage (file operations)', () => { }); + describe('validateKeyPermissions', () => { + + it('accepts 0600 (owner read/write only)', async () => { + + const keyPath = join(tempDir, 'identity.key'); + await writeFile(keyPath, 'test', { mode: 0o600 }); + await chmod(keyPath, 0o600); + + expect(await validateKeyPermissions(keyPath)).toBe(true); + + }); + + it('accepts 0400 (owner read-only, stricter than required)', async () => { + + const keyPath = join(tempDir, 'identity.key'); + await writeFile(keyPath, 'test', { mode: 0o600 }); + await chmod(keyPath, 0o400); + + expect(await validateKeyPermissions(keyPath)).toBe(true); + + }); + + it('rejects 0644 (world-readable)', async () => { + + const keyPath = join(tempDir, 'identity.key'); + await writeFile(keyPath, 'test', { mode: 0o644 }); + await chmod(keyPath, 0o644); + + expect(await validateKeyPermissions(keyPath)).toBe(false); + + }); + + it('rejects 0660 (group read/write)', async () => { + + const keyPath = join(tempDir, 'identity.key'); + await writeFile(keyPath, 'test', { mode: 0o660 }); + await chmod(keyPath, 0o660); + + expect(await validateKeyPermissions(keyPath)).toBe(false); + + }); + + it('rejects a missing file', async () => { + + const keyPath = join(tempDir, 'does-not-exist.key'); + + expect(await validateKeyPermissions(keyPath)).toBe(false); + + }); + + }); + }); diff --git a/tests/core/logger/output.test.ts b/tests/core/logger/output.test.ts index 09e0f164..49c630b2 100644 --- a/tests/core/logger/output.test.ts +++ b/tests/core/logger/output.test.ts @@ -64,7 +64,11 @@ describe('logger: output formats', () => { describe('stream routing', () => { - it('should write to console stream when provided', async () => { + // CP4: the event stream (info/warn/error/debug, observer events) is + // diagnostics output, not a command result — it must land on stderr + // in every mode, including json, so it never mixes with the one + // parseable JSON document `result()` puts on stdout. + it('should write to diagnostics stream when provided, even in json mode', async () => { const { stream, output } = createMockStream(); @@ -72,7 +76,7 @@ describe('logger: output formats', () => { projectRoot, settings, config: DEFAULT_LOGGER_CONFIG, - console: stream, + diagnostics: stream, json: true, }); @@ -85,6 +89,26 @@ describe('logger: output formats', () => { }); + it('should not write event-stream output to the console stream, even in json mode', async () => { + + const { stream: consoleStream, output: consoleOutput } = createMockStream(); + + const logger = new Logger({ + projectRoot, + settings, + config: DEFAULT_LOGGER_CONFIG, + console: consoleStream, + json: true, + }); + + await logger.start(); + logger.info('test message'); + await logger.stop(); + + expect(consoleOutput.length).toBe(0); + + }); + it('should write to file stream when provided', async () => { const { stream: fileStream, output: fileOutput } = createMockStream(); @@ -106,9 +130,10 @@ describe('logger: output formats', () => { }); - it('should write to both console and file when both provided', async () => { + it('should route result() to console and info() to diagnostics separately, both also reaching file', async () => { const { stream: consoleStream, output: consoleOutput } = createMockStream(); + const { stream: diagnosticsStream, output: diagnosticsOutput } = createMockStream(); const { stream: fileStream, output: fileOutput } = createMockStream(); const logger = new Logger({ @@ -116,18 +141,22 @@ describe('logger: output formats', () => { settings, config: DEFAULT_LOGGER_CONFIG, console: consoleStream, + diagnostics: diagnosticsStream, file: fileStream, json: true, }); await logger.start(); logger.info('dual output'); + logger.result({ ok: true }); await logger.stop(); - expect(consoleOutput.length).toBeGreaterThan(0); + expect(diagnosticsOutput.length).toBeGreaterThan(0); + expect(diagnosticsOutput[0]).toContain('dual output'); + + expect(consoleOutput).toEqual(['{"ok":true}\n']); + expect(fileOutput.length).toBeGreaterThan(0); - expect(consoleOutput[0]).toContain('dual output'); - expect(fileOutput[0]).toContain('dual output'); }); @@ -143,7 +172,7 @@ describe('logger: output formats', () => { projectRoot, settings, config: DEFAULT_LOGGER_CONFIG, - console: stream, + diagnostics: stream, json: true, }); @@ -169,7 +198,7 @@ describe('logger: output formats', () => { projectRoot, settings, config: DEFAULT_LOGGER_CONFIG, - console: stream, + diagnostics: stream, json: true, }); @@ -192,7 +221,7 @@ describe('logger: output formats', () => { projectRoot, settings, config: { ...DEFAULT_LOGGER_CONFIG, level: 'verbose' }, - console: stream, + diagnostics: stream, json: true, }); @@ -220,7 +249,7 @@ describe('logger: output formats', () => { projectRoot, settings, config: { ...DEFAULT_LOGGER_CONFIG, level: 'verbose' }, - console: stream, + diagnostics: stream, json: true, }); @@ -305,7 +334,7 @@ describe('logger: output formats', () => { projectRoot, settings, config: DEFAULT_LOGGER_CONFIG, - console: stream, + diagnostics: stream, json: true, }); @@ -329,7 +358,7 @@ describe('logger: output formats', () => { projectRoot, settings, config: DEFAULT_LOGGER_CONFIG, - console: stream, + diagnostics: stream, json: true, }); diff --git a/tests/core/logger/timestamp.test.ts b/tests/core/logger/timestamp.test.ts new file mode 100644 index 00000000..b931ecd9 --- /dev/null +++ b/tests/core/logger/timestamp.test.ts @@ -0,0 +1,72 @@ +/** + * Log timestamp formatting tests. + * + * Locks the exact format (not just shape) for both formatters, since + * `output.test.ts` only regex-asserts the shape. Offset expectations are + * derived from `Date#getTimezoneOffset()` rather than hardcoded, since the + * host running these tests may be in any timezone. + */ +import { describe, it, expect } from 'bun:test'; +import { formatLogTimestamp, formatLogTimestampIso } from '../../../src/core/logger/timestamp.js'; + +describe('logger: timestamp', () => { + + describe('formatLogTimestamp', () => { + + it('should format as YY-MM-DD HH:mm:ss using local time fields', () => { + + const d = new Date(2024, 0, 15, 10, 30, 5); + + expect(formatLogTimestamp(d)).toBe('24-01-15 10:30:05'); + + }); + + it('should zero-pad single-digit month, day, hour, minute, and second', () => { + + const d = new Date(2005, 2, 4, 1, 2, 3); + + expect(formatLogTimestamp(d)).toBe('05-03-04 01:02:03'); + + }); + + }); + + describe('formatLogTimestampIso', () => { + + it('should format as YYYY-MM-DDTHH:mm:ss.SSS with the local UTC offset', () => { + + const d = new Date(2024, 0, 15, 10, 30, 5, 123); + + // getTimezoneOffset() is UTC-minus-local in minutes (positive when + // local is behind UTC), so the sign is flipped relative to it. + const offset = d.getTimezoneOffset(); + const sign = offset <= 0 ? '+' : '-'; + const absOffset = Math.abs(offset); + const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0'); + const offsetMinutes = String(absOffset % 60).padStart(2, '0'); + + expect(formatLogTimestampIso(d)).toBe( + `2024-01-15T10:30:05.123${sign}${offsetHours}:${offsetMinutes}`, + ); + + }); + + it('should zero-pad milliseconds to 3 digits', () => { + + const d = new Date(2024, 0, 15, 10, 30, 5, 7); + + expect(formatLogTimestampIso(d).slice(20, 23)).toBe('007'); + + }); + + it('should always emit a signed 2-digit:2-digit offset suffix', () => { + + const d = new Date(2024, 0, 15, 10, 30, 5, 123); + + expect(formatLogTimestampIso(d)).toMatch(/[+-]\d{2}:\d{2}$/); + + }); + + }); + +}); diff --git a/tests/core/mcp/server.test.ts b/tests/core/mcp/server.test.ts index 0b6dd52a..852469ba 100644 --- a/tests/core/mcp/server.test.ts +++ b/tests/core/mcp/server.test.ts @@ -4,7 +4,7 @@ * Uses InMemoryTransport + Client to exercise the full JSON-RPC * pipeline through createMcpServer without a real network transport. */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { z } from 'zod'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; @@ -256,19 +256,32 @@ describe('mcp: server dispatch (run_noorm_cmd)', () => { }); - it('should return isError with message and stack when handler throws', async () => { + it('should return isError with message only (no stack) and log the stack server-side when handler throws', async () => { + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); const { isError, parsed } = await callJson(client, 'run_noorm_cmd', { command: 'failing_cmd', payload: {}, }); - const body = parsed as { error: string; stack: string }; + const body = parsed as { error: string; stack?: string }; expect(isError).toBe(true); expect(body.error).toBe('boom'); - expect(typeof body.stack).toBe('string'); - expect(body.stack).toContain('boom'); + expect(body).not.toHaveProperty('stack'); + + expect(errorSpy).toHaveBeenCalled(); + const loggedArgs = errorSpy.mock.calls.flat(); + const loggedArg = loggedArgs.find((arg) => typeof arg === 'string' && arg.includes('boom')); + + expect(loggedArg).toBeDefined(); + // A plain `err.message` is just "boom" — a real stack has frame lines + // like " at functionName (file:line:col)", so this fails if the + // source regresses to logging the message instead of the stack. + expect(loggedArg).toMatch(/\n\s*at /); + + errorSpy.mockRestore(); }); diff --git a/tests/core/policy/check.test.ts b/tests/core/policy/check.test.ts index 96a8f6d3..0ac803e9 100644 --- a/tests/core/policy/check.test.ts +++ b/tests/core/policy/check.test.ts @@ -2,7 +2,7 @@ * Access policy: checkPolicy + guarded. */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, guarded } from '../../../src/core/policy/index.js'; +import { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, formatAccessTag, guarded } from '../../../src/core/policy/index.js'; import type { Channel, Permission, PolicyCell, PolicyTarget, Role } from '../../../src/core/policy/index.js'; /** @@ -30,12 +30,13 @@ const EXPECTED_MATRIX: Record> = { 'db:destroy': { viewer: 'deny', operator: 'deny', admin: 'confirm' }, 'config:rm': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, + 'change:rm': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, }; const PERMISSIONS: Permission[] = [ 'explore', 'sql:read', 'sql:write', 'sql:ddl', - 'change:run', 'change:ff', 'change:revert', + 'change:run', 'change:ff', 'change:revert', 'change:rm', 'run:build', 'run:file', 'run:dir', 'db:create', 'db:reset', 'db:destroy', 'config:rm', @@ -159,6 +160,30 @@ describe('policy: checkPolicy', () => { }); + it('should skip user-channel confirmation when NOORM_YES=yes (unified truthiness, not just 1/true)', () => { + + process.env['NOORM_YES'] = 'yes'; + + const check = checkPolicy('user', targetFor('operator', 'prod'), 'change:run'); + + expect(check.allowed).toBe(true); + expect(check.requiresConfirmation).toBe(false); + expect(check.confirmationPhrase).toBeUndefined(); + + }); + + it('should still require confirmation when NOORM_YES=0 (unified truthiness stays falsy for 0)', () => { + + process.env['NOORM_YES'] = '0'; + + const check = checkPolicy('user', targetFor('operator', 'prod'), 'change:run'); + + expect(check.allowed).toBe(true); + expect(check.requiresConfirmation).toBe(true); + expect(check.confirmationPhrase).toBe('yes-prod'); + + }); + it('should deny with a blockedReason when access.mcp is false', () => { const target: PolicyTarget = { name: 'invisible', access: { user: 'admin', mcp: false } }; @@ -268,3 +293,31 @@ describe('policy: guarded', () => { }); }); + +describe('policy: formatAccessTag', () => { + + it('should render "user: mcp:" for a guarded config', () => { + + const config: PolicyTarget = { name: 'prod', access: { user: 'operator', mcp: 'viewer' } }; + + expect(formatAccessTag(config)).toBe('user:operator mcp:viewer'); + + }); + + it('should render "mcp:off" when access.mcp is false', () => { + + const config: PolicyTarget = { name: 'prod', access: { user: 'viewer', mcp: false } }; + + expect(formatAccessTag(config)).toBe('user:viewer mcp:off'); + + }); + + it('should return null for an admin/admin (fully open) config', () => { + + const config: PolicyTarget = { name: 'prod', access: { user: 'admin', mcp: 'admin' } }; + + expect(formatAccessTag(config)).toBeNull(); + + }); + +}); diff --git a/tests/core/policy/visibility.test.ts b/tests/core/policy/visibility.test.ts new file mode 100644 index 00000000..03fd3fdb --- /dev/null +++ b/tests/core/policy/visibility.test.ts @@ -0,0 +1,38 @@ +/** + * Access policy: isVisibleToChannel fail-closed null-handling. + */ +import { describe, it, expect } from 'bun:test'; +import { isVisibleToChannel } from '../../../src/core/policy/index.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +describe('policy: isVisibleToChannel', () => { + + it('should deny the mcp channel when access is undefined', () => { + + expect(isVisibleToChannel(undefined, 'mcp')).toBe(false); + + }); + + it('should allow the user channel when access is undefined', () => { + + expect(isVisibleToChannel(undefined, 'user')).toBe(true); + + }); + + it('should deny the mcp channel when access.mcp is false', () => { + + const access: ConfigAccess = { user: 'admin', mcp: false }; + + expect(isVisibleToChannel(access, 'mcp')).toBe(false); + + }); + + it('should allow the mcp channel when access.mcp is a real role', () => { + + const access: ConfigAccess = { user: 'admin', mcp: 'viewer' }; + + expect(isVisibleToChannel(access, 'mcp')).toBe(true); + + }); + +}); diff --git a/tests/core/rpc/permissions.test.ts b/tests/core/rpc/permissions.test.ts index 567a7d3c..cd3a4609 100644 --- a/tests/core/rpc/permissions.test.ts +++ b/tests/core/rpc/permissions.test.ts @@ -25,6 +25,7 @@ const EXPECTED_PERMISSIONS: Record = { list_configs: 'open', connect: 'open', disconnect: 'open', + status: 'open', }; describe('rpc: command permissions', () => { diff --git a/tests/core/rpc/registry-integration.test.ts b/tests/core/rpc/registry-integration.test.ts index 2836c6f4..9a0a9063 100644 --- a/tests/core/rpc/registry-integration.test.ts +++ b/tests/core/rpc/registry-integration.test.ts @@ -7,7 +7,7 @@ const registry = createRegistry(); describe('rpc registry: completeness', () => { const expectedCommands = [ - 'connect', 'disconnect', + 'connect', 'disconnect', 'status', 'list_configs', 'overview', 'list', 'detail', 'sql', @@ -15,10 +15,10 @@ describe('rpc registry: completeness', () => { 'run_build', 'run_file', ]; - it('should have all 13 commands registered', () => { + it('should have all 14 commands registered', () => { const registered = registry.list().map(c => c.name); - expect(registered).toHaveLength(13); + expect(registered).toHaveLength(14); for (const name of expectedCommands) { @@ -47,6 +47,7 @@ describe('rpc registry: schema validation — valid inputs', () => { const validInputs: Record> = { connect: {}, disconnect: {}, + status: {}, list_configs: {}, overview: {}, list: { category: 'tables' }, diff --git a/tests/core/rpc/session-status.test.ts b/tests/core/rpc/session-status.test.ts new file mode 100644 index 00000000..58429290 --- /dev/null +++ b/tests/core/rpc/session-status.test.ts @@ -0,0 +1,230 @@ +/** + * rpc commands: status. + * + * Uses a real StateManager (via the module singleton `status` reads through + * `initState()`) rather than mocking state, so active-config resolution is + * proven end-to-end the same way `list-configs.test.ts` proves mcp-channel + * filtering. `setKeyOverride` supplies the encryption key in-memory so + * persistence never touches the real `~/.noorm/identity.key`. Only + * `RpcSession` (`channel`, `listConnections`, `hasConnection`) is mocked. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { initState, resetStateManager } from '../../../src/core/state/index.js'; +import { generateKeyPair, setKeyOverride, clearKeyOverride } from '../../../src/core/identity/index.js'; +import { sessionCommands } from '../../../src/rpc/commands/session.js'; +import type { SessionStatus } from '../../../src/rpc/commands/session.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { RpcSession } from '../../../src/rpc/types.js'; +import type { Channel } from '../../../src/core/policy/index.js'; + +/** `sessionCommands` is typed `RpcCommand[]` (generics erased) — narrow the handler's `unknown` result without casting. */ +function isSessionStatus(value: unknown): value is SessionStatus { + + return typeof value === 'object' && value !== null && 'connections' in value; + +} + +function testConfig(name: string, overrides: Partial = {}): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:' }, + ...overrides, + }; + +} + +interface MockSessionOptions { + channel?: Channel; + connections?: string[]; + connected?: string[]; +} + +function sessionFor(options: MockSessionOptions = {}): RpcSession { + + const connected = new Set(options.connected ?? options.connections ?? []); + + return { + channel: options.channel ?? 'user', + getContext: () => { + + throw new Error('not used by status'); + + }, + connect: async () => ({ name: 'x', dialect: 'sqlite', database: ':memory:', role: 'admin' }), + disconnect: async () => {}, + disconnectAll: async () => {}, + hasConnection: (config: string) => connected.has(config), + listConnections: () => options.connections ?? [], + }; + +} + +describe('rpc commands: status', () => { + + const cmd = sessionCommands.find((c) => c.name === 'status')!; + + let tempDir: string; + let originalEnvConfig: string | undefined; + + beforeEach(async () => { + + resetStateManager(); + tempDir = mkdtempSync(join(tmpdir(), 'noorm-session-status-')); + + const { privateKey } = await generateKeyPair(); + setKeyOverride(privateKey); + + await initState(tempDir); + + originalEnvConfig = process.env['NOORM_CONFIG']; + delete process.env['NOORM_CONFIG']; + + }); + + afterEach(() => { + + if (originalEnvConfig === undefined) { + + delete process.env['NOORM_CONFIG']; + + } + else { + + process.env['NOORM_CONFIG'] = originalEnvConfig; + + } + + clearKeyOverride(); + resetStateManager(); + rmSync(tempDir, { recursive: true, force: true }); + + }); + + it('should return empty connections and null active config when nothing is set up', async () => { + + const result = await cmd.handler({}, sessionFor()); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result).toEqual({ connections: [], activeConfig: null, activeConnected: false }); + + }); + + it('should reflect session.listConnections() in connections', async () => { + + const session = sessionFor({ connections: ['dev', 'staging'] }); + const result = await cmd.handler({}, session); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result.connections).toEqual(['dev', 'staging']); + + }); + + it('should reflect the state active config in activeConfig', async () => { + + const manager = await initState(tempDir); + await manager.setConfig('dev', testConfig('dev')); + await manager.setActiveConfig('dev'); + + const result = await cmd.handler({}, sessionFor()); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result.activeConfig).toBe('dev'); + + }); + + it('should let NOORM_CONFIG override the state active config', async () => { + + const manager = await initState(tempDir); + await manager.setConfig('dev', testConfig('dev')); + await manager.setConfig('prod', testConfig('prod')); + await manager.setActiveConfig('dev'); + + process.env['NOORM_CONFIG'] = 'prod'; + + const result = await cmd.handler({}, sessionFor()); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result.activeConfig).toBe('prod'); + + }); + + it('should report activeConnected: true when the active config is connected', async () => { + + const manager = await initState(tempDir); + await manager.setConfig('dev', testConfig('dev')); + await manager.setActiveConfig('dev'); + + const session = sessionFor({ connections: ['dev'] }); + const result = await cmd.handler({}, session); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result.activeConnected).toBe(true); + + }); + + it('should report activeConnected: false when the active config is not connected', async () => { + + const manager = await initState(tempDir); + await manager.setConfig('dev', testConfig('dev')); + await manager.setActiveConfig('dev'); + + const session = sessionFor({ connections: ['other'] }); + const result = await cmd.handler({}, session); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result.activeConnected).toBe(false); + + }); + + it('should hide the active config name on the mcp channel when access.mcp === false', async () => { + + const manager = await initState(tempDir); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + await manager.setActiveConfig('hidden'); + + const result = await cmd.handler({}, sessionFor({ channel: 'mcp' })); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result.activeConfig).toBeNull(); + expect(result.activeConnected).toBe(false); + + }); + + it('should report the active config name unfiltered on the user channel for the same hidden config', async () => { + + const manager = await initState(tempDir); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + await manager.setActiveConfig('hidden'); + + const result = await cmd.handler({}, sessionFor({ channel: 'user' })); + + if (!isSessionStatus(result)) throw new Error('expected a SessionStatus'); + + expect(result.activeConfig).toBe('hidden'); + + }); + + it('should accept an empty object as input', () => { + + const result = cmd.inputSchema.safeParse({}); + + expect(result.success).toBe(true); + + }); + +}); diff --git a/tests/core/runner/execute-files.test.ts b/tests/core/runner/execute-files.test.ts new file mode 100644 index 00000000..49281fba --- /dev/null +++ b/tests/core/runner/execute-files.test.ts @@ -0,0 +1,111 @@ +/** + * executeFiles / runBuild / runFiles guards (v1/49-54 CP9). + * + * An RCA ruled out any mechanism that could actually double-execute a file + * (kysely, tedious, discovery, the sequential loop). These tests instead + * pin the invariants that make a recurrence self-diagnosing: discovery + * uniqueness is enforced, not assumed, and each discovered file produces + * exactly one result. Runs against a real in-memory SQLite database so the + * duplicate guard is proven to fire before any SQL executes. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, SqliteDialect, sql } from 'kysely'; +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; + +import { runBuild, runFiles } from '../../../src/core/runner/runner.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { RunContext } from '../../../src/core/runner/types.js'; + +describe('runner: executeFiles — duplicate and exactly-once guards', () => { + + let db: Kysely; + let tempDir: string; + let sqlDir: string; + + function buildContext(): RunContext { + + return { + db, + configName: 'test', + identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, + projectRoot: tempDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'sqlite', + }; + + } + + beforeEach(async () => { + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-runner-cp9-test-')); + sqlDir = join(tempDir, 'sql'); + await mkdir(sqlDir, { recursive: true }); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + }); + + afterEach(async () => { + + await db.destroy(); + await rm(tempDir, { recursive: true, force: true }); + + }); + + it('should fail loudly instead of running a duplicated file twice', async () => { + + const filePath = join(sqlDir, '001_dup.sql'); + await writeFile(filePath, 'CREATE TABLE cp9_dup_test (id INTEGER PRIMARY KEY);\n', 'utf-8'); + + const result = await runFiles(buildContext(), [filePath, filePath]); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('Duplicate'); + expect(result.error).toContain('001_dup.sql'); + expect(result.filesRun).toBe(0); + + // The duplicate must be rejected before any SQL runs, not merely + // reported after the fact. + const tableCheck = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cp9_dup_test' + `.execute(db); + expect(tableCheck.rows).toHaveLength(0); + + }); + + it('should execute each discovered file exactly once', async () => { + + await writeFile( + join(sqlDir, '001_a.sql'), + 'CREATE TABLE cp9_once_a (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + await writeFile( + join(sqlDir, '002_b.sql'), + 'CREATE TABLE cp9_once_b (id INTEGER PRIMARY KEY);\n', + 'utf-8', + ); + + const result = await runBuild(buildContext(), sqlDir); + + expect(result.status).toBe('success'); + expect(result.filesRun).toBe(2); + expect(result.files).toHaveLength(2); + + const filepaths = result.files.map((f) => f.filepath); + expect(new Set(filepaths).size).toBe(filepaths.length); + + }); + +}); diff --git a/tests/core/runner/tracker.test.ts b/tests/core/runner/tracker.test.ts new file mode 100644 index 00000000..4972e033 --- /dev/null +++ b/tests/core/runner/tracker.test.ts @@ -0,0 +1,251 @@ +/** + * Runner tracker tests (v1/49-54 CP9/CP10). + * + * Uses a real in-memory SQLite database, not a mock -- the CP10 defect + * lives in `needsRun`'s `ORDER BY id DESC` picking up a row that a mocked + * tracker would never reproduce. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { Kysely, SqliteDialect, sql } from 'kysely'; +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; + +import { Tracker } from '../../../src/core/runner/tracker.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; +import type { CreateOperationData } from '../../../src/core/runner/types.js'; + +describe('runner: tracker', () => { + + let db: Kysely; + let tracker: Tracker; + + const baseOp: Omit = { + changeType: 'build', + configName: 'test', + executedBy: 'test@example.com', + }; + + beforeEach(async () => { + + db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + tracker = new Tracker(db, 'test', 'sqlite'); + + }); + + afterEach(async () => { + + await db.destroy(); + + }); + + describe('needsRun — excludeOperationId (CP10)', () => { + + it('should not treat the current operation\'s own upfront pending row as the newest record', async () => { + + // A prior, completed build ran this file successfully. + const priorOpId = await tracker.createOperation({ ...baseOp, name: 'build:prior' }); + await tracker.createFileRecords(priorOpId, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc123' }, + ]); + await tracker.updateFileExecution(priorOpId, 'sql/001.sql', 'success', 10); + + // A second build's `executeFiles` inserts a pending row for + // every file upfront (createFileRecords), before needsRun runs. + const currentOpId = await tracker.createOperation({ ...baseOp, name: 'build:current' }); + await tracker.createFileRecords(currentOpId, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc123' }, + ]); + + // Reproduces the defect: without exclusion, the newest row by id + // is this run's own pending record, and 'pending' always reads + // as 'new' -- checksum-based skipping is unreachable. + const withoutExclusion = await tracker.needsRun('sql/001.sql', 'abc123', false); + expect(withoutExclusion).toEqual({ needsRun: true, reason: 'new' }); + + // With exclusion, the lookup skips this operation's own pending + // row and finds the prior success -- the file is unchanged. + const result = await tracker.needsRun('sql/001.sql', 'abc123', false, currentOpId); + + expect(result.needsRun).toBe(false); + expect(result.skipReason).toBe('unchanged'); + + }); + + it('should still report a changed file as needing to run when excluding the current operation', async () => { + + const priorOpId = await tracker.createOperation({ ...baseOp, name: 'build:prior' }); + await tracker.createFileRecords(priorOpId, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'old-checksum' }, + ]); + await tracker.updateFileExecution(priorOpId, 'sql/001.sql', 'success', 10); + + const currentOpId = await tracker.createOperation({ ...baseOp, name: 'build:current' }); + await tracker.createFileRecords(currentOpId, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'new-checksum' }, + ]); + + const result = await tracker.needsRun('sql/001.sql', 'new-checksum', false, currentOpId); + + expect(result).toEqual({ + needsRun: true, + reason: 'changed', + previousChecksum: 'old-checksum', + }); + + }); + + it('should force re-run regardless of exclusion', async () => { + + const priorOpId = await tracker.createOperation({ ...baseOp, name: 'build:prior' }); + await tracker.createFileRecords(priorOpId, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc123' }, + ]); + await tracker.updateFileExecution(priorOpId, 'sql/001.sql', 'success', 10); + + const result = await tracker.needsRun('sql/001.sql', 'abc123', true, priorOpId); + + expect(result).toEqual({ needsRun: true, reason: 'force' }); + + }); + + }); + + describe('needsRun — DB error path (CP9.3)', () => { + + it('should distinguish a failed read from a genuinely new file', async () => { + + // Drop the table out from under the lookup so the SELECT itself + // fails -- distinct from "no matching row". + await sql`DROP TABLE __noorm_executions__`.execute(db); + + const result = await tracker.needsRun('sql/001.sql', 'abc123', false); + + expect(result.needsRun).toBe(true); + expect(result.reason).toBe('error'); + expect(result.reason).not.toBe('new'); + + }); + + }); + + describe('updateFileExecution — row count enforcement (CP9.2)', () => { + + it('should fail when no row matches', async () => { + + const opId = await tracker.createOperation({ ...baseOp, name: 'build:x' }); + + const err = await tracker.updateFileExecution(opId, 'sql/missing.sql', 'success', 1); + + expect(err).not.toBeNull(); + expect(err).toContain('sql/missing.sql'); + + }); + + it('should fail when more than one row matches, instead of reporting success', async () => { + + const opId = await tracker.createOperation({ ...baseOp, name: 'build:dup' }); + + // Two pending rows for the same (change_id, filepath) simulate + // the upstream defect this check exists to catch: a duplicate + // in the discovered file list. Before this fix, updateFileExecution + // tolerated any nonzero row count and reported this as a clean + // update -- masking the duplicate entirely. + await tracker.createFileRecords(opId, [ + { filepath: 'sql/dup.sql', fileType: 'sql', checksum: 'abc' }, + { filepath: 'sql/dup.sql', fileType: 'sql', checksum: 'abc' }, + ]); + + const err = await tracker.updateFileExecution(opId, 'sql/dup.sql', 'success', 1); + + expect(err).not.toBeNull(); + expect(err).toContain('sql/dup.sql'); + expect(err).toContain('2'); + + }); + + it('should succeed when exactly one row matches', async () => { + + const opId = await tracker.createOperation({ ...baseOp, name: 'build:ok' }); + await tracker.createFileRecords(opId, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc' }, + ]); + + const err = await tracker.updateFileExecution(opId, 'sql/001.sql', 'success', 1); + + expect(err).toBeNull(); + + }); + + }); + + describe('priorSuccessfulExecutions (CP9.4)', () => { + + it('should return prior successful executions, most recent first', async () => { + + const opId1 = await tracker.createOperation({ ...baseOp, name: 'build:2026-01-01' }); + await tracker.createFileRecords(opId1, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc' }, + ]); + await tracker.updateFileExecution(opId1, 'sql/001.sql', 'success', 1); + + const opId2 = await tracker.createOperation({ ...baseOp, name: 'build:2026-01-02' }); + await tracker.createFileRecords(opId2, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc' }, + ]); + await tracker.updateFileExecution(opId2, 'sql/001.sql', 'success', 1); + + const prior = await tracker.priorSuccessfulExecutions('sql/001.sql'); + + expect(prior).toEqual([ + { operationName: 'build:2026-01-02', operationId: opId2 }, + { operationName: 'build:2026-01-01', operationId: opId1 }, + ]); + + }); + + it('should exclude the current operation when asked', async () => { + + const opId1 = await tracker.createOperation({ ...baseOp, name: 'build:2026-01-01' }); + await tracker.createFileRecords(opId1, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc' }, + ]); + await tracker.updateFileExecution(opId1, 'sql/001.sql', 'success', 1); + + const prior = await tracker.priorSuccessfulExecutions('sql/001.sql', opId1); + + expect(prior).toHaveLength(0); + + }); + + it('should not count failed or skipped executions as prior successes', async () => { + + const opId = await tracker.createOperation({ ...baseOp, name: 'build:failed-only' }); + await tracker.createFileRecords(opId, [ + { filepath: 'sql/001.sql', fileType: 'sql', checksum: 'abc' }, + ]); + await tracker.updateFileExecution(opId, 'sql/001.sql', 'failed', 1, 'boom'); + + const prior = await tracker.priorSuccessfulExecutions('sql/001.sql'); + + expect(prior).toEqual([]); + + }); + + it('should return an empty array for a file that never ran', async () => { + + const prior = await tracker.priorSuccessfulExecutions('sql/never.sql'); + + expect(prior).toEqual([]); + + }); + + }); + +}); diff --git a/tests/core/state/manager.test.ts b/tests/core/state/manager.test.ts index c9643085..f1dd30b9 100644 --- a/tests/core/state/manager.test.ts +++ b/tests/core/state/manager.test.ts @@ -5,10 +5,12 @@ * polluting the project directory. */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; +import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync, statSync } from 'fs'; import { join, dirname } from 'path'; -import { StateManager, resetStateManager, getPackageVersion } from '../../../src/core/state/index.js'; -import type { Config } from '../../../src/core/config/types.js'; +import { StateManager, resetStateManager, getPackageVersion, InvalidSecretKeyError } from '../../../src/core/state/index.js'; +import type { Config, Stage } from '../../../src/core/config/types.js'; +import { ConfigStageLockedError } from '../../../src/core/config/index.js'; +import { SettingsProvider } from '../../../src/core/config/resolver.js'; import type { KnownUser } from '../../../src/core/identity/types.js'; import { generateKeyPair } from '../../../src/core/identity/crypto.js'; import { encrypt, decrypt } from '../../../src/core/state/encryption/index.js'; @@ -35,6 +37,28 @@ function createTestConfig(name: string, overrides: Partial = {}): Config } +/** + * Create a mock settings provider for testing. + */ +function createMockSettings(stages: Record = {}): SettingsProvider { + + const mock = { + getStage(name: string): Stage | null { + + return stages[name] ?? null; + + }, + findStageForConfig(configName: string): Stage | null { + + return stages[configName] ?? null; + + }, + }; + + return Object.assign(Object.create(SettingsProvider.prototype), mock); + +} + describe('state: manager', () => { let tempDir: string; @@ -431,6 +455,44 @@ describe('state: manager', () => { }); + describe('deleteConfig: locked stage guard', () => { + + it('should throw ConfigStageLockedError naming the stage when linked to a locked stage', async () => { + + await state.setConfig('prod', createTestConfig('prod')); + const settings = createMockSettings({ prod: { locked: true } }); + + await expect(state.deleteConfig('prod', settings)).rejects.toThrow( + ConfigStageLockedError, + ); + await expect(state.deleteConfig('prod', settings)).rejects.toThrow('prod'); + expect(state.getConfig('prod')).not.toBeNull(); + + }); + + it('should delete cleanly when linked to an unlocked stage', async () => { + + await state.setConfig('dev', createTestConfig('dev')); + const settings = createMockSettings({ dev: { locked: false } }); + + await state.deleteConfig('dev', settings); + + expect(state.getConfig('dev')).toBeNull(); + + }); + + it('should delete cleanly when no settings provider is given', async () => { + + await state.setConfig('staging', createTestConfig('staging')); + + await state.deleteConfig('staging'); + + expect(state.getConfig('staging')).toBeNull(); + + }); + + }); + }); // ───────────────────────────────────────────────────────────── @@ -470,6 +532,37 @@ describe('state: manager', () => { }); + it('should reject a key with spaces', async () => { + + await expect(state.setSecret('dev', 'key with spaces', 'v')).rejects.toThrow( + InvalidSecretKeyError, + ); + + }); + + it('should reject a key starting with a digit', async () => { + + await expect(state.setSecret('dev', '1abc', 'v')).rejects.toThrow( + InvalidSecretKeyError, + ); + + }); + + it('should reject a key with a hyphen', async () => { + + await expect(state.setSecret('dev', 'a-b', 'v')).rejects.toThrow( + InvalidSecretKeyError, + ); + + }); + + it('should accept valid keys', async () => { + + await expect(state.setSecret('dev', 'API_KEY', 'v')).resolves.toBeUndefined(); + await expect(state.setSecret('dev', 'db_password', 'v')).resolves.toBeUndefined(); + + }); + it('should list secret keys without values', async () => { await state.setSecret('dev', 'API_KEY', 'secret1'); @@ -643,6 +736,16 @@ describe('state: manager', () => { }); + it('should write statePath at mode 0600 after a persisting operation', async () => { + + await state.load(); + await state.setConfig('dev', createTestConfig('dev')); + + const stat = statSync(state.getStatePath()); + expect(stat.mode & 0o777).toBe(0o600); + + }); + }); // ───────────────────────────────────────────────────────────── @@ -889,6 +992,24 @@ describe('state: manager', () => { }); + it('should write statePath at mode 0600 after importEncrypted', async () => { + + await state.load(); + await state.setConfig('dev', createTestConfig('dev')); + const exported = state.exportEncrypted()!; + + const newState = new StateManager(tempDir, { + stateDir: '.test-state', + stateFile: 'imported-mode.enc', + privateKey: testPrivateKey, + }); + await newState.importEncrypted(exported); + + const stat = statSync(newState.getStatePath()); + expect(stat.mode & 0o777).toBe(0o600); + + }); + }); }); diff --git a/tests/core/teardown/operations.test.ts b/tests/core/teardown/operations.test.ts index 92ba8aa7..295f37de 100644 --- a/tests/core/teardown/operations.test.ts +++ b/tests/core/teardown/operations.test.ts @@ -7,6 +7,7 @@ */ import { describe, it, expect, vi } from 'bun:test'; +import { attempt } from '@logosdx/utils'; import { Kysely, DummyDriver, @@ -16,6 +17,7 @@ import { } from 'kysely'; import { isNoormTable, truncateData, teardownSchema } from '../../../src/core/teardown/index.js'; +import { observer } from '../../../src/core/observer.js'; // ───────────────────────────────────────────────────────────── // Helpers — mock Kysely that returns controlled table rows @@ -133,6 +135,76 @@ function createMockKyselyForTeardown(rows: { } +/** + * Like createMockKysely, but records every executed SQL statement (past + * the initial table-list query) and can inject a failure into any + * statement matching `failWhen`. Used to prove the FK re-enable + * guarantee: a disable/truncate failure must not skip the enable-FK + * phase, and enable-phase failures must not stop remaining enables. + * + * The mock always compiles through a PostgresAdapter/Compiler regardless + * of the `dialect` string passed to `truncateData` — same as + * `createMockKysely` above — because `sql.raw(...)` statements pass + * through untouched; only the SQL text (built by the dialect-specific + * teardown ops) differs, not the compiler. + */ +function createRecordingMockKysely( + tableRows: Record[], + failWhen?: (sqlText: string) => boolean, +) { + + const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + + const executed: string[] = []; + let callCount = 0; + const originalExecutor = db.getExecutor(); + + vi.spyOn(originalExecutor, 'provideConnection').mockImplementation(async (consumer) => { + + return consumer({ + executeQuery: vi.fn().mockImplementation((compiledQuery: { sql: string }) => { + + callCount++; + + // First query is listTables (includeNoormTables) + if (callCount === 1) { + + return Promise.resolve({ rows: tableRows }); + + } + + const stmt = compiledQuery.sql; + executed.push(stmt); + + if (failWhen?.(stmt)) { + + return Promise.reject(new Error(`injected failure: ${stmt}`)); + + } + + return Promise.resolve({ rows: [] }); + + }), + streamQuery: () => { + + throw new Error('not implemented'); + + }, + }); + + }); + + return { db, executed }; + +} + describe('teardown: operations', () => { @@ -451,6 +523,139 @@ describe('teardown: truncateData session-level FK toggle dialects', () => { }); +// ───────────────────────────────────────────────────────────── +// truncateData — FK re-enable guarantee (v1-03) +// A mid-truncate failure must never leave FK enforcement off: the +// enable-FK phase always executes, the original disable/truncate error +// still surfaces, and enable-phase failures don't stop remaining enables. +// ───────────────────────────────────────────────────────────── + +describe('teardown: truncateData FK re-enable guarantee (v1-03)', () => { + + it('mssql: mid-truncate DELETE failure still executes every enable-FK statement, throws the injected error', async () => { + + const { db, executed } = createRecordingMockKysely( + [ + { table_name: 'users', schema_name: 'dbo', column_count: 1, row_count: 0 }, + { table_name: 'posts', schema_name: 'dbo', column_count: 1, row_count: 0 }, + ], + (stmt) => stmt.includes('DELETE FROM [users]'), + ); + + const [, err] = await attempt(() => truncateData(db, 'mssql')); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('DELETE FROM [users]'); + + const checks = executed.filter((s) => s.includes('CHECK CONSTRAINT ALL') && !s.includes('NOCHECK')); + expect(checks).toEqual([ + 'ALTER TABLE [users] CHECK CONSTRAINT ALL', + 'ALTER TABLE [posts] CHECK CONSTRAINT ALL', + ]); + + }); + + it('postgres: mid-truncate failure still executes the enable statement, throws the original error', async () => { + + const { db, executed } = createRecordingMockKysely( + [tableRow('users')], + (stmt) => stmt.startsWith('TRUNCATE TABLE'), + ); + + const [, err] = await attempt(() => truncateData(db, 'postgres')); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('TRUNCATE TABLE'); + + expect(executed).toContain('SET session_replication_role = \'origin\''); + + }); + + it('mssql: enable-only failure on one table still executes the remaining enable statements, throws the enable error', async () => { + + const { db, executed } = createRecordingMockKysely( + [ + { table_name: 'users', schema_name: 'dbo', column_count: 1, row_count: 0 }, + { table_name: 'posts', schema_name: 'dbo', column_count: 1, row_count: 0 }, + ], + (stmt) => stmt === 'ALTER TABLE [users] CHECK CONSTRAINT ALL', + ); + + const [, err] = await attempt(() => truncateData(db, 'mssql')); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('ALTER TABLE [users] CHECK CONSTRAINT ALL'); + + const checks = executed.filter((s) => s.includes('CHECK CONSTRAINT ALL') && !s.includes('NOCHECK')); + expect(checks).toEqual([ + 'ALTER TABLE [users] CHECK CONSTRAINT ALL', + 'ALTER TABLE [posts] CHECK CONSTRAINT ALL', + ]); + + }); + + it('mssql: truncate failure and enable failure both occur — the thrown error is the truncate one', async () => { + + const { db, executed } = createRecordingMockKysely( + [ + { table_name: 'users', schema_name: 'dbo', column_count: 1, row_count: 0 }, + ], + (stmt) => stmt.includes('DELETE FROM [users]') || stmt === 'ALTER TABLE [users] CHECK CONSTRAINT ALL', + ); + + const [, err] = await attempt(() => truncateData(db, 'mssql')); + + expect(err).toBeInstanceOf(Error); + // The DELETE failure is captured first and takes priority over the + // later CHECK CONSTRAINT ALL failure — the caller needs to know why + // the truncate itself broke. + expect(err?.message).toContain('DELETE FROM [users]'); + expect(err?.message).not.toContain('CHECK CONSTRAINT ALL'); + + // The enable phase must still have been attempted despite the + // earlier truncate failure, even though it also failed. + expect(executed).toContain('ALTER TABLE [users] CHECK CONSTRAINT ALL'); + + }); + + it('emits teardown:error for each enable-FK failure, not just the first', async () => { + + const { db } = createRecordingMockKysely( + [ + { table_name: 'users', schema_name: 'dbo', column_count: 1, row_count: 0 }, + { table_name: 'posts', schema_name: 'dbo', column_count: 1, row_count: 0 }, + ], + (stmt) => stmt.includes('CHECK CONSTRAINT ALL') && !stmt.includes('NOCHECK'), + ); + + const events: Array<{ error: Error; object: string | null }> = []; + const unsub = observer.on('teardown:error', (data) => events.push(data)); + + try { + + await attempt(() => truncateData(db, 'mssql')); + + const enableErrorEvents = events.filter( + (e) => e.object?.includes('CHECK CONSTRAINT ALL') && !e.object.includes('NOCHECK'), + ); + + // Both tables' CHECK CONSTRAINT ALL fail — each must emit its own + // teardown:error, proving the loop doesn't stop after the first. + expect(enableErrorEvents.length).toBe(2); + expect(enableErrorEvents[0]!.object).toBe('ALTER TABLE [users] CHECK CONSTRAINT ALL'); + expect(enableErrorEvents[1]!.object).toBe('ALTER TABLE [posts] CHECK CONSTRAINT ALL'); + + } + finally { + + unsub(); + + } + + }); + +}); + // ───────────────────────────────────────────────────────────── // teardownSchema — preserveTables filtering (dryRun, real pipeline) // ───────────────────────────────────────────────────────────── diff --git a/tests/core/template/context.test.ts b/tests/core/template/context.test.ts new file mode 100644 index 00000000..1b72b0e1 --- /dev/null +++ b/tests/core/template/context.test.ts @@ -0,0 +1,93 @@ +/** + * Template context builder tests — the $.secrets proxy. + * + * $.secrets used to be a plain object: a missing key read as `undefined`, + * which sqlQuote then stringified into the literal SQL text 'undefined' + * (noorm#50). These tests pin the replacement contract: a missing key + * throws naming it, while existence can still be probed without throwing. + */ +import { describe, it, expect } from 'bun:test'; +import path from 'node:path'; +import { buildContext, MissingSecretError } from '../../../src/core/template/context.js'; + +const FIXTURES_DIR = path.join(import.meta.dirname, 'fixtures/engine'); +const TEMPLATE_PATH = path.join(FIXTURES_DIR, 'template.sql.tmpl'); + +describe('template: context — secrets proxy', () => { + + it('should throw MissingSecretError naming the key on an unresolved secret', async () => { + + const ctx = await buildContext(TEMPLATE_PATH, { + projectRoot: FIXTURES_DIR, + secrets: { API_KEY: 'secret123' }, + }); + + expect(() => ctx.secrets.MISSING).toThrow(MissingSecretError); + expect(() => ctx.secrets.MISSING).toThrow(/MISSING/); + expect(() => ctx.secrets.MISSING).toThrow(/config-local/); + + }); + + it('should name every tier it searches, and only those', async () => { + + // Every render-path caller sources `secrets` from buildSecretsContext, + // which merges config-local, global-local, and vault. The message has + // to match that set exactly: naming a tier nobody searched sends the + // reader looking in the wrong place, omitting one hides where the + // value should have come from. This test is the thing that fails when + // the two drift apart. + const ctx = await buildContext(TEMPLATE_PATH, { + projectRoot: FIXTURES_DIR, + secrets: {}, + }); + + expect(() => ctx.secrets.MISSING).toThrow(/config-local/); + expect(() => ctx.secrets.MISSING).toThrow(/global-local/); + expect(() => ctx.secrets.MISSING).toThrow(/vault/); + + }); + + it('should still resolve a present key, including through ??', async () => { + + const ctx = await buildContext(TEMPLATE_PATH, { + projectRoot: FIXTURES_DIR, + secrets: { API_KEY: 'secret123' }, + }); + + expect(ctx.secrets.API_KEY).toBe('secret123'); + expect(ctx.secrets.API_KEY ?? 'fallback').toBe('secret123'); + + }); + + it('should support `in` to probe for an optional secret without throwing', async () => { + + const ctx = await buildContext(TEMPLATE_PATH, { + projectRoot: FIXTURES_DIR, + secrets: { API_KEY: 'secret123' }, + }); + + expect('API_KEY' in ctx.secrets).toBe(true); + expect('MISSING' in ctx.secrets).toBe(false); + + const optional = 'MISSING' in ctx.secrets ? ctx.secrets.MISSING : 'fallback'; + + expect(optional).toBe('fallback'); + + }); + + it('should not throw for Object.keys, spread, or JSON.stringify of the context', async () => { + + const ctx = await buildContext(TEMPLATE_PATH, { + projectRoot: FIXTURES_DIR, + secrets: { API_KEY: 'secret123' }, + }); + + expect(Object.keys(ctx.secrets)).toEqual(['API_KEY']); + expect({ ...ctx.secrets }).toEqual({ API_KEY: 'secret123' }); + + expect(() => JSON.stringify(ctx)).not.toThrow(); + expect(JSON.parse(JSON.stringify(ctx)).secrets).toEqual({ API_KEY: 'secret123' }); + + }); + +}); diff --git a/tests/core/template/engine.test.ts b/tests/core/template/engine.test.ts index 9d8dcb6c..5cf8f1d5 100644 --- a/tests/core/template/engine.test.ts +++ b/tests/core/template/engine.test.ts @@ -182,6 +182,21 @@ describe('template: engine', () => { }); + it('should fail loudly instead of rendering "undefined" when a secret is unresolved', async () => { + + // Regression for noorm#50: a missing secret used to render as the + // literal SQL text 'undefined' and apply successfully. + const filepath = path.join(FIXTURES_DIR, 'secrets.sql.tmpl'); + + await expect( + processFile(filepath, { + projectRoot: FIXTURES_DIR, + secrets: {}, + }), + ).rejects.toThrow(/API_KEY/); + + }); + it('should provide built-in helpers', async () => { const filepath = path.join(FIXTURES_DIR, 'builtin.sql.tmpl'); diff --git a/tests/core/template/utils.test.ts b/tests/core/template/utils.test.ts index f0dd3914..62c43a04 100644 --- a/tests/core/template/utils.test.ts +++ b/tests/core/template/utils.test.ts @@ -8,6 +8,7 @@ import { sqlQuote, generateUuid, isoNow, + UndefinedSqlValueError, } from '../../../src/core/template/utils.js'; describe('template: utils', () => { @@ -107,6 +108,14 @@ describe('template: utils', () => { }); + it('should throw UndefinedSqlValueError for undefined, distinct from null', () => { + + // A missing value must never render as the SQL literal 'undefined' + // (noorm#50) — null is a legitimate SQL value, undefined is not. + expect(() => sqlQuote(undefined)).toThrow(UndefinedSqlValueError); + + }); + it('should handle empty string', () => { expect(sqlQuote('')).toBe("''"); diff --git a/tests/core/transfer/fk-restore.test.ts b/tests/core/transfer/fk-restore.test.ts new file mode 100644 index 00000000..99a0d87e --- /dev/null +++ b/tests/core/transfer/fk-restore.test.ts @@ -0,0 +1,162 @@ +/** + * Transfer FK-restore surfacing tests (v1-03). + * + * Drives executeTransfer directly against a mocked destination Kysely + * connection — no DB container required. Proves TransferResult.fkChecksRestored + * is false only when the enable-FK attempt fails, true otherwise (success, + * and disableForeignKeys: false where checks were never touched). + */ +import { describe, it, expect, vi } from 'bun:test'; + +import { + Kysely, + DummyDriver, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely'; + +import type { DualConnectionContext } from '../../../src/core/db/dual.js'; +import type { TransferPlan } from '../../../src/core/transfer/types.js'; +import type { NoormDatabase } from '../../../src/core/shared/tables.js'; + +import { executeTransfer } from '../../../src/core/transfer/executor.js'; +import { observer } from '../../../src/core/observer.js'; +import { makeTestConfig, TEST_CONNECTIONS } from '../../utils/db.js'; + +// ───────────────────────────────────────────────────────────── +// Helpers — mock Kysely that records executed SQL and can inject a +// failure into any statement matching `failWhen`. +// ───────────────────────────────────────────────────────────── + +function createRecordingDb(failWhen?: (sqlText: string) => boolean) { + + const db = new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + + const executed: string[] = []; + const originalExecutor = db.getExecutor(); + + vi.spyOn(originalExecutor, 'provideConnection').mockImplementation(async (consumer) => { + + return consumer({ + executeQuery: vi.fn().mockImplementation((compiledQuery: { sql: string }) => { + + const stmt = compiledQuery.sql; + executed.push(stmt); + + if (failWhen?.(stmt)) { + + return Promise.reject(new Error(`injected failure: ${stmt}`)); + + } + + return Promise.resolve({ rows: [] }); + + }), + streamQuery: () => { + + throw new Error('not implemented'); + + }, + }); + + }); + + return { db, executed }; + +} + +/** + * Minimal DualConnectionContext. `plan.tables` stays empty in every test + * here, so the source connection is never touched — only `destination.db` + * matters for the FK disable/enable calls under test. + */ +function makeContext(destDb: Kysely): DualConnectionContext { + + return { + source: { + config: makeTestConfig('fk_restore_source', TEST_CONNECTIONS.postgres), + db: destDb, + dialect: 'postgres', + }, + destination: { + config: makeTestConfig('fk_restore_dest', TEST_CONNECTIONS.postgres), + db: destDb, + dialect: 'postgres', + }, + }; + +} + +const emptyPlan: TransferPlan = { + tables: [], + sameServer: false, + estimatedRows: 0, + warnings: [], + crossDialect: false, + sourceDialect: 'postgres', + destinationDialect: 'postgres', +}; + +describe('transfer: executeTransfer fkChecksRestored', () => { + + it('is false when the enable-FK statement fails, status stays unaffected, and an error event is emitted', async () => { + + // postgresTransferOperations.getEnableFKSql() -> 'SET session_replication_role = DEFAULT' + const { db } = createRecordingDb((stmt) => stmt.includes('DEFAULT')); + const ctx = makeContext(db); + + const events: Array<{ source: string; error: Error }> = []; + const unsub = observer.on('error', (data) => events.push(data)); + + try { + + const [result, err] = await executeTransfer(ctx, emptyPlan, {}); + + expect(err).toBeNull(); + expect(result?.fkChecksRestored).toBe(false); + expect(result?.status).toBe('success'); + expect(events.some((e) => e.source === 'transfer')).toBe(true); + + } + finally { + + unsub(); + + } + + }); + + it('is true when FK checks disable/enable both succeed', async () => { + + const { db } = createRecordingDb(); + const ctx = makeContext(db); + + const [result, err] = await executeTransfer(ctx, emptyPlan, {}); + + expect(err).toBeNull(); + expect(result?.fkChecksRestored).toBe(true); + + }); + + it('is true when disableForeignKeys: false — checks were never touched', async () => { + + const { db, executed } = createRecordingDb(); + const ctx = makeContext(db); + + const [result, err] = await executeTransfer(ctx, emptyPlan, { disableForeignKeys: false }); + + expect(err).toBeNull(); + expect(result?.fkChecksRestored).toBe(true); + expect(executed).toEqual([]); + + }); + +}); diff --git a/tests/core/update/checksum.test.ts b/tests/core/update/checksum.test.ts new file mode 100644 index 00000000..07dd0286 --- /dev/null +++ b/tests/core/update/checksum.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for checksum verification (`checksum.ts`). + * + * `parseChecksums`/`sha256File` are pure and unit-tested directly. + * `verifyChecksum` is exercised against a real local HTTP server (no fetch + * mocks) because the behavior under test — `insecure` NEVER bypassing a + * confirmed mismatch — is the single most important invariant in this + * ticket; a fixture-driven mock could hide a bug in the actual fetch/compare + * wiring that only a real request/response round-trip would surface. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { attempt } from '@logosdx/utils'; + +import { parseChecksums, sha256File, verifyChecksum, ChecksumError } from '../../../src/core/update/checksum.js'; + +let server: ReturnType; +let baseUrl: string; +let workDir: string; + +const ASSET_NAME = 'noorm-test-asset'; + +// The "correct" binary bytes and their sha256 — what a legitimate release +// would serve, and what checksums.txt correctly records for it. +const GOOD_PAYLOAD = new Uint8Array(2048).fill(1); +const GOOD_HASH = new Bun.CryptoHasher('sha256').update(GOOD_PAYLOAD).digest('hex'); + +// Different bytes entirely — simulates a corrupted download or a tampered +// asset: checksums.txt still records GOOD_HASH for ASSET_NAME, but this file +// on disk hashes to something else. +const TAMPERED_PAYLOAD = new Uint8Array(2048).fill(2); +const TAMPERED_HASH = new Bun.CryptoHasher('sha256').update(TAMPERED_PAYLOAD).digest('hex'); + +let goodFilePath: string; +let tamperedFilePath: string; + +beforeAll(async () => { + + workDir = await mkdtemp(join(tmpdir(), 'noorm-checksum-test-')); + + goodFilePath = join(workDir, 'good.bin'); + tamperedFilePath = join(workDir, 'tampered.bin'); + + await writeFile(goodFilePath, GOOD_PAYLOAD); + await writeFile(tamperedFilePath, TAMPERED_PAYLOAD); + + server = Bun.serve({ + port: 0, + fetch(req) { + + const url = new URL(req.url); + + // Legitimate checksums.txt: records the GOOD hash for ASSET_NAME. + if (url.pathname === '/checksums.txt') { + + return new Response(`${GOOD_HASH} ${ASSET_NAME}\n`); + + } + + // Reachable, but has no entry for ASSET_NAME — "can't verify" + // just like an unreachable file, per the spec's Outline. + if (url.pathname === '/checksums-no-entry.txt') { + + return new Response(`${GOOD_HASH} some-other-asset\n`); + + } + + return new Response('not found', { status: 404 }); + + }, + }); + + baseUrl = `http://localhost:${server.port}`; + +}); + +afterAll(async () => { + + server.stop(true); + await rm(workDir, { recursive: true, force: true }); + +}); + +describe('checksum: parseChecksums', () => { + + it('parses a standard two-space-separated shasum line', () => { + + const map = parseChecksums(`${GOOD_HASH} ${ASSET_NAME}\n`); + + expect(map[ASSET_NAME]).toBe(GOOD_HASH); + + }); + + it('parses the optional `*` binary-mode prefix', () => { + + const map = parseChecksums(`${GOOD_HASH} *${ASSET_NAME}\n`); + + expect(map[ASSET_NAME]).toBe(GOOD_HASH); + + }); + + it('lowercases an uppercase hash', () => { + + const map = parseChecksums(`${GOOD_HASH.toUpperCase()} ${ASSET_NAME}\n`); + + expect(map[ASSET_NAME]).toBe(GOOD_HASH); + + }); + + it('parses multiple lines, one entry per asset', () => { + + const text = `${GOOD_HASH} asset-one\n${TAMPERED_HASH} asset-two\n`; + const map = parseChecksums(text); + + expect(map['asset-one']).toBe(GOOD_HASH); + expect(map['asset-two']).toBe(TAMPERED_HASH); + + }); + + it('skips blank and malformed lines', () => { + + const text = `\n\nnot-a-hash ${ASSET_NAME}\n${GOOD_HASH} ${ASSET_NAME}\n\n`; + const map = parseChecksums(text); + + expect(Object.keys(map)).toHaveLength(1); + expect(map[ASSET_NAME]).toBe(GOOD_HASH); + + }); + +}); + +describe('checksum: sha256File', () => { + + it('computes the sha256 hex digest of a file on disk', async () => { + + const hash = await sha256File(goodFilePath); + + expect(hash).toBe(GOOD_HASH); + + }); + + it('produces different digests for different file contents', async () => { + + const hash = await sha256File(tamperedFilePath); + + expect(hash).toBe(TAMPERED_HASH); + expect(hash).not.toBe(GOOD_HASH); + + }); + +}); + +describe('checksum: verifyChecksum', () => { + + it('resolves when the file matches the recorded checksum', async () => { + + const [, err] = await attempt(() => verifyChecksum({ + checksumsUrl: `${baseUrl}/checksums.txt`, + assetName: ASSET_NAME, + filePath: goodFilePath, + insecure: false, + })); + + expect(err).toBeNull(); + + }); + + it('throws ChecksumError("mismatch") on a tampered file, and insecure does NOT bypass it', async () => { + + const [, secureErr] = await attempt(() => verifyChecksum({ + checksumsUrl: `${baseUrl}/checksums.txt`, + assetName: ASSET_NAME, + filePath: tamperedFilePath, + insecure: false, + })); + + expect(secureErr).toBeInstanceOf(ChecksumError); + if (secureErr instanceof ChecksumError) { + + expect(secureErr.reason).toBe('mismatch'); + + } + + // The critical invariant: insecure: true must NOT suppress a confirmed + // mismatch — unlike the "unreachable" cases below, this throw is + // unconditional. + const [, insecureErr] = await attempt(() => verifyChecksum({ + checksumsUrl: `${baseUrl}/checksums.txt`, + assetName: ASSET_NAME, + filePath: tamperedFilePath, + insecure: true, + })); + + expect(insecureErr).toBeInstanceOf(ChecksumError); + if (insecureErr instanceof ChecksumError) { + + expect(insecureErr.reason).toBe('mismatch'); + + } + + }); + + it('throws ChecksumError("unreachable") when checksums.txt 404s and insecure is false', async () => { + + const [, err] = await attempt(() => verifyChecksum({ + checksumsUrl: `${baseUrl}/does-not-exist.txt`, + assetName: ASSET_NAME, + filePath: goodFilePath, + insecure: false, + })); + + expect(err).toBeInstanceOf(ChecksumError); + if (err instanceof ChecksumError) { + + expect(err.reason).toBe('unreachable'); + + } + + }); + + it('resolves without throwing when checksums.txt 404s and insecure is true', async () => { + + const [, err] = await attempt(() => verifyChecksum({ + checksumsUrl: `${baseUrl}/does-not-exist.txt`, + assetName: ASSET_NAME, + filePath: goodFilePath, + insecure: true, + })); + + expect(err).toBeNull(); + + }); + + it('treats a checksums.txt with no entry for this asset the same as unreachable', async () => { + + const [, secureErr] = await attempt(() => verifyChecksum({ + checksumsUrl: `${baseUrl}/checksums-no-entry.txt`, + assetName: ASSET_NAME, + filePath: goodFilePath, + insecure: false, + })); + + expect(secureErr).toBeInstanceOf(ChecksumError); + if (secureErr instanceof ChecksumError) { + + expect(secureErr.reason).toBe('unreachable'); + + } + + const [, insecureErr] = await attempt(() => verifyChecksum({ + checksumsUrl: `${baseUrl}/checksums-no-entry.txt`, + assetName: ASSET_NAME, + filePath: goodFilePath, + insecure: true, + })); + + expect(insecureErr).toBeNull(); + + }); + +}); diff --git a/tests/core/update/updater.test.ts b/tests/core/update/updater.test.ts index 0290adff..94c75630 100644 --- a/tests/core/update/updater.test.ts +++ b/tests/core/update/updater.test.ts @@ -14,7 +14,7 @@ import { readFile, stat, mkdtemp, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -import { attempt } from '@logosdx/utils'; +import { attempt, wait } from '@logosdx/utils'; import { downloadToFile } from '../../../src/core/update/updater.js'; import { observer } from '../../../src/core/observer.js'; @@ -35,6 +35,48 @@ const RESUME_ETAG = '"asset-v1"'; // (sent a byte range) rather than restarting from scratch. let resumeRanges: Array = []; +// 128 KiB — enough to force multiple pull() cycles for the 1.5 MB PAYLOAD, so +// the mock server delivers a body over several ticks instead of one +// native-buffered blob. A single-tick, instantly-complete body is what +// triggers a Bun runtime race in `for await` iteration over `response.body` +// — this keeps the streaming tests deterministic without +// touching production code. +const CHUNK_SIZE = 128 * 1024; + +/** + * Streams `data` out in bounded pieces via a pull()-based ReadableStream, + * yielding cooperatively between enqueues so consumers see the body arrive + * over multiple ticks rather than as one instantly-complete chunk. + * + * @example + * new Response(chunkedStream(PAYLOAD)); + */ +function chunkedStream(data: Uint8Array, chunkSize = CHUNK_SIZE): ReadableStream { + + let offset = 0; + + return new ReadableStream({ + async pull(controller) { + + if (offset >= data.byteLength) { + + controller.close(); + + return; + + } + + const end = Math.min(offset + chunkSize, data.byteLength); + controller.enqueue(data.slice(offset, end)); + offset = end; + + await wait(0); + + }, + }); + +} + beforeAll(async () => { workDir = await mkdtemp(join(tmpdir(), 'noorm-updater-test-')); @@ -48,7 +90,7 @@ beforeAll(async () => { // Healthy download with a correct Content-Length. if (url.pathname === '/ok') { - return new Response(PAYLOAD, { + return new Response(chunkedStream(PAYLOAD), { headers: { 'content-length': String(PAYLOAD.byteLength) }, }); @@ -104,7 +146,7 @@ beforeAll(async () => { const startByte = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0); - return new Response(PAYLOAD.slice(startByte), { + return new Response(chunkedStream(PAYLOAD.slice(startByte)), { status: 206, headers: { etag: RESUME_ETAG, diff --git a/tests/core/vault/key.test.ts b/tests/core/vault/key.test.ts new file mode 100644 index 00000000..026306de --- /dev/null +++ b/tests/core/vault/key.test.ts @@ -0,0 +1,188 @@ +/** + * Vault key crypto tests. + * + * Pins `generateVaultKey`/`encryptVaultKey`/`decryptVaultKey`/`encryptSecret`/`decryptSecret` + * (`src/core/vault/key.ts`) directly — pure `node:crypto` wrapping, no DB dependency. + * The third-identity-decrypt-fails test is the ticket's named core security property: + * a vault key encrypted for one recipient must never be recoverable by any other keypair. + */ +import { describe, it, expect } from 'bun:test'; + +import { + generateVaultKey, + encryptVaultKey, + decryptVaultKey, + encryptSecret, + decryptSecret, +} from '../../../src/core/vault/key.js'; +import { generateKeyPair } from '../../../src/core/identity/index.js'; +import type { EncryptedVaultKey } from '../../../src/core/vault/types.js'; + +/** + * Flip one hex character in a hex string, at a position guaranteed to change + * the resulting byte (never flips to the same nibble). + */ +function flipOneHexChar(hex: string): string { + + const chars = hex.split(''); + const targetIndex = 0; + const current = chars[targetIndex]; + const flipped = current === '0' ? '1' : '0'; + + chars[targetIndex] = flipped; + + return chars.join(''); + +} + +describe('vault: generateVaultKey', () => { + + it('should return a 32-byte Buffer', () => { + + const key = generateVaultKey(); + + expect(key).toBeInstanceOf(Buffer); + expect(key.length).toBe(32); + + }); + + it('should produce different bytes on each call (not a fixed/zero key)', () => { + + const first = generateVaultKey(); + const second = generateVaultKey(); + + expect(first.equals(second)).toBe(false); + expect(first.equals(Buffer.alloc(32))).toBe(false); + + }); + +}); + +describe('vault: encryptVaultKey / decryptVaultKey round trip', () => { + + it('should decrypt to the original vault key using the recipient\'s own private key', () => { + + const identityA = generateKeyPair(); + const vaultKey = generateVaultKey(); + + const encrypted = encryptVaultKey(vaultKey, identityA.publicKey); + const decrypted = decryptVaultKey(encrypted, identityA.privateKey); + + expect(decrypted).toBeInstanceOf(Buffer); + expect(decrypted?.equals(vaultKey)).toBe(true); + + }); + + it('should return null (not throw) when a third, unrelated identity attempts decryption', () => { + + const identityB = generateKeyPair(); + const identityC = generateKeyPair(); + const vaultKey = generateVaultKey(); + + const encrypted = encryptVaultKey(vaultKey, identityB.publicKey); + + const decrypted = decryptVaultKey(encrypted, identityC.privateKey); + + expect(decrypted).toBeNull(); + + }); + + it('should not leak any bytes of the original key to a third identity\'s failed decryption', () => { + + const identityB = generateKeyPair(); + const identityC = generateKeyPair(); + const vaultKey = generateVaultKey(); + + const encrypted = encryptVaultKey(vaultKey, identityB.publicKey); + + // decryptVaultKey returns null on auth failure — assert directly there's + // no partial-plaintext leak path (e.g. returning update() bytes before + // the final()/authTag check throws). + const decrypted = decryptVaultKey(encrypted, identityC.privateKey); + + expect(decrypted).toBeNull(); + expect(decrypted).not.toEqual(vaultKey); + + }); + + it('should return null when the authTag is tampered (one hex character flipped)', () => { + + const identityA = generateKeyPair(); + const vaultKey = generateVaultKey(); + + const encrypted = encryptVaultKey(vaultKey, identityA.publicKey); + const tampered: EncryptedVaultKey = { + ...encrypted, + authTag: flipOneHexChar(encrypted.authTag), + }; + + const decrypted = decryptVaultKey(tampered, identityA.privateKey); + + expect(decrypted).toBeNull(); + + }); + + it('should return null when the ciphertext is tampered (one hex character flipped)', () => { + + const identityA = generateKeyPair(); + const vaultKey = generateVaultKey(); + + const encrypted = encryptVaultKey(vaultKey, identityA.publicKey); + const tampered: EncryptedVaultKey = { + ...encrypted, + ciphertext: flipOneHexChar(encrypted.ciphertext), + }; + + const decrypted = decryptVaultKey(tampered, identityA.privateKey); + + expect(decrypted).toBeNull(); + + }); + +}); + +describe('vault: encryptSecret / decryptSecret round trip', () => { + + it('should decrypt to the original plaintext using the same vault key', () => { + + const vaultKey = generateVaultKey(); + const plaintext = 'sk-live-super-secret-value'; + + const encrypted = encryptSecret(plaintext, vaultKey); + const decrypted = decryptSecret(encrypted, vaultKey); + + expect(decrypted).toBe(plaintext); + + }); + + it('should return null when decrypted with the wrong vault key', () => { + + const vaultKey = generateVaultKey(); + const wrongKey = generateVaultKey(); + const plaintext = 'sk-live-super-secret-value'; + + const encrypted = encryptSecret(plaintext, vaultKey); + const decrypted = decryptSecret(encrypted, wrongKey); + + expect(decrypted).toBeNull(); + + }); + + it('should return null when the ciphertext is tampered (one hex character flipped)', () => { + + const vaultKey = generateVaultKey(); + const plaintext = 'sk-live-super-secret-value'; + + const encrypted = encryptSecret(plaintext, vaultKey); + const tampered = { + ...encrypted, + ciphertext: flipOneHexChar(encrypted.ciphertext), + }; + + const decrypted = decryptSecret(tampered, vaultKey); + + expect(decrypted).toBeNull(); + + }); + +}); diff --git a/tests/core/vault/resolve.test.ts b/tests/core/vault/resolve.test.ts new file mode 100644 index 00000000..fd4589b1 --- /dev/null +++ b/tests/core/vault/resolve.test.ts @@ -0,0 +1,87 @@ +/** + * `resolveVaultKey` (v1/49-54 finding 2) — the shared "resolve the vault + * key for the current identity, degrade to null on any failure" helper. + * Before this it was copy-pasted near-identically in five places (`run`/ + * `changes`/`templates` SDK namespaces plus `run preview`/`run inspect`); + * this pins the degrade contract once so a future change is one edit, not + * five found by grep. Live-vault precedence for the render-context + * builders that call it is proven in + * `tests/integration/sdk/run-vault-secrets.test.ts` and + * `tests/sdk/render-vault-tier.test.ts`. + * + * SQLite in-memory keeps this fast and offline — no live DB required to + * prove the degrade path. + */ +import { afterEach, describe, expect, it } from 'bun:test'; + +import type { Kysely } from 'kysely'; + +import { resolveVaultKey } from '../../../src/core/vault/index.js'; +import { createConnection } from '../../../src/core/connection/factory.js'; +import { generateKeyPair, computeIdentityHash } from '../../../src/core/identity/index.js'; +import { + setIdentityOverride, + clearIdentityOverride, + setKeyOverride, + clearKeyOverride, +} from '../../../src/core/identity/storage.js'; +import type { NoormDatabase } from '../../../src/core/shared/index.js'; + +afterEach(() => { + + clearIdentityOverride(); + clearKeyOverride(); + +}); + +describe('vault: resolveVaultKey (shared degrade helper)', () => { + + it('degrades to null, no throw, when no identity is set up', async () => { + + clearIdentityOverride(); + clearKeyOverride(); + + const conn = await createConnection({ dialect: 'sqlite', database: ':memory:' }, '__test__'); + + const vaultKey = await resolveVaultKey(conn.db as unknown as Kysely, 'sqlite'); + + expect(vaultKey).toBeNull(); + + await conn.destroy(); + + }); + + it('degrades to null, no throw, when the identity/vault tables cannot be reached', async () => { + + const { publicKey, privateKey } = generateKeyPair(); + const identityHash = computeIdentityHash({ + email: 'probe@example.com', + name: 'Probe', + machine: 'test-machine', + os: 'test-os', + }); + + setIdentityOverride({ + identityHash, + name: 'Probe', + email: 'probe@example.com', + publicKey, + machine: 'test-machine', + os: 'test-os', + createdAt: new Date().toISOString(), + }); + setKeyOverride(privateKey); + + // No schema bootstrapped — the identities table doesn't exist, so + // the lookup fails exactly like an unreachable database would. + const conn = await createConnection({ dialect: 'sqlite', database: ':memory:' }, '__test__'); + + const vaultKey = await resolveVaultKey(conn.db as unknown as Kysely, 'sqlite'); + + expect(vaultKey).toBeNull(); + + await conn.destroy(); + + }); + +}); diff --git a/tests/core/vault/storage.test.ts b/tests/core/vault/storage.test.ts new file mode 100644 index 00000000..8557bbf9 --- /dev/null +++ b/tests/core/vault/storage.test.ts @@ -0,0 +1,317 @@ +/** + * Vault storage CRUD tests. + * + * Mirrors `tests/core/vault/idempotent-init.test.ts`'s harness: in-memory SQLite, + * `v1.up` bootstrap, `seedIdentity` reusing `generateKeyPair`/`computeIdentityHash`. + * The vault key under test is always obtained via a real `initializeVault()` call + * (never a hand-rolled buffer) so these tests exercise the full path a real caller + * takes, including `getVaultKey`'s DB-backed decrypt lookup. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { Kysely, SqliteDialect } from 'kysely'; + +import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; +import { initializeVault } from '../../../src/core/vault/index.js'; +import { + setVaultSecret, + getVaultSecret, + getAllVaultSecrets, + vaultSecretExists, + deleteVaultSecret, + getVaultKey, + getVaultStatus, +} from '../../../src/core/vault/storage.js'; +import { + NOORM_TABLES, + type NoormDatabase, +} from '../../../src/core/shared/index.js'; +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { + generateKeyPair, + computeIdentityHash, +} from '../../../src/core/identity/index.js'; + +interface TestIdentity { + identityHash: string; + publicKey: string; + privateKey: string; +} + +async function createTestDb(): Promise> { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + return db; + +} + +async function seedIdentity( + db: Kysely, + email = 'alice@example.com', + name = 'Alice', +): Promise { + + const { publicKey, privateKey } = generateKeyPair(); + const identityHash = computeIdentityHash({ + email, + name, + machine: 'test-machine', + os: 'test-os', + }); + + await db + .insertInto(NOORM_TABLES.identities) + .values({ + identity_hash: identityHash, + email, + name, + machine: 'test-machine', + os: 'test-os', + public_key: publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + return { identityHash, publicKey, privateKey }; + +} + +describe('vault: storage CRUD', () => { + + let db: Kysely; + + beforeEach(async () => { + + db = await createTestDb(); + + }); + + afterEach(async () => { + + await db.destroy(); + + }); + + it('should round-trip a secret through real DB storage (setVaultSecret -> getVaultSecret)', async () => { + + const alice = await seedIdentity(db); + const [vaultKey, err] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + expect(err).toBeNull(); + expect(vaultKey).toBeInstanceOf(Buffer); + + const [, setErr] = await setVaultSecret( + db, + vaultKey as Buffer, + 'API_KEY', + 'sk-live-abc123', + alice.identityHash, + 'sqlite', + ); + + expect(setErr).toBeNull(); + + const value = await getVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'sqlite'); + + expect(value).toBe('sk-live-abc123'); + + }); + + it('should update the existing row (not insert a duplicate) when setVaultSecret is called twice with the same key', async () => { + + const alice = await seedIdentity(db); + const [vaultKey] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + await setVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'first-value', alice.identityHash, 'sqlite'); + await setVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'second-value', 'bob@example.com', 'sqlite'); + + const rows = await db + .selectFrom(NOORM_TABLES.vault) + .selectAll() + .where('secret_key', '=', 'API_KEY') + .execute(); + + expect(rows.length).toBe(1); + expect(rows[0].set_by).toBe('bob@example.com'); + + const value = await getVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'sqlite'); + + expect(value).toBe('second-value'); + + }); + + it('should return all secrets, correctly decrypted, keyed by secret_key', async () => { + + const alice = await seedIdentity(db); + const [vaultKey] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + await setVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'value-1', alice.identityHash, 'sqlite'); + await setVaultSecret(db, vaultKey as Buffer, 'DB_PASSWORD', 'value-2', alice.identityHash, 'sqlite'); + await setVaultSecret(db, vaultKey as Buffer, 'JWT_SECRET', 'value-3', alice.identityHash, 'sqlite'); + + const secrets = await getAllVaultSecrets(db, vaultKey as Buffer, 'sqlite'); + + expect(Object.keys(secrets).sort()).toEqual(['API_KEY', 'DB_PASSWORD', 'JWT_SECRET']); + expect(secrets.API_KEY.value).toBe('value-1'); + expect(secrets.DB_PASSWORD.value).toBe('value-2'); + expect(secrets.JWT_SECRET.value).toBe('value-3'); + + }); + + it('should report vaultSecretExists as false before set and true after', async () => { + + const alice = await seedIdentity(db); + const [vaultKey] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + expect(await vaultSecretExists(db, 'API_KEY', 'sqlite')).toBe(false); + + await setVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'value', alice.identityHash, 'sqlite'); + + expect(await vaultSecretExists(db, 'API_KEY', 'sqlite')).toBe(true); + + }); + + it('should delete an existing secret and return [true, null]', async () => { + + const alice = await seedIdentity(db); + const [vaultKey] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + await setVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'value', alice.identityHash, 'sqlite'); + + const [deleted, err] = await deleteVaultSecret(db, 'API_KEY', 'sqlite'); + + expect(deleted).toBe(true); + expect(err).toBeNull(); + expect(await vaultSecretExists(db, 'API_KEY', 'sqlite')).toBe(false); + + }); + + it('should return [false, null] (not an error) when deleting a key that was never set', async () => { + + const [deleted, err] = await deleteVaultSecret(db, 'NEVER_SET', 'sqlite'); + + expect(deleted).toBe(false); + expect(err).toBeNull(); + + }); + + it('should decrypt the vault key via getVaultKey with the correct identity + matching private key', async () => { + + const alice = await seedIdentity(db); + const [vaultKey, err] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + expect(err).toBeNull(); + + const fetched = await getVaultKey(db, alice.identityHash, alice.privateKey, 'sqlite'); + + expect(fetched).toBeInstanceOf(Buffer); + expect(fetched?.equals(vaultKey as Buffer)).toBe(true); + + }); + + it('should return null from getVaultKey when the identityHash has an encrypted key but the privateKey does not match', async () => { + + const alice = await seedIdentity(db, 'alice@example.com', 'Alice'); + const bob = await seedIdentity(db, 'bob@example.com', 'Bob'); + + await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + // Query alice's identity row (which does hold an encrypted_vault_key, + // encrypted for alice's public key) but supply bob's privateKey — a + // mismatched key, never propagated the vault. This reaches the actual + // decryptVaultKey call inside getVaultKey (unlike passing bob's own + // identityHash, whose encrypted_vault_key is null and would short-circuit + // on the row-lookup guard before decryption is ever attempted). + const fetched = await getVaultKey(db, alice.identityHash, bob.privateKey, 'sqlite'); + + expect(fetched).toBeNull(); + + }); + + it('should reflect isInitialized/usersWithAccess/usersWithoutAccess/hasAccess through getVaultStatus', async () => { + + const alice = await seedIdentity(db, 'alice@example.com', 'Alice'); + + const beforeInit = await getVaultStatus(db, alice.identityHash, 'sqlite'); + + expect(beforeInit.isInitialized).toBe(false); + + await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + const afterInit = await getVaultStatus(db, alice.identityHash, 'sqlite'); + + expect(afterInit.isInitialized).toBe(true); + expect(afterInit.usersWithAccess).toBe(1); + + const bob = await seedIdentity(db, 'bob@example.com', 'Bob'); + + const afterBobSeeded = await getVaultStatus(db, bob.identityHash, 'sqlite'); + + expect(afterBobSeeded.usersWithoutAccess).toBe(1); + expect(afterBobSeeded.hasAccess).toBe(false); + + }); + + describe('absence vs. infra failure', () => { + + it('should resolve getVaultKey to null on genuine absence (identity row exists, key never set)', async () => { + + const alice = await seedIdentity(db); + + const fetched = await getVaultKey(db, alice.identityHash, alice.privateKey, 'sqlite'); + + expect(fetched).toBeNull(); + + }); + + it('should propagate a thrown error from getVaultKey when the query itself fails', async () => { + + const alice = await seedIdentity(db); + + await db.destroy(); + + await expect( + getVaultKey(db, alice.identityHash, alice.privateKey, 'sqlite'), + ).rejects.toThrow(); + + // Recreate so afterEach can destroy cleanly. + db = await createTestDb(); + + }); + + it('should resolve getVaultSecret to null on genuine absence (key never set)', async () => { + + const alice = await seedIdentity(db); + const [vaultKey] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + const value = await getVaultSecret(db, vaultKey as Buffer, 'NEVER_SET', 'sqlite'); + + expect(value).toBeNull(); + + }); + + it('should propagate a thrown error from getVaultSecret when the query itself fails', async () => { + + const alice = await seedIdentity(db); + const [vaultKey] = await initializeVault(db, alice.identityHash, alice.publicKey, 'sqlite'); + + await db.destroy(); + + await expect( + getVaultSecret(db, vaultKey as Buffer, 'API_KEY', 'sqlite'), + ).rejects.toThrow(); + + // Recreate so afterEach can destroy cleanly. + db = await createTestDb(); + + }); + + }); + +}); diff --git a/tests/core/worker-bridge/bridge.test.ts b/tests/core/worker-bridge/bridge.test.ts index 7144ff7f..f64cebb2 100644 --- a/tests/core/worker-bridge/bridge.test.ts +++ b/tests/core/worker-bridge/bridge.test.ts @@ -7,7 +7,6 @@ const ECHO_WORKER = resolve(import.meta.dir, '../../fixtures/workers/echo.ts'); interface EchoEvents { 'ping': { message: string } 'ping:res': { message: string } - 'init': Record } describe('worker-bridge: WorkerBridge', () => { @@ -36,14 +35,6 @@ describe('worker-bridge: WorkerBridge', () => { }); - it('should forward workerData to the worker', async () => { - - bridge = new WorkerBridge(ECHO_WORKER, { greeting: 'hi' }); - const { data } = await bridge.once('init'); - expect(data.greeting).toBe('hi'); - - }); - it('should shut down cleanly', async () => { bridge = new WorkerBridge(ECHO_WORKER); diff --git a/tests/fixtures/workers/echo.ts b/tests/fixtures/workers/echo.ts index 107af598..c05afc02 100644 --- a/tests/fixtures/workers/echo.ts +++ b/tests/fixtures/workers/echo.ts @@ -1,4 +1,4 @@ -import { parentPort, workerData } from 'worker_threads'; +import { parentPort } from 'worker_threads'; if (!parentPort) throw new Error('Not in worker'); @@ -10,10 +10,3 @@ parentPort.on('message', ({ event, data }: { event: string; data: Record): Promise { + + await attempt(() => sql`DROP SCHEMA IF EXISTS noorm CASCADE`.execute(db)); + + for (const table of [ + '__noorm_vault__', + '__noorm_identities__', + '__noorm_lock__', + '__noorm_executions__', + '__noorm_change__', + '__noorm_version__', + ]) { + + await attempt(() => sql`${sql.raw(`DROP TABLE IF EXISTS ${table}`)}`.execute(db)); + + } + +} + +describe('integration: postgres change transaction', () => { + + let db: Kysely; + let ndb: Kysely; + let destroy: () => Promise; + let tempDir: string; + let changesDir: string; + let sqlDir: string; + + // Clean schema-qualified table names (postgres) used with noormDb's + // withSchema('noorm') — mirrors how production ChangeHistory queries. + const tables = getNoormTables('postgres'); + + const testIdentity = { name: 'Test User', email: 'test@example.com', source: 'config' as const }; + + /** + * Create a test change on disk. + * + * Mirrors tests/core/change/executor-retry.test.ts's createTestChange, + * with a live Postgres connection wired through buildContext below + * instead of in-memory SQLite. + */ + async function createTestChange( + name: string, + files: Array<{ name: string; content: string }>, + ): Promise { + + const changePath = join(changesDir, name); + const changeFilesDir = join(changePath, 'change'); + await mkdir(changeFilesDir, { recursive: true }); + + const changeFiles = []; + + for (const file of files) { + + const filePath = join(changeFilesDir, file.name); + await writeFile(filePath, file.content); + + changeFiles.push({ + filename: file.name, + path: filePath, + type: 'sql' as const, + }); + + } + + return { + name, + path: changePath, + date: null, + description: name, + changeFiles, + revertFiles: [], + hasChangelog: false, + }; + + } + + /** + * Build a test context wired to the live Postgres connection. + */ + function buildContext(): ChangeContext { + + return { + db, + configName: 'test', + identity: testIdentity, + projectRoot: tempDir, + changesDir, + sqlDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', + dialect: 'postgres', + }; + + } + + /** + * Whether a table exists in the public schema. + * + * Uses `to_regclass` rather than information_schema so a rolled-back + * CREATE TABLE (never committed) reliably reports absent. Change SQL + * runs against the connection's default search_path (public), not the + * noorm schema, so user tables land in public. + */ + async function tableExists(tableName: string): Promise { + + const { rows } = await sql<{ reg: string | null }>`SELECT to_regclass(${`public.${tableName}`}) AS reg`.execute(db); + + return rows[0]?.reg != null; + + } + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + const conn = await createTestConnection('postgres'); + db = conn.db as Kysely; + ndb = noormDb(db, 'postgres'); + destroy = conn.destroy; + + // Bootstrap the noorm tracking tables. On postgres the lock manager + // and ChangeHistory resolve to the `noorm` schema (noormDb -> + // withSchema('noorm')), which only exists after v2 creates it and + // moves the v1 tables into it — v1 alone leaves them in public as + // `__noorm_*__` and every noorm.* reference 42P01s. + await resetNoormState(db as Kysely); + await v1.up(db as Kysely, 'postgres'); + await v2.up(db as Kysely, 'postgres'); + + }, 30_000); + + afterAll(async () => { + + if (destroy) { + + await resetNoormState(db as Kysely).catch(() => {}); + await destroy(); + + } + + }); + + beforeEach(async () => { + + resetLockManager(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-pg-txn-test-')); + changesDir = join(tempDir, 'changes'); + sqlDir = join(tempDir, 'sql'); + + await mkdir(changesDir, { recursive: true }); + await mkdir(sqlDir, { recursive: true }); + + }); + + afterEach(async () => { + + resetLockManager(); + + await rm(tempDir, { recursive: true, force: true }); + + }); + + it('failed change leaves no trace', async () => { + + // Unique per-run so this test is self-isolating against a shared + // CI database — no cross-run table name collisions. + const token = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + const tableA = `pg_txn_test_a_${token}`; + const changeName = `pg-txn-fail-${token}`; + + const change = await createTestChange(changeName, [ + { name: '001_a.sql', content: `CREATE TABLE ${tableA} (id INTEGER PRIMARY KEY)` }, + { name: '002_b.sql', content: 'CREATE TALBE this_is_a_syntax_error (id INTEGER PRIMARY KEY)' }, + ]); + + const context = buildContext(); + + const result = await executeChange(context, change); + + expect(result.status).toBe('failed'); + expect(result.operationId).toBeUndefined(); + + // File A's CREATE TABLE never committed — rolled back with the + // rest of the transaction. + expect(await tableExists(tableA)).toBe(false); + + // No operation or file history rows persist for this change. + const changeRows = await ndb + .selectFrom(tables.change) + .selectAll() + .where('name', '=', changeName) + .execute(); + + expect(changeRows).toHaveLength(0); + + const execRows = await sql<{ n: number }>` + SELECT COUNT(*)::int AS n + FROM noorm."executions" e + JOIN noorm."change" c ON c.id = e.change_id + WHERE c.name = ${changeName} + `.execute(db); + + expect(execRows.rows[0]?.n).toBe(0); + + }); + + it('retry after rollback runs the whole change fresh', async () => { + + const token = `${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + const tableA = `pg_txn_test_a2_${token}`; + const tableB = `pg_txn_test_b2_${token}`; + const changeName = `pg-txn-retry-${token}`; + + const change = await createTestChange(changeName, [ + { name: '001_a.sql', content: `CREATE TABLE ${tableA} (id INTEGER PRIMARY KEY)` }, + { name: '002_b.sql', content: 'CREATE TALBE this_is_a_syntax_error (id INTEGER PRIMARY KEY)' }, + ]); + + const context = buildContext(); + + const failedResult = await executeChange(context, change); + expect(failedResult.status).toBe('failed'); + + // Fix B on disk — nothing from the failed attempt persisted, so + // the retry below reruns the whole change (including A), not + // just B. + const fileB = change.changeFiles[1]!; + await writeFile(fileB.path, `CREATE TABLE ${tableB} (id INTEGER PRIMARY KEY)`); + + const retryResult = await executeChange(context, change); + + expect(retryResult.status).toBe('success'); + expect(await tableExists(tableA)).toBe(true); + expect(await tableExists(tableB)).toBe(true); + + const changeRows = await ndb + .selectFrom(tables.change) + .selectAll() + .where('name', '=', changeName) + .where('status', '=', 'success') + .execute(); + + expect(changeRows).toHaveLength(1); + + await attempt(() => sql`${sql.raw(`DROP TABLE IF EXISTS ${tableA}`)}`.execute(db)); + await attempt(() => sql`${sql.raw(`DROP TABLE IF EXISTS ${tableB}`)}`.execute(db)); + + }); + +}); diff --git a/tests/integration/cli/db.test.ts b/tests/integration/cli/db.test.ts index e468b27e..e779eca4 100644 --- a/tests/integration/cli/db.test.ts +++ b/tests/integration/cli/db.test.ts @@ -93,7 +93,8 @@ describe('cli: db explore', () => { expect(result.ok).toBe(true); - const text = stripAnsi(result.stderr); + // CP4: the result belongs on stdout, not the diagnostics stream. + const text = stripAnsi(result.stdout); expect(text).toContain('Database Overview'); }); diff --git a/tests/integration/cli/lock.test.ts b/tests/integration/cli/lock.test.ts index 21ad2589..a81344e2 100644 --- a/tests/integration/cli/lock.test.ts +++ b/tests/integration/cli/lock.test.ts @@ -73,7 +73,8 @@ describe('cli: lock status', () => { expect(result.ok).toBe(true); - const text = stripAnsi(result.stderr); + // CP4: the result belongs on stdout, not the diagnostics stream. + const text = stripAnsi(result.stdout); // Should show unlocked state (no active lock) expect(text.toLowerCase()).toMatch(/unlock|no.*lock/i); diff --git a/tests/integration/sdk/db-reset.test.ts b/tests/integration/sdk/db-reset.test.ts index db6c7b82..9ae0cb61 100644 --- a/tests/integration/sdk/db-reset.test.ts +++ b/tests/integration/sdk/db-reset.test.ts @@ -96,14 +96,12 @@ describe('integration: sdk DbNamespace reset vs preserveTables', () => { await createMarkers(); - const db = new DbNamespace(makeState()); - let buildForced: boolean | undefined; - db._buildFn = async (opts?: { force?: boolean }) => { + const db = new DbNamespace(makeState(), async (opts?: { force?: boolean }) => { buildForced = opts?.force; - }; + }); await db.reset(); diff --git a/tests/integration/sdk/dt-namespace.test.ts b/tests/integration/sdk/dt-namespace.test.ts new file mode 100644 index 00000000..99cb3b28 --- /dev/null +++ b/tests/integration/sdk/dt-namespace.test.ts @@ -0,0 +1,146 @@ +/** + * Integration tests for DtNamespace against live postgres/mysql/mssql — + * proves the tuple-to-throw contract (v1-25) holds against real + * infrastructure, not the mocked/sqlite harness + * `tests/sdk/transfer-dt-namespace.test.ts` already covers. + * + * `DtNamespace` has a `#kysely` getter guarded by `requireConnection`, so + * (a) proves `NotConnectedError` without needing a live DB call at all — + * kept inside the dialect block (still gated by `skipIfNoContainer`) for + * parity with the sibling files. + * + * (b) does NOT use a nonexistent table name — verified live (all three + * dialects) that `buildDtSchema`'s column lookup is an + * `information_schema`/`sys.columns` query, which returns an empty result + * set (not a SQL error) for a table that doesn't exist. `coreExportTable` + * then proceeds past the schema-build step with a 0-column schema straight + * into the worker pipeline (`WorkerPool`/`WorkerBridge` spin-up), which is + * exactly the slow/flaky path this checkpoint must avoid. Instead, (b) + * mirrors the spec's Isolation rule and `vault-namespace.test.ts`'s case + * (c): a dedicated `createTestConnection(dialect)` destroyed before the + * call, so `buildDtSchema`'s own queries fail immediately ("driver has + * already been destroyed") — genuinely fails in `buildDtSchema`, before any + * worker thread spins up, and doesn't touch the shared `beforeAll` + * connection other tests in this file depend on. + * + * (c) uses a `.dt` (not `.dtz`) nonexistent path; (d) is the `.dtz` + * sibling. `.dtz` goes through `DtReader`'s gzip branch + * (`fileStream.pipe(gunzip)`), and `.pipe()` does not forward the source + * stream's `'error'` event to the destination — with no listener on + * `fileStream` itself, an ENOENT on a `.dtz` path used to become an + * unhandled stream error that hung the process instead of rejecting + * `reader.open()`'s promise (verified live, ticket v1-41). Fixed by + * forwarding `fileStream`'s `'error'` into `gunzip.destroy(err)`; (d) proves + * that fix holds at this SDK boundary. `.dt` uses the raw stream directly as + * readline's `input`, which already propagates stream errors into the async + * iteration, so it rejects cleanly and fast, before worker spin-up. + * + * No happy-path export/import here — ticket 25's contract table already + * unit-proves the shape; this file's job is only the real-failure throw + * proof `ctx.noorm.dt.*` currently lacks live. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; + +import { DtNamespace } from '../../../src/sdk/namespaces/dt.js'; +import { NotConnectedError } from '../../../src/sdk/guards.js'; +import { + createTestConnection, + skipIfNoContainer, + makeTestConfig, + TEST_CONNECTIONS, +} from '../../utils/db.js'; + +import type { ContextState } from '../../../src/sdk/state.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConnectionResult, Dialect } from '../../../src/core/connection/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +function makeState(connection: ConnectionResult | null, config: Config): ContextState { + + return { + connection, + config, + settings: {}, + identity: { name: 'tester', source: 'system' }, + options: {}, + projectRoot: '/tmp', + changeManager: null, + }; + +} + +// ───────────────────────────────────────────────────────────── +// Suite factory — identical behavior across dialects, only the +// connection/config differ, so one factory generates all three +// `describe` blocks instead of tripling the test bodies. +// ───────────────────────────────────────────────────────────── + +function describeDtNamespace(dialect: Dialect): void { + + describe(`sdk: DtNamespace live throw contract (${dialect})`, () => { + + let conn: ConnectionResult; + let config: Config; + + beforeAll(async () => { + + await skipIfNoContainer(dialect); + conn = await createTestConnection(dialect); + config = makeTestConfig(`dt-ns-${dialect}`, TEST_CONNECTIONS[dialect]); + + }); + + afterAll(async () => { + + if (conn) await conn.destroy(); + + }); + + it('(a) dt.exportTable() and dt.importFile() reject NotConnectedError when there is no connection', async () => { + + const dt = new DtNamespace(makeState(null, config)); + + await expect(dt.exportTable('users', './fake.dtz')).rejects.toThrow(NotConnectedError); + await expect(dt.importFile('./fake.dtz')).rejects.toThrow(NotConnectedError); + + }); + + it('(b) dt.exportTable() rejects a generic Error on a real infra failure (dedicated connection destroyed before the call)', async () => { + + const dedicated = await createTestConnection(dialect); + const dt = new DtNamespace(makeState(dedicated, config)); + await dedicated.destroy(); + + const err = await dt.exportTable('nonexistent_table_xyz', './fake-export.dtz').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(NotConnectedError); + + }); + + it('(c) dt.importFile() rejects a generic Error for a genuinely absent file (fails in DtReader.open(), before worker spin-up)', async () => { + + const dt = new DtNamespace(makeState(conn, config)); + + await expect(dt.importFile('/nonexistent-dir-noorm-test/x.dt')).rejects.toThrow(); + + }); + + it('(d) dt.importFile() rejects a generic Error for a genuinely absent .dtz file (proves the checkpoint-1 fix: DtReader now forwards fileStream errors through the gzip branch)', async () => { + + const dt = new DtNamespace(makeState(conn, config)); + + await expect(dt.importFile('/nonexistent-dir-noorm-test/x.dtz')).rejects.toThrow(); + + }); + + }); + +} + +describeDtNamespace('postgres'); +describeDtNamespace('mysql'); +describeDtNamespace('mssql'); diff --git a/tests/integration/sdk/run-vault-secrets.test.ts b/tests/integration/sdk/run-vault-secrets.test.ts new file mode 100644 index 00000000..16decde8 --- /dev/null +++ b/tests/integration/sdk/run-vault-secrets.test.ts @@ -0,0 +1,298 @@ +/** + * Integration tests for CP6 — the render path connects the vault tier. + * + * `resolveSecret`/`buildSecretsContext` (`src/core/vault/resolve.ts`) + * implemented the documented config-local > global-local > vault + * precedence with no production caller: every render-context builder + * called `StateManager.getAllSecrets(configName)` instead, which never + * touches the vault. A `noorm vault set` secret reached no template, for + * any identity. Proven here against a live database — a mock would not + * catch a defect that lived entirely in which resolver function got + * called, not in the resolver logic itself. + * + * `RunNamespace` and `TemplatesNamespace` are asserted against the same + * vault-only secret to prove the apply path and the render/preview path + * resolve identical tiers — the parity CP6 exists to guarantee. + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { Kysely } from 'kysely'; + +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { v2 } from '../../../src/core/version/schema/migrations/v2.js'; +import { RunNamespace } from '../../../src/sdk/namespaces/run.js'; +import { TemplatesNamespace } from '../../../src/sdk/namespaces/templates.js'; +import { + generateKeyPair, + computeIdentityHash, +} from '../../../src/core/identity/index.js'; +import { + setIdentityOverride, + clearIdentityOverride, + setKeyOverride, + clearKeyOverride, +} from '../../../src/core/identity/storage.js'; +import { initializeVault, setVaultSecret } from '../../../src/core/vault/index.js'; +import { + NOORM_TABLES, + getNoormTables, + noormDb, + type NoormDatabase, + type NoormTableNames, +} from '../../../src/core/shared/index.js'; +import { getStateManager, resetStateManager } from '../../../src/core/state/index.js'; +import { + createTestConnection, + skipIfNoContainer, + TEST_CONNECTIONS, + makeTestConfig, +} from '../../utils/db.js'; + +import type { ContextState } from '../../../src/sdk/state.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConnectionResult, Dialect } from '../../../src/core/connection/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures — mirrors tests/integration/sdk/vault-namespace.test.ts's +// schema/identity harness (postgres only here; the vault resolver itself +// is dialect-agnostic, and CP6 does not touch dialect-specific code). +// ───────────────────────────────────────────────────────────── + +const dialect: Dialect = 'postgres'; + +interface TestIdentity { + identityHash: string; + email: string; + publicKey: string; + privateKey: string; +} + +const DROP_ORDER: Array = ['vault', 'identities', 'lock', 'executions', 'change', 'version']; + +async function dropAllNoormTables(db: Kysely): Promise { + + const ndb = noormDb(db as Kysely, dialect); + const schemaTables = getNoormTables(dialect); + + for (const key of DROP_ORDER) { + + await ndb.schema.dropTable(schemaTables[key]).ifExists().execute(); + await db.schema.dropTable(NOORM_TABLES[key]).ifExists().execute(); + + } + +} + +async function rebuildSchema(db: Kysely): Promise { + + await dropAllNoormTables(db); + await v1.up(db, dialect); + await v2.up(db, dialect); + +} + +async function seedIdentity(db: Kysely, email = 'alice@example.com', name = 'Alice'): Promise { + + const { publicKey, privateKey } = generateKeyPair(); + const identityHash = computeIdentityHash({ email, name, machine: 'test-machine', os: 'test-os' }); + + const ndb = noormDb(db as Kysely, dialect); + const tables = getNoormTables(dialect); + + await ndb + .insertInto(tables.identities as keyof NoormDatabase) + .values({ + identity_hash: identityHash, + email, + name, + machine: 'test-machine', + os: 'test-os', + public_key: publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + return { identityHash, email, publicKey, privateKey }; + +} + +function overrideIdentity(identity: TestIdentity, name = 'Alice'): void { + + setIdentityOverride({ + identityHash: identity.identityHash, + name, + email: identity.email, + publicKey: identity.publicKey, + machine: 'test-machine', + os: 'test-os', + createdAt: new Date().toISOString(), + }); + setKeyOverride(identity.privateKey); + +} + +// ───────────────────────────────────────────────────────────── +// Suite +// ───────────────────────────────────────────────────────────── + +describe('sdk: render path connects the vault tier (CP6, postgres)', () => { + + let conn: ConnectionResult; + let config: Config; + let projectRoot: string; + let identity: TestIdentity; + + beforeAll(async () => { + + await skipIfNoContainer(dialect); + conn = await createTestConnection(dialect); + config = makeTestConfig('vault-render-postgres', TEST_CONNECTIONS[dialect]); + + }); + + afterAll(async () => { + + if (!conn) return; + + await dropAllNoormTables(conn.db); + await conn.destroy(); + + }); + + beforeEach(async () => { + + await rebuildSchema(conn.db); + identity = await seedIdentity(conn.db); + + projectRoot = await mkdtemp(join(tmpdir(), 'noorm-vault-render-')); + await mkdir(join(projectRoot, 'sql'), { recursive: true }); + + resetStateManager(); + const state = getStateManager(projectRoot); + await state.load(); + await state.setConfig(config.name, config); + + }); + + afterEach(async () => { + + clearIdentityOverride(); + clearKeyOverride(); + resetStateManager(); + await rm(projectRoot, { recursive: true, force: true }); + + }); + + function makeState(): ContextState { + + return { + connection: conn, + config, + settings: {}, + identity: { name: 'tester', source: 'system' }, + options: {}, + projectRoot, + changeManager: null, + }; + + } + + async function writeTemplate(key: string): Promise { + + const relPath = join('sql', `${key.toLowerCase()}.sql.tmpl`); + await writeFile(join(projectRoot, relPath), `select '{%~ $.secrets.${key} %}' as val;`); + + return relPath; + + } + + it('resolves a secret set only in the vault tier', async () => { + + const [vaultKey, initErr] = await initializeVault(conn.db, identity.identityHash, identity.publicKey, dialect); + expect(initErr).toBeNull(); + expect(vaultKey).not.toBeNull(); + + const [, setErr] = await setVaultSecret(conn.db, vaultKey!, 'DB_PASS_WORKER', 'vault-only-value', identity.email, dialect); + expect(setErr).toBeNull(); + + overrideIdentity(identity); + + const relPath = await writeTemplate('DB_PASS_WORKER'); + const templates = new TemplatesNamespace(makeState()); + + const result = await templates.render(relPath); + + expect(result.sql).toContain('vault-only-value'); + + }); + + it('applies config-local > global-local > vault precedence for the same key', async () => { + + const [vaultKey] = await initializeVault(conn.db, identity.identityHash, identity.publicKey, dialect); + await setVaultSecret(conn.db, vaultKey!, 'SHARED_KEY', 'from-vault', identity.email, dialect); + await setVaultSecret(conn.db, vaultKey!, 'GLOBAL_OR_VAULT', 'from-vault-2', identity.email, dialect); + + overrideIdentity(identity); + + const state = getStateManager(projectRoot); + await state.setGlobalSecret('SHARED_KEY', 'from-global'); + await state.setGlobalSecret('GLOBAL_OR_VAULT', 'from-global-2'); + await state.setSecret(config.name, 'SHARED_KEY', 'from-config'); + + const templates = new TemplatesNamespace(makeState()); + + // config-local beats global-local and vault for the same key + const allThreeTiers = await templates.render(await writeTemplate('SHARED_KEY')); + expect(allThreeTiers.sql).toContain('from-config'); + expect(allThreeTiers.sql).not.toContain('from-global'); + expect(allThreeTiers.sql).not.toContain('from-vault'); + + // global-local beats vault when no config-local override exists + const globalOverVault = await templates.render(await writeTemplate('GLOBAL_OR_VAULT')); + expect(globalOverVault.sql).toContain('from-global-2'); + expect(globalOverVault.sql).not.toContain('from-vault-2'); + + }); + + it('degrades to local tiers without throwing when this identity has no vault key', async () => { + + // Vault never initialized for this identity — the resolver must + // still resolve local secrets, not throw. + overrideIdentity(identity); + + const state = getStateManager(projectRoot); + await state.setSecret(config.name, 'LOCAL_ONLY', 'config-value'); + + const templates = new TemplatesNamespace(makeState()); + + const result = await templates.render(await writeTemplate('LOCAL_ONLY')); + + expect(result.sql).toContain('config-value'); + + }); + + it('RunNamespace resolves the same vault-tier secret TemplatesNamespace does (apply/preview parity)', async () => { + + const [vaultKey] = await initializeVault(conn.db, identity.identityHash, identity.publicKey, dialect); + await setVaultSecret(conn.db, vaultKey!, 'PARITY_KEY', 'vault-parity-value', identity.email, dialect); + + overrideIdentity(identity); + + const relPath = await writeTemplate('PARITY_KEY'); + + const templates = new TemplatesNamespace(makeState()); + const rendered = await templates.render(relPath); + + const run = new RunNamespace(makeState()); + const previewed = await run.preview([join(projectRoot, relPath)]); + + expect(rendered.sql).toContain('vault-parity-value'); + expect(previewed[0]?.status).toBe('success'); + expect(previewed[0]?.renderedSql).toContain('vault-parity-value'); + + }); + +}); diff --git a/tests/integration/sdk/transfer-namespace.test.ts b/tests/integration/sdk/transfer-namespace.test.ts new file mode 100644 index 00000000..5d836c69 --- /dev/null +++ b/tests/integration/sdk/transfer-namespace.test.ts @@ -0,0 +1,180 @@ +/** + * Integration tests for TransferNamespace against live postgres/mysql/mssql — + * proves the tuple-to-throw contract (v1-25) holds against real + * infrastructure, not the mocked/sqlite harness + * `tests/sdk/transfer-dt-namespace.test.ts` already covers. + * + * `TransferNamespace` has no internal connection (no `#kysely`) — `to()`/ + * `plan()` open their own source+dest connections from the `Config` objects + * passed in, so `NotConnectedError` is not a reachable failure path here. + * The live-failure proof is an unreachable destination: a real closed port + * that both methods must reject on, not resolve as a `[value, err]` tuple. + * + * The postgres-only happy-path case proves the success path isn't + * tuple-shaped either — `transfer.plan()` must resolve a plain + * `TransferPlan` object. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { sql, type Kysely } from 'kysely'; + +import { TransferNamespace } from '../../../src/sdk/namespaces/transfer.js'; +import { createConnection } from '../../../src/core/connection/factory.js'; +import { + createTestConnection, + deployTestSchema, + teardownTestSchema, + skipIfNoContainer, + makeTestConfig, + TEST_CONNECTIONS, +} from '../../utils/db.js'; + +import type { ContextState } from '../../../src/sdk/state.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +function makeState(config: Config): ContextState { + + return { + connection: null, + config, + settings: {}, + identity: { name: 'tester', source: 'system' }, + options: {}, + projectRoot: '/tmp', + changeManager: null, + }; + +} + +// ───────────────────────────────────────────────────────────── +// Suite factory — unreachable-dest failure proof, identical across +// dialects; only the connection config differs. +// ───────────────────────────────────────────────────────────── + +function describeTransferNamespaceFailure(dialect: Dialect): void { + + describe(`sdk: TransferNamespace live throw contract (${dialect})`, () => { + + let transfer: TransferNamespace; + let destConfig: Config; + + beforeAll(async () => { + + await skipIfNoContainer(dialect); + + const sourceConfig = makeTestConfig(`transfer-ns-src-${dialect}`, TEST_CONNECTIONS[dialect]); + + // Nothing listens on localhost:1 — connection attempts refuse + // immediately (ECONNREFUSED) for pg/mysql2/tedious alike. + destConfig = makeTestConfig(`transfer-ns-dest-${dialect}`, { + ...TEST_CONNECTIONS[dialect], + port: 1, + }); + + transfer = new TransferNamespace(makeState(sourceConfig)); + + }); + + it('transfer.to() rejects the underlying connection Error on an unreachable dest', async () => { + + await expect(transfer.to(destConfig)).rejects.toThrow(); + + }); + + it('transfer.plan() rejects the underlying connection Error on an unreachable dest', async () => { + + await expect(transfer.plan(destConfig)).rejects.toThrow(); + + }); + + }); + +} + +describeTransferNamespaceFailure('postgres'); +describeTransferNamespaceFailure('mysql'); +describeTransferNamespaceFailure('mssql'); + +// ───────────────────────────────────────────────────────────── +// Happy path — proves the success shape isn't a tuple either. +// One dialect is sufficient (see spec's Out of scope); the +// failure-path proof above is what runs on all three. +// ───────────────────────────────────────────────────────────── + +describe('sdk: TransferNamespace live throw contract (postgres happy path)', () => { + + let sourceDb: Kysely; + let destDb: Kysely; + let sourceDestroy: () => Promise; + let destDestroy: () => Promise; + let transfer: TransferNamespace; + + const sourceConfig = makeTestConfig('transfer-ns-plan-src', { ...TEST_CONNECTIONS.postgres }); + const destConfig = makeTestConfig('transfer-ns-plan-dest', { + ...TEST_CONNECTIONS.postgres, + database: process.env['TEST_POSTGRES_DATABASE_DEST'] ?? 'noorm_test_dest', + }); + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + const sourceConn = await createTestConnection('postgres'); + sourceDb = sourceConn.db; + sourceDestroy = sourceConn.destroy; + + // Connect to the postgres system db to create the dest db if absent + // — mirrors tests/integration/transfer/postgres.test.ts. + const destDbName = destConfig.connection.database; + const systemConn = await createConnection({ + ...TEST_CONNECTIONS.postgres, + database: 'postgres', + }, 'system'); + + const dbCheck = await sql<{ exists: boolean }>` + SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = ${destDbName}) as exists + `.execute(systemConn.db); + + if (!dbCheck.rows[0]?.exists) { + + await sql.raw(`CREATE DATABASE "${destDbName}"`).execute(systemConn.db); + + } + + await systemConn.destroy(); + + const destConn = await createConnection(destConfig.connection, 'transfer-ns-plan-dest'); + destDb = destConn.db; + destDestroy = destConn.destroy; + + await teardownTestSchema(sourceDb, 'postgres'); + await deployTestSchema(sourceDb, 'postgres'); + + await teardownTestSchema(destDb, 'postgres'); + await deployTestSchema(destDb, 'postgres'); + + transfer = new TransferNamespace(makeState(sourceConfig)); + + }); + + afterAll(async () => { + + if (destDestroy) await destDestroy(); + if (sourceDestroy) await sourceDestroy(); + + }); + + it('transfer.plan() resolves a real TransferPlan object, not a tuple', async () => { + + const result = await transfer.plan(destConfig); + + expect(Array.isArray(result)).toBe(false); + expect(result.tables.length).toBeGreaterThan(0); + + }); + +}); diff --git a/tests/integration/sdk/vault-namespace.test.ts b/tests/integration/sdk/vault-namespace.test.ts new file mode 100644 index 00000000..34da09f4 --- /dev/null +++ b/tests/integration/sdk/vault-namespace.test.ts @@ -0,0 +1,291 @@ +/** + * Integration tests for VaultNamespace against live postgres/mysql/mssql — + * proves the tuple-to-throw contract (v1-25) holds against real + * infrastructure, not the mocked/sqlite harness `tests/sdk/vault-namespace.test.ts` + * already covers. + * + * The pair that matters most is (b)/(c): a genuinely-absent key must + * resolve `null` (not an error), while a real infra failure (destroyed + * connection, dropped table) must reject — the SDK boundary must never + * collapse the two into the same falsy shape. + * + * Schema is rebuilt fresh per test (not per describe block) because (e) + * drops the vault table to force a write failure; leaving that in place + * would break every test that runs after it in the same dialect block. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; + +import type { Kysely } from 'kysely'; + +import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; +import { v2 } from '../../../src/core/version/schema/migrations/v2.js'; +import { VaultNamespace, VaultAccessError } from '../../../src/sdk/namespaces/vault.js'; +import { NotConnectedError } from '../../../src/sdk/guards.js'; +import { + generateKeyPair, + computeIdentityHash, +} from '../../../src/core/identity/index.js'; +import { + setIdentityOverride, + clearIdentityOverride, +} from '../../../src/core/identity/storage.js'; +import { + NOORM_TABLES, + getNoormTables, + noormDb, + type NoormDatabase, + type NoormTableNames, +} from '../../../src/core/shared/index.js'; +import { + createTestConnection, + skipIfNoContainer, + TEST_CONNECTIONS, + makeTestConfig, +} from '../../utils/db.js'; + +import type { ContextState } from '../../../src/sdk/state.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConnectionResult, Dialect } from '../../../src/core/connection/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +interface TestIdentity { + identityHash: string; + publicKey: string; + privateKey: string; +} + +// Children before parents (executions -> change FK). Same order v1.down uses. +const DROP_ORDER: Array = ['vault', 'identities', 'lock', 'executions', 'change', 'version']; + +/** + * Drop every noorm tracking table, in both possible locations. + * + * `v2` moves postgres/mssql tables into a schema-qualified `noorm.*` + * layout — every vault/identity storage function reads through that + * layout exclusively (`noormDb()`/`getNoormTables()`), so a v1-only + * bootstrap leaves those functions unable to find their own tables. + * `v1.down`/`v2.down` assume a known-clean sequential state and abort on + * the first missing object (e.g. after a test manually drops the vault + * table), so this drops directly via `ifExists()` in both the + * schema-qualified and legacy prefixed locations instead — idempotent + * regardless of what the previous test left behind. + */ +async function dropAllNoormTables(db: Kysely, dialect: Dialect): Promise { + + const ndb = noormDb(db as Kysely, dialect); + const schemaTables = getNoormTables(dialect); + + for (const key of DROP_ORDER) { + + await ndb.schema.dropTable(schemaTables[key]).ifExists().execute(); + await db.schema.dropTable(NOORM_TABLES[key]).ifExists().execute(); + + } + +} + +async function rebuildSchema(db: Kysely, dialect: Dialect): Promise { + + await dropAllNoormTables(db, dialect); + await v1.up(db, dialect); + await v2.up(db, dialect); + +} + +async function seedIdentity( + db: Kysely, + dialect: Dialect, + email = 'alice@example.com', + name = 'Alice', +): Promise { + + const { publicKey, privateKey } = generateKeyPair(); + const identityHash = computeIdentityHash({ + email, + name, + machine: 'test-machine', + os: 'test-os', + }); + + const ndb = noormDb(db as Kysely, dialect); + const tables = getNoormTables(dialect); + + await ndb + .insertInto(tables.identities as keyof NoormDatabase) + .values({ + identity_hash: identityHash, + email, + name, + machine: 'test-machine', + os: 'test-os', + public_key: publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + return { identityHash, publicKey, privateKey }; + +} + +function overrideIdentity(identity: TestIdentity, email = 'alice@example.com', name = 'Alice'): void { + + setIdentityOverride({ + identityHash: identity.identityHash, + name, + email, + publicKey: identity.publicKey, + machine: 'test-machine', + os: 'test-os', + createdAt: new Date().toISOString(), + }); + +} + +function makeState(connection: ConnectionResult | null, config: Config): ContextState { + + return { + connection, + config, + settings: {}, + identity: { name: 'tester', source: 'system' }, + options: {}, + projectRoot: '/tmp', + changeManager: null, + }; + +} + +// ───────────────────────────────────────────────────────────── +// Suite factory — identical behavior across dialects, only the +// connection/config differ, so one factory generates all three +// `describe` blocks instead of tripling the test bodies. +// ───────────────────────────────────────────────────────────── + +function describeVaultNamespace(dialect: Dialect): void { + + describe(`sdk: VaultNamespace live throw contract (${dialect})`, () => { + + let conn: ConnectionResult; + let config: Config; + let alice: TestIdentity; + + beforeAll(async () => { + + await skipIfNoContainer(dialect); + conn = await createTestConnection(dialect); + config = makeTestConfig(`vault-ns-${dialect}`, TEST_CONNECTIONS[dialect]); + + }); + + afterAll(async () => { + + if (!conn) return; + + await dropAllNoormTables(conn.db, dialect); + await conn.destroy(); + + }); + + beforeEach(async () => { + + await rebuildSchema(conn.db, dialect); + alice = await seedIdentity(conn.db, dialect); + overrideIdentity(alice); + + }); + + afterEach(() => { + + clearIdentityOverride(); + + }); + + it('(a) vault.get() rejects NotConnectedError when there is no connection', async () => { + + const vault = new VaultNamespace(makeState(null, config)); + + await expect(vault.get('ANY_KEY', alice.privateKey)).rejects.toThrow(NotConnectedError); + + }); + + it('(b) vault.get() resolves null for a genuinely absent key (vault initialized, key never set)', async () => { + + const vault = new VaultNamespace(makeState(conn, config)); + await vault.init(); + + const result = await vault.get('MISSING_KEY', alice.privateKey); + + expect(result).toBeNull(); + + }); + + it('(c) vault.get() rejects on a real infra failure (dedicated connection destroyed before the call)', async () => { + + const vault = new VaultNamespace(makeState(conn, config)); + await vault.init(); + + const dedicated = await createTestConnection(dialect); + const brokenVault = new VaultNamespace(makeState(dedicated, config)); + await dedicated.destroy(); + + await expect(brokenVault.get('ANY_KEY', alice.privateKey)).rejects.toThrow(); + + }); + + it('(d) vault.set() rejects VaultAccessError when this identity has no vault access', async () => { + + // Vault never initialized for this identity — #getVaultKey resolves null. + const vault = new VaultNamespace(makeState(conn, config)); + + await expect( + vault.set('API_KEY', 'value', alice.privateKey), + ).rejects.toThrow(VaultAccessError); + + }); + + it('(e) vault.set() rejects a generic Error (not VaultAccessError) when the vault table is dropped before the write', async () => { + + const vault = new VaultNamespace(makeState(conn, config)); + await vault.init(); + + // Vault key resolves fine (identities table intact); drop the vault + // table so setVaultSecret's write fails after #getVaultKey succeeds. + const ndb = noormDb(conn.db as Kysely, dialect); + await ndb.schema.dropTable(getNoormTables(dialect).vault).execute(); + + const err = await vault.set('API_KEY', 'value', alice.privateKey).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(VaultAccessError); + + }); + + it('(f) vault.get() rejects when the vault secrets table is gone but the identity/key is intact (isolates getVaultSecret\'s read-path throw)', async () => { + + const vault = new VaultNamespace(makeState(conn, config)); + await vault.init(); + + // Vault key resolves fine (identities table intact); drop only the + // vault table so getVaultSecret's read fails after #getVaultKey + // succeeds — case (c) destroys the whole connection and only + // proves #getVaultKey's throw, since that read happens first. + const ndb = noormDb(conn.db as Kysely, dialect); + await ndb.schema.dropTable(getNoormTables(dialect).vault).execute(); + + const err = await vault.get('ANY_KEY', alice.privateKey).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(NotConnectedError); + + }); + + }); + +} + +describeVaultNamespace('postgres'); +describeVaultNamespace('mysql'); +describeVaultNamespace('mssql'); diff --git a/tests/integration/teardown/mssql.test.ts b/tests/integration/teardown/mssql.test.ts index 6f089d1f..7b29ec79 100644 --- a/tests/integration/teardown/mssql.test.ts +++ b/tests/integration/teardown/mssql.test.ts @@ -4,9 +4,11 @@ * Tests truncateData, teardownSchema, and previewTeardown against a real MSSQL instance. * Requires docker-compose.test.yml to be running. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; import { sql, type Kysely } from 'kysely'; +import { attempt } from '@logosdx/utils'; + import { truncateData, teardownSchema, @@ -638,4 +640,54 @@ describe('integration: mssql teardown', () => { }); + // ───────────────────────────────────────────────────────────── + // v1-03: FK re-enable guarantee — a mid-truncate failure must never + // leave FK enforcement off. An AFTER DELETE trigger on one user table + // simulates the failure (e.g. a business-rule trigger blocking a + // delete); truncateData must still throw the injected error AND + // leave every FK constraint enabled afterward. + // ───────────────────────────────────────────────────────────── + + describe('truncateData mid-truncate failure (v1-03 FK re-enable guarantee)', () => { + + beforeEach(async () => { + + await teardownTestSchema(db, 'mssql').catch(() => {}); + await deployTestSchema(db, 'mssql'); + await seedTestData(db, 'mssql'); + + await sql.raw(` + CREATE TRIGGER trg_block_delete ON todo_lists + AFTER DELETE + AS BEGIN + THROW 50000, 'injected mid-truncate failure', 1; + END + `).execute(db); + + }); + + afterEach(async () => { + + await sql.raw('DROP TRIGGER IF EXISTS trg_block_delete').execute(db); + + }); + + it('re-enables FK checks even when a mid-truncate DELETE throws, and re-surfaces the injected error', async () => { + + const [, err] = await attempt(() => truncateData(db, 'mssql')); + + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('injected mid-truncate failure'); + + const disabledFks = await sql.raw( + 'SELECT COUNT(*) as cnt FROM sys.foreign_keys WHERE is_disabled = 1', + ).execute(db); + const cnt = (disabledFks.rows[0] as { cnt: number }).cnt; + + expect(cnt).toBe(0); + + }); + + }); + }); diff --git a/tests/sdk/bundle-smoke.test.ts b/tests/sdk/bundle-smoke.test.ts index 8583a2c6..f8bf83ab 100644 --- a/tests/sdk/bundle-smoke.test.ts +++ b/tests/sdk/bundle-smoke.test.ts @@ -256,15 +256,6 @@ describe.skipIf(!bundleExists)('sdk bundle: Context instantiation', () => { }); - it('should expose noorm.observer', () => { - - const ctx = createBundleContext(); - - expect(typeof ctx.noorm.observer.on).toBe('function'); - expect(typeof ctx.noorm.observer.emit).toBe('function'); - - }); - it('should lazily create all namespace instances', () => { const ctx = createBundleContext(); @@ -284,6 +275,39 @@ describe.skipIf(!bundleExists)('sdk bundle: Context instantiation', () => { }); +// ───────────────────────────────────────────────────────────── +// Observer Event Bus +// ───────────────────────────────────────────────────────────── + +describe.skipIf(!bundleExists)('sdk bundle: noormObserver export', () => { + + it('should export noormObserver with the ObserverEngine method shape', () => { + + expect(bundle.noormObserver).toBeDefined(); + expect(typeof bundle.noormObserver.on).toBe('function'); + expect(typeof bundle.noormObserver.emit).toBe('function'); + expect(typeof bundle.noormObserver.off).toBe('function'); + + }); + + it('should invoke subscribed listeners on emit', () => { + + let received: unknown; + + bundle.noormObserver.on('bundle-smoke:noormObserver', (data: unknown) => { + + received = data; + + }); + + bundle.noormObserver.emit('bundle-smoke:noormObserver', { ok: true }); + + expect(received).toEqual({ ok: true }); + + }); + +}); + // ───────────────────────────────────────────────────────────── // Dynamic Dialect Chunks // ───────────────────────────────────────────────────────────── diff --git a/tests/sdk/db-namespace.test.ts b/tests/sdk/db-namespace.test.ts index 4ef795ca..df241c1d 100644 --- a/tests/sdk/db-namespace.test.ts +++ b/tests/sdk/db-namespace.test.ts @@ -256,4 +256,35 @@ describe('sdk: DbNamespace', () => { }); + // ───────────────────────────────────────────────────── + // Build fn — constructor injection, no public setter + // ───────────────────────────────────────────────────── + + describe('build fn injection', () => { + + it('should not expose a public _buildFn setter', () => { + + const descriptor = Object.getOwnPropertyDescriptor(DbNamespace.prototype, '_buildFn'); + + expect(descriptor).toBeUndefined(); + + }); + + it('should invoke the constructor-injected build fn on reset()', async () => { + + const state = createState( + {}, + [tableRow('users')], + ); + const buildFnStub = vi.fn().mockResolvedValue(undefined); + const db = new DbNamespace(state, buildFnStub); + + await db.reset(); + + expect(buildFnStub).toHaveBeenCalledWith({ force: true }); + + }); + + }); + }); diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts index 3368044b..6fd5dc05 100644 --- a/tests/sdk/destructive-ops.test.ts +++ b/tests/sdk/destructive-ops.test.ts @@ -12,7 +12,11 @@ * admin, matching the legacy protected:false behavior for open configs. * Read-only ops are never blocked regardless of access. */ -import { describe, it, expect } from 'bun:test'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { DbNamespace } from '../../src/sdk/namespaces/db.js'; import { DtNamespace } from '../../src/sdk/namespaces/dt.js'; @@ -24,6 +28,7 @@ import { ProtectedConfigError } from '../../src/sdk/guards.js'; import type { ContextState } from '../../src/sdk/state.js'; import type { Config } from '../../src/core/config/types.js'; import type { ConfigAccess } from '../../src/core/policy/index.js'; +import type { Change } from '../../src/core/change/index.js'; // ───────────────────────────────────────────────────────────── // Fixtures @@ -50,7 +55,7 @@ function makeConfig(access: ConfigAccess): Config { } -function makeState(access: ConfigAccess): ContextState { +function makeState(access: ConfigAccess, options: ContextState['options'] = {}): ContextState { return { connection: null, @@ -60,13 +65,40 @@ function makeState(access: ConfigAccess): ContextState { name: 'tester', source: 'system', }, - options: {}, + options, projectRoot: '/tmp', changeManager: null, }; } +/** + * Fake Change fixture for delete() gate tests. rm({ force: true }) on a + * nonexistent path silently no-ops, so a placeholder path is safe for every + * test except the dedicated disk-mutation block below. + */ +function makeFakeChange(changePath: string = join(tmpdir(), 'sample-change')): Change { + + return { + name: 'sample-change', + path: changePath, + date: new Date(), + description: 'sample-change', + changeFiles: [], + revertFiles: [], + hasChangelog: false, + }; + +} + +const tempDirs: string[] = []; + +afterEach(async () => { + + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + +}); + // ───────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────── @@ -106,6 +138,43 @@ describe('sdk: access-guarded destructive ops', () => { }); + // ───────────────────────────────────────────────────── + // DbNamespace — options.yes: true (context created with the + // programmatic --yes equivalent) satisfies the db:reset confirm + // cell for operator role without NOORM_YES in the environment + // ───────────────────────────────────────────────────── + + describe('DbNamespace on operator-role config with options.yes: true', () => { + + it('should not throw ProtectedConfigError for truncate()', async () => { + + const db = new DbNamespace(makeState(OPERATOR_ACCESS, { yes: true })); + const err = await db.truncate().catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError for teardown()', async () => { + + const db = new DbNamespace(makeState(OPERATOR_ACCESS, { yes: true })); + const err = await db.teardown().catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError for reset()', async () => { + + const db = new DbNamespace(makeState(OPERATOR_ACCESS, { yes: true })); + const err = await db.reset().catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + // ───────────────────────────────────────────────────── // DtNamespace — operator role can't satisfy the db:reset // confirm cell (SDK has no interactive prompt) @@ -409,6 +478,112 @@ describe('sdk: access-guarded destructive ops', () => { }); + // ───────────────────────────────────────────────────── + // ChangesNamespace.delete() — deleting an applied change also + // deletes its DB tracking row, so admin gets `confirm` here + // (like config:rm/db:destroy), NOT the frictionless `allow` + // change:revert gives admin. + // ───────────────────────────────────────────────────── + + describe('ChangesNamespace delete() on viewer-role config', () => { + + it('should throw ProtectedConfigError', async () => { + + const changes = new ChangesNamespace(makeState(VIEWER_ACCESS)); + + await expect(changes.delete(makeFakeChange())).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + describe('ChangesNamespace delete() on operator-role config', () => { + + it('should throw ProtectedConfigError', async () => { + + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS)); + + await expect(changes.delete(makeFakeChange())).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + describe('ChangesNamespace delete() on operator-role config with options.yes: true', () => { + + it('should not throw ProtectedConfigError', async () => { + + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS, { yes: true })); + const err = await changes.delete(makeFakeChange()).catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + describe('ChangesNamespace delete() on admin-role config', () => { + + // Unlike change:revert (an `allow` cell for admin), change:rm stays + // `confirm` for admin too — deletion is irreversible and can drop + // the DB tracking row, so even admin needs options.yes. + it('should throw ProtectedConfigError', async () => { + + const changes = new ChangesNamespace(makeState(ADMIN_ACCESS)); + + await expect(changes.delete(makeFakeChange())).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + describe('ChangesNamespace delete() on admin-role config with options.yes: true', () => { + + it('should not throw ProtectedConfigError', async () => { + + const changes = new ChangesNamespace(makeState(ADMIN_ACCESS, { yes: true })); + const err = await changes.delete(makeFakeChange()).catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + // ───────────────────────────────────────────────────── + // ChangesNamespace.delete() — disk-state proof: a denied call + // never touches the filesystem, an allowed call actually + // removes the change directory + // ───────────────────────────────────────────────────── + + describe('ChangesNamespace delete() disk mutation', () => { + + it('should leave the change directory intact when denied', async () => { + + const dir = await mkdtemp(join(tmpdir(), 'noorm-change-rm-denied-')); + tempDirs.push(dir); + + const changes = new ChangesNamespace(makeState(VIEWER_ACCESS)); + + await expect(changes.delete(makeFakeChange(dir))).rejects.toThrow(ProtectedConfigError); + expect(existsSync(dir)).toBe(true); + + }); + + it('should remove the change directory when admin confirms with options.yes: true', async () => { + + const dir = await mkdtemp(join(tmpdir(), 'noorm-change-rm-allowed-')); + tempDirs.push(dir); + + const changes = new ChangesNamespace(makeState(ADMIN_ACCESS, { yes: true })); + + await changes.delete(makeFakeChange(dir)); + expect(existsSync(dir)).toBe(false); + + }); + + }); + // ───────────────────────────────────────────────────── // TransferNamespace — gated on the DESTINATION config's role, since // the destructive act (writing rows) lands there, not on the source diff --git a/tests/sdk/dts-surface.test.ts b/tests/sdk/dts-surface.test.ts new file mode 100644 index 00000000..03dc3df2 --- /dev/null +++ b/tests/sdk/dts-surface.test.ts @@ -0,0 +1,74 @@ +/** + * SDK Type Surface Tests. + * + * Reads the built `.d.ts` (packages/sdk/dist/index.d.ts) as text to verify + * the shipped type surface, independent of the JS bundle — types are erased + * at runtime, so `bundle-smoke.test.ts` cannot see them. + * + * These tests catch: + * - Internal setters (e.g. `_buildFn`) leaking into the public type surface + * - Curated explore/teardown types silently dropping out of the shipped `.d.ts` + * + * Requires `bun run build:packages` (dts-bundle-generator) to have been run first. + * Skipped when the `.d.ts` does not exist (e.g. CI runs `tsc` only). + */ +import { describe, it, expect } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; + +// ───────────────────────────────────────────────────────────── +// Bundled Types +// ───────────────────────────────────────────────────────────── + +const DTS_PATH = '../../packages/sdk/dist/index.d.ts'; +const dtsExists = existsSync(new URL(DTS_PATH, import.meta.url)); + +// Read the built .d.ts as text — NOT imported, types have no runtime presence +const dtsContent: string = dtsExists ? readFileSync(new URL(DTS_PATH, import.meta.url), 'utf8') : ''; + +// ───────────────────────────────────────────────────────────── +// Internal Setter Removal +// ───────────────────────────────────────────────────────────── + +describe.skipIf(!dtsExists)('sdk .d.ts: internal setter removal', () => { + + it('should not contain _buildFn anywhere in the shipped type surface', () => { + + expect(dtsContent).not.toContain('_buildFn'); + + }); + +}); + +// ───────────────────────────────────────────────────────────── +// Curated Explore/Teardown Types +// ───────────────────────────────────────────────────────────── + +describe.skipIf(!dtsExists)('sdk .d.ts: curated type exports', () => { + + const curatedTypes = [ + 'ViewSummary', + 'ProcedureSummary', + 'FunctionSummary', + 'TypeSummary', + 'IndexSummary', + 'ForeignKeySummary', + 'ViewDetail', + 'ProcedureDetail', + 'FunctionDetail', + 'TypeDetail', + 'TruncateOptions', + 'ColumnDetail', + 'ParameterDetail', + ] as const; + + for (const name of curatedTypes) { + + it(`should export ${name} as a top-level interface`, () => { + + expect(dtsContent).toContain(`export interface ${name}`); + + }); + + } + +}); diff --git a/tests/sdk/guards.test.ts b/tests/sdk/guards.test.ts index b0d9d234..0920fbd6 100644 --- a/tests/sdk/guards.test.ts +++ b/tests/sdk/guards.test.ts @@ -315,4 +315,63 @@ describe('checkProtectedConfig', () => { }); + it('throws ProtectedConfigError for operator role on db:reset when options.yes is absent and NOORM_YES is unset', () => { + + const config = makeConfig(OPERATOR_ACCESS, { name: 'prod' }); + + expect(() => checkProtectedConfig(config, { channel: 'user' }, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); + + }); + + it('allows a confirm cell for operator role (db:reset) once options.yes is true, without NOORM_YES', () => { + + const config = makeConfig(OPERATOR_ACCESS, { name: 'prod' }); + + const [, err] = attemptSync(() => checkProtectedConfig(config, { channel: 'user', yes: true }, 'db:reset', 'truncate')); + + expect(err).toBeNull(); + + }); + + it('denies db:reset on the mcp channel even when options.yes is true (operator config, mcp resolves to viewer -> deny)', () => { + + const config = makeConfig(OPERATOR_ACCESS, { name: 'prod' }); + + expect(() => checkProtectedConfig(config, { channel: 'mcp', yes: true }, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); + + }); + + it('denies db:reset on the mcp channel via the confirm-to-deny collapse, even when options.yes is true (mcp:operator hits a confirm cell, not a plain deny)', () => { + + // mcp:'viewer' above hits db:reset's deny cell directly and never + // reaches checkPolicy's mcp-collapse branch (check.ts ~68-75). Here + // mcp:'operator' resolves db:reset to the same 'confirm' cell as the + // user channel, so this only denies if the mcp channel collapses + // confirm-to-deny before options.yes is ever consulted -- the + // invariant spec C2 pins. If that collapse branch were removed, + // options.yes: true would satisfy requiresConfirmation and this + // would NOT throw. + const config = makeConfig({ user: 'operator', mcp: 'operator' }, { name: 'prod' }); + + expect(() => checkProtectedConfig(config, { channel: 'mcp', yes: true }, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); + + }); + + it('names --yes in the confirmation message alongside NOORM_YES=1', () => { + + const config = makeConfig(ADMIN_ACCESS, { name: 'prod' }); + + expect.assertions(2); + + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'db:destroy', 'drop')); + + if (err instanceof ProtectedConfigError) { + + expect(err.message).toContain('--yes'); + expect(err.message).toContain('NOORM_YES=1'); + + } + + }); + }); diff --git a/tests/sdk/noorm-ops.test.ts b/tests/sdk/noorm-ops.test.ts index 02da226c..423198ab 100644 --- a/tests/sdk/noorm-ops.test.ts +++ b/tests/sdk/noorm-ops.test.ts @@ -5,9 +5,11 @@ * namespace getters, shared state reading, and not-connected errors. */ import { describe, it, expect } from 'bun:test'; +import { ObserverEngine } from '@logosdx/observer'; import { Context } from '../../src/sdk/context.js'; import { NoormOps } from '../../src/sdk/noorm-ops.js'; +import { noormObserver } from '../../src/sdk/index.js'; import { ChangesNamespace } from '../../src/sdk/namespaces/changes.js'; import { RunNamespace } from '../../src/sdk/namespaces/run.js'; import { DbNamespace } from '../../src/sdk/namespaces/db.js'; @@ -211,12 +213,12 @@ describe('sdk: NoormOps', () => { }); - it('should expose observer', () => { + it('should not expose observer on NoormOps', () => { const ctx = createContext(); - expect(ctx.noorm.observer).toBeDefined(); - expect(typeof ctx.noorm.observer.on).toBe('function'); + // @ts-expect-error observer was relocated to the top-level `noormObserver` export + expect(ctx.noorm.observer).toBeUndefined(); }); @@ -319,3 +321,18 @@ describe('sdk: NoormOps', () => { }); }); + +// ───────────────────────────────────────────────────────────── +// noormObserver (top-level export) +// ───────────────────────────────────────────────────────────── + +describe('sdk: noormObserver export', () => { + + it('should be importable from the SDK entry point as an ObserverEngine instance', () => { + + expect(noormObserver).toBeInstanceOf(ObserverEngine); + expect(typeof noormObserver.on).toBe('function'); + + }); + +}); diff --git a/tests/sdk/render-vault-tier.test.ts b/tests/sdk/render-vault-tier.test.ts new file mode 100644 index 00000000..f435e72d --- /dev/null +++ b/tests/sdk/render-vault-tier.test.ts @@ -0,0 +1,128 @@ +/** + * Fast (no live DB) tests for CP6's vault-tier wiring at the render-context + * builders (`TemplatesNamespace.render`, mirrored by `RunNamespace` / + * `ChangesNamespace`). A disconnected project — no vault to reach at all — + * must keep rendering exactly as it did before `buildSecretsContext` + * replaced `getAllSecrets`; the live-vault precedence itself is proven + * against a real database in `tests/integration/sdk/run-vault-secrets.test.ts`. + */ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { TemplatesNamespace } from '../../src/sdk/namespaces/templates.js'; +import { getStateManager, resetStateManager } from '../../src/core/state/index.js'; + +import type { ContextState } from '../../src/sdk/state.js'; +import type { Config } from '../../src/core/config/types.js'; +import type { Identity } from '../../src/core/identity/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +function makeConfig(overrides: Partial = {}): Config { + + return { + name: 'dev', + type: 'local', + isTest: false, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'postgres', database: 'testdb' }, + ...overrides, + }; + +} + +const mockIdentity: Identity = { + name: 'tester', + source: 'system', +}; + +function makeState(projectRoot: string, config: Config): ContextState { + + return { + connection: null, + config, + settings: {}, + identity: mockIdentity, + options: {}, + projectRoot, + changeManager: null, + }; + +} + +async function makeProject(templateBody: string): Promise { + + const projectRoot = await mkdtemp(join(tmpdir(), 'noorm-vault-degrade-')); + + await mkdir(join(projectRoot, 'sql'), { recursive: true }); + await writeFile(join(projectRoot, 'sql', 'greet.sql.tmpl'), templateBody); + + return projectRoot; + +} + +const tempDirs: string[] = []; + +afterEach(async () => { + + resetStateManager(); + + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + +}); + +// ───────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────── + +describe('sdk: render context resolves secrets without a connection (CP6 degrade path)', () => { + + it('renders using the local tier, no throw, when there is no database connection at all', async () => { + + const projectRoot = await makeProject("select '{%~ $.secrets.GREETING %}' as val;"); + tempDirs.push(projectRoot); + + resetStateManager(); + const state = getStateManager(projectRoot); + await state.load(); + + const config = makeConfig(); + await state.setConfig(config.name, config); + await state.setSecret(config.name, 'GREETING', 'hello-local'); + + const templates = new TemplatesNamespace(makeState(projectRoot, config)); + + const result = await templates.render('sql/greet.sql.tmpl'); + + expect(result.sql).toContain('hello-local'); + + }); + + it('applies config-local over global-local precedence with no vault reachable', async () => { + + const projectRoot = await makeProject("select '{%~ $.secrets.SHARED_KEY %}' as val;"); + tempDirs.push(projectRoot); + + resetStateManager(); + const state = getStateManager(projectRoot); + await state.load(); + + const config = makeConfig(); + await state.setConfig(config.name, config); + await state.setGlobalSecret('SHARED_KEY', 'global-value'); + await state.setSecret(config.name, 'SHARED_KEY', 'config-value'); + + const templates = new TemplatesNamespace(makeState(projectRoot, config)); + + const result = await templates.render('sql/greet.sql.tmpl'); + + expect(result.sql).toContain('config-value'); + expect(result.sql).not.toContain('global-value'); + + }); + +}); diff --git a/tests/sdk/run-build-filtering.test.ts b/tests/sdk/run-build-filtering.test.ts new file mode 100644 index 00000000..77518e1a --- /dev/null +++ b/tests/sdk/run-build-filtering.test.ts @@ -0,0 +1,374 @@ +/** + * SDK run.build() include/exclude/rules filtering tests. + * + * Proves ctx.noorm.run.build() honors settings.build.include/exclude and + * settings.rules identically to the TUI's Run Build screen + * (`RunBuildScreen.tsx`'s loading effect), and that db.reset()'s rebuild + * inherits the same filtering purely through its existing delegation to + * run.build — no duplicate filtering machinery. + * + * Uses Kysely DummyDriver with a mocked executor — no mock.module, per + * db-namespace.test.ts's precedent (avoids polluting the shared module + * cache for tests/utils + tests/core + tests/sdk, which CI runs in one + * `bun test --serial` process). build() also resolves context.secrets / + * globalSecrets via the process-wide StateManager singleton + * (getStateManager), which throws unless .load() has run — + * loadEmptyState() below resets the singleton and loads it against each + * test's own temp projectRoot (no on-disk state file, so load() takes the + * offline "new project" branch — no identity key or live service needed). + */ +import { afterEach, describe, expect, it, vi } from 'bun:test'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + Kysely, + DummyDriver, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely'; + +import { RunNamespace } from '../../src/sdk/namespaces/run.js'; +import { NoormOps } from '../../src/sdk/noorm-ops.js'; +import { NotConnectedError } from '../../src/sdk/guards.js'; +import { getStateManager, resetStateManager } from '../../src/core/state/index.js'; + +import type { ContextState } from '../../src/sdk/state.js'; +import type { Config } from '../../src/core/config/types.js'; +import type { Settings } from '../../src/core/settings/types.js'; +import type { Identity } from '../../src/core/identity/types.js'; +import type { BatchResult } from '../../src/core/runner/index.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────── + +function fakeBatchResult(): BatchResult { + + return { + status: 'success', + files: [], + filesRun: 0, + filesSkipped: 0, + filesFailed: 0, + durationMs: 0, + }; + +} + +/** Bare DummyDriver Kysely — DummyDriver.executeQuery throws unless the caller mocks it. */ +function makeKysely(): Kysely { + + return new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + +} + +/** + * Kysely wired to a DummyDriver whose connection answers generically + * enough to drive a real runBuild() to completion. Mocked at + * `driver.acquireConnection()` rather than `db.getExecutor().provideConnection` + * (db-namespace.test.ts's spot) because Tracker queries go through + * `db.withSchema('noorm')`, which clones the executor (`withPluginAtFront` + * builds a *new* `DefaultQueryExecutor`) — a spy on the original executor's + * `provideConnection` never sees schema-scoped queries. The driver instance + * itself is shared across every such clone, so this is the one seam that + * actually intercepts everything build() runs. + * + * Tracker.createOperation's `insert ... returning "id"` (verified via a + * compiled-query sanity check — it's the only query shape containing both + * substrings) gets an incrementing id, or the whole batch aborts before + * touching any file. Everything else — the needsRun lookup, file-record + * inserts/updates, and each fixture file's own SQL body — gets empty rows, + * which reads as "no prior execution" / "no-op success". This is enough to + * prove which files build() attempted to run; simulating real execution + * semantics is core/runner's own test surface, not this one's. + */ +function makeMockKysely(): Kysely { + + const driver = new DummyDriver(); + let nextId = 1; + + vi.spyOn(driver, 'acquireConnection').mockResolvedValue({ + executeQuery: vi.fn().mockImplementation((compiledQuery: { sql: string }) => { + + const allocatesId = /insert into/i.test(compiledQuery.sql) + && /returning/i.test(compiledQuery.sql); + + return Promise.resolve( + allocatesId + ? { rows: [{ id: nextId++ }] } + : { rows: [], numAffectedRows: 1n }, + ); + + }), + streamQuery: () => { + + throw new Error('not implemented'); + + }, + }); + + return new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => driver, + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }); + +} + +function makeConfig(overrides: Partial = {}): Config { + + return { + name: 'dev', + type: 'local', + isTest: false, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'postgres', database: 'testdb' }, + ...overrides, + }; + +} + +const mockIdentity: Identity = { + name: 'tester', + source: 'system', +}; + +function makeState( + projectRoot: string, + settings: Settings, + configOverrides: Partial = {}, + db: Kysely = makeMockKysely(), +): ContextState { + + return { + connection: { db, dialect: 'postgres', destroy: async () => {} }, + config: makeConfig(configOverrides), + settings, + identity: mockIdentity, + options: {}, + projectRoot, + changeManager: null, + }; + +} + +/** Temp project with sql/a/001_a.sql and sql/b/001_b.sql fixture files. */ +async function makeSqlProject(): Promise { + + const projectRoot = await mkdtemp(join(tmpdir(), 'noorm-build-filter-')); + + await mkdir(join(projectRoot, 'sql', 'a'), { recursive: true }); + await mkdir(join(projectRoot, 'sql', 'b'), { recursive: true }); + await writeFile(join(projectRoot, 'sql', 'a', '001_a.sql'), 'select 1;'); + await writeFile(join(projectRoot, 'sql', 'b', '001_b.sql'), 'select 1;'); + + return projectRoot; + +} + +/** + * Resets the process-wide StateManager singleton and loads it against + * `projectRoot` before each test — getStateManager caches its first + * instance for the process lifetime, so without a reset the second test + * to run would silently reuse the first test's temp projectRoot. + */ +async function loadEmptyState(projectRoot: string): Promise { + + resetStateManager(); + await getStateManager(projectRoot).load(); + +} + +const tempDirs: string[] = []; + +afterEach(async () => { + + resetStateManager(); + + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + +}); + +// ───────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────── + +describe('sdk: RunNamespace.build() filtering', () => { + + it('should only pass files under the included folder to runBuild', async () => { + + const projectRoot = await makeSqlProject(); + tempDirs.push(projectRoot); + await loadEmptyState(projectRoot); + + const state = makeState(projectRoot, { build: { include: ['a'] } }); + const run = new RunNamespace(state); + + const result = await run.build(); + + expect(result.files.map((f) => f.filepath)).toEqual([ + join(projectRoot, 'sql', 'a', '001_a.sql'), + ]); + + }); + + it('should resolve a failed BatchResult, not throw, when the sql dir is missing', async () => { + + // Regression: db reset on a project without a sql/ dir must keep + // runBuild's discovery-failure contract (failed result, no throw) — + // an eager throw here crashed `noorm db reset` to exit 1. + const { mkdtemp } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const projectRoot = await mkdtemp(join(tmpdir(), 'noorm-run-filter-nodir-')); + tempDirs.push(projectRoot); + await loadEmptyState(projectRoot); + + const state = makeState(projectRoot, {}); + const run = new RunNamespace(state); + + const result = await run.build(); + + expect(result.status).toBe('failed'); + expect(result.files).toEqual([]); + + }); + + it('should pass every discovered file when build settings are empty (regression guard)', async () => { + + const projectRoot = await makeSqlProject(); + tempDirs.push(projectRoot); + await loadEmptyState(projectRoot); + + const state = makeState(projectRoot, {}); + const run = new RunNamespace(state); + + const result = await run.build(); + + expect(result.files.map((f) => f.filepath).sort()).toEqual([ + join(projectRoot, 'sql', 'a', '001_a.sql'), + join(projectRoot, 'sql', 'b', '001_b.sql'), + ].sort()); + + }); + + it('should apply a rule matching isTest to alter the effective file set', async () => { + + const projectRoot = await makeSqlProject(); + tempDirs.push(projectRoot); + await loadEmptyState(projectRoot); + + const state = makeState( + projectRoot, + { rules: [{ match: { isTest: true }, exclude: ['b'] }] }, + { isTest: true }, + ); + const run = new RunNamespace(state); + + const result = await run.build(); + + expect(result.files.map((f) => f.filepath)).toEqual([ + join(projectRoot, 'sql', 'a', '001_a.sql'), + ]); + + }); + + it('should run zero files (not fall back to discovery) when every file is excluded', async () => { + + const projectRoot = await makeSqlProject(); + tempDirs.push(projectRoot); + await loadEmptyState(projectRoot); + + const state = makeState(projectRoot, { build: { exclude: ['a', 'b'] } }); + const run = new RunNamespace(state); + + const result = await run.build(); + + // runner.ts:123 `if (preFilteredFiles)` treats `[]` as provided (arrays + // are truthy) and `undefined` as "discover everything" — an empty + // result here proves build() took the pre-filtered branch rather than + // falling back to a full discovery that would have found 2 files. + expect(result.files).toEqual([]); + expect(result.filesRun).toBe(0); + + }); + + it('should throw NotConnectedError before any file-system access when disconnected', async () => { + + // Iteration 1 regression: #createRunContext() (which throws + // NotConnectedError via requireConnection) must run before sqlPath + // discovery, or a missing/unreadable sql dir masks the connection + // error. projectRoot below doesn't exist, so a discovery-before- + // connection-check regression would surface as a "Failed to read + // directory" error instead of NotConnectedError. + const state: ContextState = { + connection: null, + config: makeConfig(), + settings: {}, + identity: mockIdentity, + options: {}, + projectRoot: '/nonexistent/noorm-build-filter-root', + changeManager: null, + }; + const run = new RunNamespace(state); + + await expect(run.build()).rejects.toThrow(NotConnectedError); + + }); + +}); + +describe('sdk: db.reset() inherits build filtering via delegation', () => { + + it('should invoke run.build (not duplicate filtering machinery) on reset()', async () => { + + const projectRoot = await makeSqlProject(); + tempDirs.push(projectRoot); + + const db = makeKysely(); + const state = makeState(projectRoot, { build: { include: ['a'] } }, {}, db); + const ops = new NoormOps(state); + + // ops.run is accessed here only to obtain a reference to pass to + // vi.spyOn — access order doesn't otherwise matter: NoormOps#run + // memoizes on #run, and db.ts's buildFn closure ((opts) => + // this.run.build(opts)) re-reads this.run on every call rather than + // capturing a reference at ops.db construction time, so it always + // resolves to whichever instance #run is holding — the same one + // spied on below, regardless of whether ops.run or ops.db was + // touched first. + const runNamespace = ops.run; + const buildSpy = vi.spyOn(runNamespace, 'build').mockResolvedValue(fakeBatchResult()); + + // teardownSchema (reset()'s pre-rebuild step) needs a connection that + // answers "no objects found" for every explore query. + vi.spyOn(db.getExecutor(), 'provideConnection').mockImplementation(async (consumer) => + consumer({ + executeQuery: vi.fn().mockResolvedValue({ rows: [] }), + streamQuery: () => { + + throw new Error('not implemented'); + + }, + }), + ); + + await ops.db.reset(); + + expect(buildSpy).toHaveBeenCalledWith({ force: true }); + + }); + +}); diff --git a/tests/sdk/transfer-dt-namespace.test.ts b/tests/sdk/transfer-dt-namespace.test.ts new file mode 100644 index 00000000..dbd9a9cc --- /dev/null +++ b/tests/sdk/transfer-dt-namespace.test.ts @@ -0,0 +1,120 @@ +/** + * SDK TransferNamespace/DtNamespace throw-not-tuple contract tests. + * + * `transfer.to`/`transfer.plan`/`dt.exportTable`/`dt.importFile` used to + * return `[value, error]` tuples; this ticket converts them to throw. + * `transfer.to`/`transfer.plan` validate dialect support before opening + * any connection (`isTransferSupported` inside `transferData`/ + * `getTransferPlan`), so an unsupported dialect (sqlite, excluded from + * `TRANSFER_SUPPORTED_DIALECTS`) proves the throw without a live DB. + * `dt.exportTable`/`dt.importFile` require a connection before reaching + * the core tuple, so `connection: null` (NotConnectedError, from CP1) is + * the achievable unit-level proof there. + */ +import { describe, it, expect } from 'bun:test'; + +import { TransferNamespace } from '../../src/sdk/namespaces/transfer.js'; +import { DtNamespace } from '../../src/sdk/namespaces/dt.js'; +import { NotConnectedError } from '../../src/sdk/guards.js'; + +import type { ContextState } from '../../src/sdk/state.js'; +import type { Config } from '../../src/core/config/types.js'; + +// ───────────────────────────────────────────────────────────── +// Fixtures — mirrors tests/sdk/destructive-ops.test.ts +// ───────────────────────────────────────────────────────────── + +function makeConfig(dialect: Config['connection']['dialect']): Config { + + return { + name: 'dev', + type: 'local', + isTest: false, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect, database: 'testdb' }, + }; + +} + +function makeState(config: Config): ContextState { + + return { + connection: null, + config, + settings: {}, + identity: { + name: 'tester', + source: 'system', + }, + options: {}, + projectRoot: '/tmp', + changeManager: null, + }; + +} + +// ───────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────── + +describe('sdk: TransferNamespace/DtNamespace throw-not-tuple contract', () => { + + describe('TransferNamespace.to on an unsupported dialect', () => { + + it('should throw rather than resolve a [result, error] tuple', async () => { + + const sourceConfig = makeConfig('sqlite'); + const transfer = new TransferNamespace(makeState(sourceConfig)); + const destConfig = makeConfig('sqlite'); + + await expect(transfer.to(destConfig)).rejects.toThrow( + /Transfer not supported for dialect: sqlite/, + ); + + }); + + }); + + describe('TransferNamespace.plan on an unsupported dialect', () => { + + it('should throw rather than resolve a [plan, error] tuple', async () => { + + const sourceConfig = makeConfig('sqlite'); + const transfer = new TransferNamespace(makeState(sourceConfig)); + const destConfig = makeConfig('sqlite'); + + await expect(transfer.plan(destConfig)).rejects.toThrow( + /Transfer not supported for dialect: sqlite/, + ); + + }); + + }); + + describe('DtNamespace.exportTable with no connection', () => { + + it('should throw NotConnectedError rather than resolve a [result, error] tuple', async () => { + + const dt = new DtNamespace(makeState(makeConfig('postgres'))); + + await expect(dt.exportTable('users', './fake.dtz')).rejects.toThrow(NotConnectedError); + + }); + + }); + + describe('DtNamespace.importFile with no connection', () => { + + it('should throw NotConnectedError rather than resolve a [result, error] tuple', async () => { + + const dt = new DtNamespace(makeState(makeConfig('postgres'))); + + const err = await dt.importFile('./fake.dtz').catch((e: unknown) => e); + + expect(err).toBeInstanceOf(NotConnectedError); + + }); + + }); + +}); diff --git a/tests/sdk/vault-namespace.test.ts b/tests/sdk/vault-namespace.test.ts new file mode 100644 index 00000000..fc959cde --- /dev/null +++ b/tests/sdk/vault-namespace.test.ts @@ -0,0 +1,368 @@ +/** + * VaultNamespace SDK wrapper tests — tuple-to-throw contract conversion (v1-25 CP2). + * + * `init()`/`set()`/`delete()`/`copy()` used to return `[value, Error|null]` tuples; + * they now throw. `get`/`getAll`/`list`/`exists`/`propagate`/`status` are untouched + * by this checkpoint (already fixed in CP1 via storage.ts) and aren't re-tested here. + * + * Harness mirrors `tests/core/vault/storage.test.ts`: in-memory SQLite, `v1.up` + * bootstrap, `seedIdentity` reusing `generateKeyPair`/`computeIdentityHash`. The + * `ContextState` construction mirrors `tests/sdk/destructive-ops.test.ts`'s + * `makeState`/`makeConfig`, adapted to carry a real `connection`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Kysely, SqliteDialect } from 'kysely'; + +import { BunSqliteDatabase } from '../../src/core/connection/dialects/sqlite-bun.js'; +import { VaultNamespace } from '../../src/sdk/namespaces/vault.js'; +import { VaultAccessError } from '../../src/sdk/namespaces/vault.js'; +import { initializeVault, setVaultSecret } from '../../src/core/vault/index.js'; +import type { VaultCopyResult } from '../../src/core/vault/index.js'; +import { + NOORM_TABLES, + type NoormDatabase, +} from '../../src/core/shared/index.js'; +import { v1 } from '../../src/core/version/schema/migrations/v1.js'; +import { + generateKeyPair, + computeIdentityHash, +} from '../../src/core/identity/index.js'; +import { + setIdentityOverride, + clearIdentityOverride, +} from '../../src/core/identity/storage.js'; + +import type { ContextState } from '../../src/sdk/state.js'; +import type { Config } from '../../src/core/config/types.js'; + +interface TestIdentity { + identityHash: string; + publicKey: string; + privateKey: string; +} + +async function createTestDb(): Promise> { + + const db = new Kysely({ + dialect: new SqliteDialect({ + database: new BunSqliteDatabase(':memory:') as never, + }), + }); + + await v1.up(db as Kysely, 'sqlite'); + + return db; + +} + +async function seedIdentity( + db: Kysely, + email = 'alice@example.com', + name = 'Alice', +): Promise { + + const { publicKey, privateKey } = generateKeyPair(); + const identityHash = computeIdentityHash({ + email, + name, + machine: 'test-machine', + os: 'test-os', + }); + + await db + .insertInto(NOORM_TABLES.identities) + .values({ + identity_hash: identityHash, + email, + name, + machine: 'test-machine', + os: 'test-os', + public_key: publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + return { identityHash, publicKey, privateKey }; + +} + +function makeConfig(name: string, connectionOverrides: Record = {}): Config { + + return { + name, + type: 'local', + isTest: false, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:', ...connectionOverrides }, + }; + +} + +function makeState(db: Kysely, config: Config = makeConfig('vault-ns-test')): ContextState { + + return { + connection: { + db: db as unknown as Kysely, + dialect: 'sqlite', + destroy: () => db.destroy(), + }, + config, + settings: {}, + identity: { name: 'tester', source: 'system' }, + options: {}, + projectRoot: '/tmp', + changeManager: null, + }; + +} + +function overrideIdentity(identity: TestIdentity, email = 'alice@example.com', name = 'Alice'): void { + + setIdentityOverride({ + identityHash: identity.identityHash, + name, + email, + publicKey: identity.publicKey, + machine: 'test-machine', + os: 'test-os', + createdAt: new Date().toISOString(), + }); + +} + +describe('sdk: VaultNamespace tuple-to-throw contract', () => { + + let db: Kysely; + let alice: TestIdentity; + + beforeEach(async () => { + + db = await createTestDb(); + alice = await seedIdentity(db); + + overrideIdentity(alice); + + }); + + afterEach(async () => { + + clearIdentityOverride(); + await db.destroy(); + + }); + + describe('init()', () => { + + it('should resolve to a Buffer (not a tuple) on first init', async () => { + + const vault = new VaultNamespace(makeState(db)); + + const result = await vault.init(); + + expect(Array.isArray(result)).toBe(false); + expect(result).toBeInstanceOf(Buffer); + expect(result?.length).toBe(32); + + }); + + it('should resolve to null (not [null, null]) on repeat init — legitimately not an error', async () => { + + const vault = new VaultNamespace(makeState(db)); + + await vault.init(); + const second = await vault.init(); + + expect(Array.isArray(second)).toBe(false); + expect(second).toBeNull(); + + }); + + it('should throw the underlying Error (not resolve a tuple) on DB failure', async () => { + + const vault = new VaultNamespace(makeState(db)); + + await db.destroy(); + + await expect(vault.init()).rejects.toThrow(); + + // Recreate so afterEach can destroy cleanly and re-seed the identity + // override to match the fresh db's identity row. + db = await createTestDb(); + alice = await seedIdentity(db); + overrideIdentity(alice); + + }); + + }); + + describe('set()', () => { + + it('should resolve to undefined (not a tuple) on success', async () => { + + const vault = new VaultNamespace(makeState(db)); + await vault.init(); + + const result = await vault.set('API_KEY', 'sk-live-abc', alice.privateKey); + + expect(Array.isArray(result)).toBe(false); + expect(result).toBeUndefined(); + + }); + + it('should throw VaultAccessError (not a generic Error) when no vault key is available', async () => { + + // Vault never initialized for this identity — #getVaultKey resolves null. + const vault = new VaultNamespace(makeState(db)); + + await expect( + vault.set('API_KEY', 'value', alice.privateKey), + ).rejects.toThrow(VaultAccessError); + + }); + + it('VaultAccessError should be instanceof-matchable and name the config', async () => { + + const vault = new VaultNamespace(makeState(db, makeConfig('no-access-config'))); + + const err = await vault.set('API_KEY', 'value', alice.privateKey).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(VaultAccessError); + expect((err as Error).message).toContain('no-access-config'); + + }); + + it('should throw the underlying Error (not VaultAccessError, not a tuple) when the vault key is valid but the write fails', async () => { + + const vault = new VaultNamespace(makeState(db)); + await vault.init(); + + // Vault key resolves fine (identities table intact); drop the vault + // table so setVaultSecret's write fails after #getVaultKey succeeds. + await db.schema.dropTable(NOORM_TABLES.vault).execute(); + + const err = await vault.set('API_KEY', 'value', alice.privateKey).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(VaultAccessError); + + }); + + }); + + describe('delete()', () => { + + it('should resolve to true (not a tuple) when the key existed', async () => { + + const vault = new VaultNamespace(makeState(db)); + await vault.init(); + await vault.set('API_KEY', 'value', alice.privateKey); + + const result = await vault.delete('API_KEY'); + + expect(Array.isArray(result)).toBe(false); + expect(result).toBe(true); + + }); + + it('should resolve to false (not a tuple, not an error) when the key was never set', async () => { + + const vault = new VaultNamespace(makeState(db)); + + const result = await vault.delete('NEVER_SET'); + + expect(Array.isArray(result)).toBe(false); + expect(result).toBe(false); + + }); + + it('should throw the underlying Error (not resolve a tuple) on DB failure', async () => { + + const vault = new VaultNamespace(makeState(db)); + + await db.destroy(); + + await expect(vault.delete('API_KEY')).rejects.toThrow(); + + db = await createTestDb(); + alice = await seedIdentity(db); + overrideIdentity(alice); + + }); + + }); + + describe('copy()', () => { + + const tempFiles: string[] = []; + + afterEach(async () => { + + for (const file of tempFiles.splice(0)) { + + await unlink(file).catch(() => {}); + + } + + }); + + it('should resolve to a VaultCopyResult (not a tuple) on success', async () => { + + const sourceFile = join( + tmpdir(), + `noorm-vault-copy-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`, + ); + tempFiles.push(sourceFile); + + const sourceSetupDb = new Kysely({ + dialect: new SqliteDialect({ database: new BunSqliteDatabase(sourceFile) as never }), + }); + + await v1.up(sourceSetupDb as Kysely, 'sqlite'); + await sourceSetupDb + .insertInto(NOORM_TABLES.identities) + .values({ + identity_hash: alice.identityHash, + email: 'alice@example.com', + name: 'Alice', + machine: 'test-machine', + os: 'test-os', + public_key: alice.publicKey, + encrypted_vault_key: null, + } as never) + .execute(); + + const [vaultKey] = await initializeVault(sourceSetupDb, alice.identityHash, alice.publicKey, 'sqlite'); + await setVaultSecret(sourceSetupDb, vaultKey as Buffer, 'API_KEY', 'sk-live-abc', alice.identityHash, 'sqlite'); + await sourceSetupDb.destroy(); + + const sourceConfig = makeConfig('vault-copy-source', { database: sourceFile }); + const destConfig = makeConfig('vault-copy-dest'); + + const vault = new VaultNamespace(makeState(db, sourceConfig)); + + const result: VaultCopyResult = await vault.copy(destConfig, ['API_KEY'], alice.privateKey); + + expect(Array.isArray(result)).toBe(false); + expect(result.copied).toEqual(['API_KEY']); + expect(result.errors).toEqual([]); + + }); + + it('should throw the underlying Error (not resolve a tuple) when the source vault is unreachable', async () => { + + const brokenConfig = makeConfig('vault-copy-broken', { database: '/nonexistent-dir-noorm-test/broken.sqlite' }); + + const vault = new VaultNamespace(makeState(db, brokenConfig)); + + await expect( + vault.copy(makeConfig('vault-copy-dest'), ['API_KEY'], alice.privateKey), + ).rejects.toThrow(); + + }); + + }); + +}); diff --git a/tests/utils/db-guard.test.ts b/tests/utils/db-guard.test.ts new file mode 100644 index 00000000..5c9875d2 --- /dev/null +++ b/tests/utils/db-guard.test.ts @@ -0,0 +1,143 @@ +/** + * Unit tests for the test-database naming-convention guard. + * + * Proves createTestConnection refuses to connect to anything that doesn't + * look like a dedicated test database — the guard fires before any + * connection attempt, so none of these tests require docker. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { attempt, attemptSync } from '@logosdx/utils'; + +import { assertTestDatabase, createTestConnection, NotATestDatabaseError, TEST_CONNECTIONS } from './db.js'; +import type { ConnectionConfig, Dialect } from '../../src/core/connection/types.js'; + +function makeConfig(dialect: Dialect, database: string | undefined): ConnectionConfig { + + return { dialect, database } as unknown as ConnectionConfig; + +} + +describe('utils: assertTestDatabase', () => { + + it('accepts database names that look like test databases', () => { + + const accepted = [ + 'noorm_test', + 'noorm_test_dest', + ':memory:', + 'test', + 'my_test_db', + 'TEST-DB', + 'Test_Suite', + ]; + + for (const database of accepted) { + + expect(() => assertTestDatabase(makeConfig('postgres', database))).not.toThrow(); + + } + + }); + + it('rejects database names that do not look like test databases', () => { + + const rejected = ['production', 'noorm', 'attestation', 'contest', 'testdata', 'mytestdb', '', undefined]; + + for (const database of rejected) { + + expect(() => assertTestDatabase(makeConfig('postgres', database))).toThrow(NotATestDatabaseError); + + } + + }); + + it('names the database, the convention, and the remediation env var in the error message', () => { + + expect.assertions(4); + + const [, err] = attemptSync(() => assertTestDatabase(makeConfig('postgres', 'prod_analytics'))); + + if (err instanceof NotATestDatabaseError) { + + expect(err.message).toContain('prod_analytics'); + expect(err.message).toContain('test'); + expect(err.message).toContain('TEST_POSTGRES_DATABASE'); + expect(err.database).toBe('prod_analytics'); + + } + + }); + + it('names the database as "(unset)" when database is undefined or empty', () => { + + expect.assertions(2); + + const [, undefinedErr] = attemptSync(() => assertTestDatabase(makeConfig('mysql', undefined))); + const [, emptyErr] = attemptSync(() => assertTestDatabase(makeConfig('mysql', ''))); + + if (undefinedErr instanceof NotATestDatabaseError) { + + expect(undefinedErr.message).toContain('(unset)'); + + } + + if (emptyErr instanceof NotATestDatabaseError) { + + expect(emptyErr.message).toContain('(unset)'); + + } + + }); + + it('accepts the default TEST_CONNECTIONS entry for every dialect', () => { + + for (const dialect of Object.keys(TEST_CONNECTIONS) as Dialect[]) { + + expect(() => assertTestDatabase(TEST_CONNECTIONS[dialect])).not.toThrow(); + + } + + }); + +}); + +describe('utils: createTestConnection guard', () => { + + const originalPostgresConfig = TEST_CONNECTIONS.postgres; + + beforeEach(() => { + + TEST_CONNECTIONS.postgres = originalPostgresConfig; + + }); + + afterEach(() => { + + TEST_CONNECTIONS.postgres = originalPostgresConfig; + + }); + + it('throws NotATestDatabaseError before attempting to connect', async () => { + + TEST_CONNECTIONS.postgres = { ...originalPostgresConfig, database: 'prod_analytics' }; + + expect.assertions(1); + + const [, err] = await attempt(() => createTestConnection('postgres')); + + expect(err).toBeInstanceOf(NotATestDatabaseError); + + }); + + it('resolves createTestConnection("sqlite") without docker (":memory:" passes the guard)', async () => { + + const conn = await createTestConnection('sqlite'); + + expect(conn.db).toBeDefined(); + expect(conn.dialect).toBe('sqlite'); + + await conn.destroy(); + + }); + +}); diff --git a/tests/utils/db.ts b/tests/utils/db.ts index 32639d65..d01190e6 100644 --- a/tests/utils/db.ts +++ b/tests/utils/db.ts @@ -63,12 +63,85 @@ export const TEST_CONNECTIONS: Record = { */ const FIXTURES_DIR = join(import.meta.dirname, '..', 'fixtures', 'sql'); +/** + * Error thrown when a resolved connection's database name does not look + * like a dedicated test database. + * + * `createTestConnection` targets run destructive operations (truncate, + * teardown, drop) — this stops a stray `.env`, a leaked CI secret, or a + * copy-pasted production env file from pointing that suite at a real + * database with nothing in the code path to stop it. + * + * @example + * ```typescript + * throw new NotATestDatabaseError('postgres', 'prod_analytics') + * ``` + */ +export class NotATestDatabaseError extends Error { + + override readonly name = 'NotATestDatabaseError' as const; + + constructor( + public readonly dialect: Dialect, + public readonly database: string | undefined, + ) { + + const resolved = database ? `"${database}"` : '(unset)'; + const envVar = `TEST_${dialect.toUpperCase()}_DATABASE`; + + super( + `Refusing to connect: ${dialect} database ${resolved} does not look like a test ` + + 'database. The test suite runs destructive operations (truncate, teardown, drop). ' + + 'Use a database whose name is ":memory:" or contains "test" as a word ' + + `(e.g. "noorm_test"), or set ${envVar} to a dedicated test database, e.g. "noorm_test".`, + ); + + } + +} + +/** + * Assert that a connection's resolved database name looks like a test + * database. + * + * `checkRequireTest` (`src/sdk/guards.ts`) gates on the declared + * `Config.isTest` flag, but `createTestConnection` only holds a + * `ConnectionConfig` with no `isTest` field — this naming-convention + * check is the single source of truth for the test harness's fallback + * guard. Pure synchronous validation, no I/O. + * + * @throws NotATestDatabaseError unless `config.database` is `:memory:` + * or contains "test" as a `_`/`-`-delimited word + * + * @example + * ```typescript + * assertTestDatabase({ dialect: 'postgres', database: 'noorm_test' }) // no-op + * assertTestDatabase({ dialect: 'postgres', database: 'prod' }) // throws + * ``` + */ +export function assertTestDatabase(config: ConnectionConfig): void { + + const { dialect, database } = config; + const isMemory = database === ':memory:'; + const isTestNamed = Boolean(database) && /(^|[_-])test([_-]|$)/i.test(database ?? ''); + + if (!isMemory && !isTestNamed) { + + throw new NotATestDatabaseError(dialect, database); + + } + +} + /** * Create a test database connection. * * @param dialect - Database dialect to connect to * @returns Connection result with db instance and destroy function * + * @throws NotATestDatabaseError if the resolved database name doesn't + * look like a test database — checked before any connection attempt + * * @example * ```typescript * const conn = await createTestConnection('postgres') @@ -84,6 +157,8 @@ export async function createTestConnection(dialect: Dialect): Promise