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/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/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/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..a5107d4a 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 --config prod change ff --json
noorm change ff --help # Per-command help, rendered by citty
```
@@ -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
```
@@ -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
@@ -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..8d1ffda4 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}`)
}
```
@@ -1003,7 +1001,7 @@ noorm --config dev change run 2024-01-15-add-users
noorm --config test db truncate
# JSON output for scripting
-noorm --json --config dev change ff | jq '.status'
+noorm --config dev change ff --json | jq '.status'
```
diff --git a/docs/dev/secrets.md b/docs/dev/secrets.md
index 5ef14447..9addb118 100644
--- a/docs/dev/secrets.md
+++ b/docs/dev/secrets.md
@@ -155,7 +155,7 @@ The local secret store is intentionally TUI-only — there is no headless `noorm
```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
```
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..88de9b3f 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.
@@ -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..0d41ad78 100644
--- a/docs/guide/changes/overview.md
+++ b/docs/guide/changes/overview.md
@@ -208,7 +208,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..44da1904 100644
--- a/docs/guide/database/teardown.md
+++ b/docs/guide/database/teardown.md
@@ -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
@@ -258,7 +258,7 @@ noorm --dry-run db teardown
### JSON Output
```bash
-noorm --json db teardown
+noorm db teardown --json
```
```json
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..3e5af872 100644
--- a/docs/guide/environments/secrets.md
+++ b/docs/guide/environments/secrets.md
@@ -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/headless.md b/docs/headless.md
index fbee29b4..973b4353 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
```
@@ -57,7 +57,7 @@ These options are reused across most commands. Run ` --help` to see exa
> **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).
+> **`--json` placement:** `--json` is per-subcommand, not global — it must appear **after** the subcommand name, e.g. `noorm change ff --json`. See [CLI flag conventions](./cli/flags.md) for the full reasoning. For per-command help, see [Discovering command help](./cli/help.md).
## Configuration
@@ -127,7 +127,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 +141,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 +178,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 +231,7 @@ Set the active configuration.
```bash
noorm config use dev
-noorm --json config use production
+noorm config use production --json
```
**JSON output:**
@@ -247,7 +246,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 +257,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 +366,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 +405,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 +415,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
```
@@ -650,7 +660,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 +680,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 +714,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,7 +741,7 @@ 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 run preview sql/seed.sql.tmpl --json
noorm --config staging run preview sql/migrations/002.sql.tmpl
```
@@ -812,7 +822,7 @@ Apply the next pending change only.
```bash
noorm change next
-noorm --json change next
+noorm change next --json
```
@@ -823,7 +833,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 +857,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 +868,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
```
@@ -888,7 +898,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 +912,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 +940,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 +999,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):**
@@ -1081,7 +1091,7 @@ Check if database is locked.
```bash
noorm lock status
-noorm --json lock status
+noorm lock status --json
```
**JSON output:**
@@ -1103,7 +1113,7 @@ Acquire an exclusive lock.
```bash
noorm lock acquire
-noorm --json lock acquire
+noorm lock acquire --json
```
**CI/CD pattern with cleanup:**
@@ -1152,7 +1162,7 @@ Initialize the vault for the current database.
```bash
noorm vault init
-noorm --json vault init
+noorm vault init --json
```
@@ -1162,7 +1172,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 +1184,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 +1208,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 +1238,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 +1261,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 +1303,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 +1360,7 @@ Project and database status overview.
```bash
noorm info
-noorm --json info
+noorm info --json
```
**JSON output:**
@@ -1387,7 +1397,7 @@ CLI version and diagnostic information.
```bash
noorm version
-noorm --json version
+noorm version --json
```
@@ -1397,7 +1407,7 @@ Check for and install noorm updates.
```bash
noorm update
-noorm --json update
+noorm update --json
```
@@ -1577,7 +1587,7 @@ noorm ci init --name prod
noorm lock acquire
trap "noorm lock release" EXIT
noorm change ff
-noorm --json db explore
+noorm db explore --json
```
@@ -1585,7 +1595,7 @@ noorm --json db explore
```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 +1604,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/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/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/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..866ccd3e 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-json-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..8a3f97d5 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, renameSync, unlinkSync } from 'fs';
import { get as httpsGet } from 'https';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
@@ -18,6 +19,27 @@ const PACKAGE_ROOT = resolve(__dirname, '..');
const BIN_DIR = resolve(PACKAGE_ROOT, 'bin');
const REPO = 'noormdev/noorm';
+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';
+
+ }
+
+}
+
/**
* Resolves the platform suffix used in binary asset names.
*
@@ -107,11 +129,164 @@ 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() {
@@ -120,13 +295,11 @@ async function main() {
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 +307,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-json-placement.sh b/scripts/check-json-placement.sh
new file mode 100755
index 00000000..ec43adfb
--- /dev/null
+++ b/scripts/check-json-placement.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+# check-json-placement.sh - guard against the broken root-level `--json` form in docs
+#
+# `noorm --json ` is silently swallowed by citty (no error, no JSON,
+# default human output) — `--json` is a per-subcommand flag, not a root one.
+# Only ` --json` works. This script fails the build if the broken form
+# reappears in doc/skill/example prose outside the files that deliberately
+# show it as a contrasted gotcha or a literal transcript.
+#
+# Usage:
+# bash scripts/check-json-placement.sh
+#
+set -e
+
+PATTERN='noorm --json '
+TARGETS=(README.md docs skills examples)
+
+# Files that intentionally show the broken form: either contrasted directly
+# against the correct form under a "does not work" heading, a literal shell
+# transcript of a real bug-hunting session. `docs/spec/` is exempt wholesale —
+# specs document the anti-pattern in prose by nature.
+EXEMPT_REGEX='^(docs/cli/flags\.md|docs/guide/troubleshooting\.md|examples/llm-memory-db-mssql/mssql-problems\.md|examples/llm-memory-db-pg/REPORT\.md|examples/llm-memory-db-pg/REPORT-PHASE-1\.md|docs/spec/.*):'
+
+MATCHES=$(grep -rn -- "$PATTERN" "${TARGETS[@]}" 2>/dev/null | grep -Ev "$EXEMPT_REGEX") || true
+
+if [ -n "$MATCHES" ]; then
+
+ echo "Found root-level 'noorm --json' placement (broken — --json must come after the subcommand):"
+ echo "$MATCHES"
+ exit 1
+
+fi
+
+echo "OK: no root-level 'noorm --json' occurrences 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..5821633d 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
```
---
@@ -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,7 +361,7 @@ 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 run preview sql/seed.sql.tmpl --json # JSON envelope
noorm --config staging run preview sql/migrations/002.sql.tmpl
```
@@ -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
```
@@ -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 }`
@@ -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..b6b9158a 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;
@@ -169,7 +193,7 @@ export async function withContext(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);
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..fbc35285 100644
--- a/src/cli/db/create.ts
+++ b/src/cli/db/create.ts
@@ -74,7 +74,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/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/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..468df99e 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,11 +96,16 @@ 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 };
}
@@ -223,10 +232,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) {
@@ -292,16 +309,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/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..16644a0b 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';
@@ -237,6 +240,7 @@ export async function executeChange(
files,
'change',
changeChecksum,
+ opts.force,
history,
start,
),
@@ -376,6 +380,7 @@ export async function revertChange(
files,
'revert',
revertChecksum,
+ opts.force,
history,
start,
),
@@ -405,8 +410,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 +457,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 +644,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 +706,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 +737,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 +752,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 +773,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 +785,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 +804,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' },
});
}
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..7156beb1 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';
@@ -90,8 +92,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);
@@ -187,7 +189,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 +354,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 +565,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 {
- 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..5519de2f 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';
@@ -514,7 +513,7 @@ export class Logger {
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 +526,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 +585,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..684bad47 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.
@@ -244,7 +245,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/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/utils.ts b/src/core/template/utils.ts
index aed17dd9..b2248a94 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);
}
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/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..96c6be6c 100644
--- a/src/sdk/context.ts
+++ b/src/sdk/context.ts
@@ -18,6 +18,7 @@ import { createConnection } 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 +96,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;
}
@@ -225,12 +220,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 +303,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 +346,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..09e6efac 100644
--- a/src/sdk/namespaces/changes.ts
+++ b/src/sdk/namespaces/changes.ts
@@ -38,6 +38,7 @@ import { getStateManager } from '../../core/state/index.js';
import { checkProtectedConfig } from '../guards.js';
import type { ContextState } from '../state.js';
+import { requireConnection } from '../state.js';
// ─────────────────────────────────────────────────────────────
// ChangesNamespace
@@ -158,6 +159,8 @@ export class ChangesNamespace {
*/
async delete(change: Change): Promise {
+ checkProtectedConfig(this.#state.config, this.#state.options, 'change:rm', 'changes.delete');
+
return coreDeleteChange(change);
}
@@ -435,29 +438,18 @@ 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 {
const state = getStateManager(this.#state.projectRoot);
-
- if (!this.#state.connection) {
-
- throw new Error('Not connected. Call connect() first.');
-
- }
+ const conn = requireConnection(this.#state);
return {
db: this.#kysely as unknown as Kysely,
- dialect: this.#state.connection.dialect,
+ dialect: conn.dialect,
configName: this.#state.config.name,
identity: this.#state.identity,
projectRoot: this.#state.projectRoot,
diff --git a/src/sdk/namespaces/db.ts b/src/sdk/namespaces/db.ts
index df82d7a1..93231102 100644
--- a/src/sdk/namespaces/db.ts
+++ b/src/sdk/namespaces/db.ts
@@ -29,6 +29,7 @@ import { truncateData, teardownSchema, previewTeardown } from '../../core/teardo
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;
}
@@ -340,30 +342,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..6e912dc7 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,12 @@ 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 { checkProtectedConfig } from '../guards.js';
import type { ContextState } from '../state.js';
+import { requireConnection } from '../state.js';
import type { BuildOptions } from '../types.js';
// ─────────────────────────────────────────────────────────────
@@ -153,6 +158,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 })
@@ -168,7 +184,40 @@ export class RunNamespace {
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 });
+
+ }
+
+ const filteredFiles = filterFilesByPaths(
+ discoveredFiles,
+ sqlPath,
+ effectivePaths.include,
+ effectivePaths.exclude,
+ );
+
+ return runBuild(context, sqlPath, { force: options?.force }, filteredFiles);
}
@@ -178,13 +227,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;
}
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..944eec3f 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;
+
}
// ─────────────────────────────────────────────────────────────
@@ -88,7 +97,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 +123,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 +138,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..9e9295c6 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 = 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..9777b518 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) {
@@ -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 = 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..f311ec3b 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 = 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..7e14a621 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';
@@ -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 = 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/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/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/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..e0decdc7
--- /dev/null
+++ b/tests/cli/db/create.test.ts
@@ -0,0 +1,201 @@
+/**
+ * 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);
+
+ });
+
+});
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