Skip to content

Configuration

The Problem

A database tool needs connection details: host, port, credentials, paths. But where should these come from? Hardcoding is inflexible. Environment variables alone don't persist. Config files risk credential leaks.

noorm solves this with layered configuration. Multiple sources merge together with clear precedence. Sensitive data stays encrypted. Environment variables enable CI/CD without changing stored configs.

Configuration Sources

Configs come from five sources, merged in priority order:

PrioritySourcePurpose
1 (highest)CLI flagsOne-time overrides
2Environment variablesCI/CD, per-session tweaks
3Stored configYour saved database configs
4Stage defaultsTeam-defined templates
5 (lowest)DefaultsSensible fallbacks

Higher priority sources override lower ones. This means you can set a base config, override the host via environment for CI, and override the port via CLI for a specific run.

typescript
import { resolveConfig } from './core/config'

// Merges all sources into final config
const config = resolveConfig(state, {
    name: 'dev',
    flags: { connection: { port: 5433 } },  // Override just the port
})

Config Structure

A complete config defines everything needed to connect and run:

typescript
interface Config {
    name: string              // Unique identifier: 'dev', 'staging', 'prod'
    type: 'local' | 'remote'  // Connection type
    isTest: boolean           // Test database flag
    access: ConfigAccess      // Per-channel access roles — see Access Roles below

    connection: {
        dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql'
        host?: string         // Required for non-SQLite, defaults to 'localhost'
        port?: number         // Default by dialect
        database: string      // Database name or file path
        filename?: string     // SQLite only — alternative to database
        user?: string
        password?: string
        ssl?: boolean | SSLConfig
        pool?: { min?: number, max?: number }  // Defaults to { min: 0, max: 10 }
        tlsServerName?: string  // MSSQL only — cert hostname when host is an IP
        connectTimeoutMs?: number  // Per attempt, defaults to 15000
    }

    identity?: string         // Override audit identity
}

File locations (paths.sql, paths.changes) are not part of Config — they live in settings.yml and are read through SettingsManager.getPaths(). See Settings.

connectTimeoutMs bounds one connection attempt, and is passed to whichever option the dialect's driver exposes for it — connectionTimeoutMillis on pg, connectTimeout on mysql2 and tedious. It exists because pg defaults to no limit, so an unreachable host waited forever. Raise it when the link is slow but working; a serverless database resuming from auto-pause is the case that needs it.

Environment Variables

Every config property maps to an environment variable using nested naming. Underscores separate nesting levels:

NOORM_{PATH}_{TO}_{VALUE}  →  { path: { to: { value: '' } } }

Connection variables:

VariableConfig PathNotes
NOORM_CONNECTION_DIALECTconnection.dialectpostgres, mysql, sqlite, mssql
NOORM_CONNECTION_HOSTconnection.host
NOORM_CONNECTION_PORTconnection.portAuto-parsed as integer
NOORM_CONNECTION_DATABASEconnection.database
NOORM_CONNECTION_USERconnection.user
NOORM_CONNECTION_PASSWORDconnection.passwordKept as string
NOORM_CONNECTION_SSLconnection.sslUse 'true'/'false'
NOORM_CONNECTION_POOL_MINconnection.pool.min
NOORM_CONNECTION_POOL_MAXconnection.pool.max

Path variables (override settings.yml paths, not Config):

VariableSettings Path
NOORM_PATHS_SQLpaths.sql
NOORM_PATHS_CHANGESpaths.changes

Top-level variables:

VariableConfig PathNotes
NOORM_NAMEname
NOORM_TYPEtype'local' or 'remote'
NOORM_PROTECTEDaccess (legacy)Use 'true'/'false'. Maps through the deprecated protected boolean into access — see Access Roles
NOORM_IDENTITYidentity
NOORM_isTestisTestcamelCase preserved

Note: For camelCase properties like isTest, preserve the case: NOORM_isTest (not NOORM_IS_TEST).

Behavior variables (excluded from config merging — the META_ENV_VARS set in core/config):

VariablePurpose
NOORM_CONFIGWhich config to use
NOORM_YESSkip confirmations
NOORM_JSONJSON output mode
NOORM_HEADLESSHeadless mode detection
NOORM_DEBUGDebug logging
NOORM_DEVDev mode detection
NOORM_CI_CONFIG_NAMEci init config name override
NOORM_LOGGER_DEBUGLogger-internal debug
NOORM_CHANNELPolicy channel override (resolveChannel)

NOORM_IDENTITY_* is also excluded — it belongs to loadIdentityFromEnv, and forwarding it would collide with the identity: string field.

bash
# CI/CD example: use stored config with overridden host
export NOORM_CONFIG=staging
export NOORM_CONNECTION_HOST=db.ci-runner.local
noorm run build

Config Resolution

The resolver determines which config to use and merges all sources.

typescript
const config = resolveConfig(state, options)

Resolution follows this flow:

  1. Determine config name from (in order):

    • options.name (explicit)
    • NOORM_CONFIG env var
    • Active config in state
  2. If no name found, check if env vars provide enough to run:

    • Need at least NOORM_CONNECTION_DIALECT and NOORM_CONNECTION_DATABASE
    • If yes, build config from env only (named __env__)
    • If no, return null
  3. Load stored config by name (throws if not found)

  4. Merge sources: defaults ← stage ← stored ← env ← flags

  5. Validate the merged result

typescript
// Explicit name
resolveConfig(state, { name: 'production' })

// From NOORM_CONFIG
process.env.NOORM_CONFIG = 'staging'
resolveConfig(state)  // uses 'staging'

// From active config
state.setActiveConfig('dev')
resolveConfig(state)  // uses 'dev'

// Env-only (CI mode)
process.env.NOORM_CONNECTION_DIALECT = 'postgres'
process.env.NOORM_CONNECTION_DATABASE = 'ci_test'
resolveConfig(state)  // creates __env__ config

Validation

Configs are validated using Zod schemas. Key rules:

  • name - Required, alphanumeric with hyphens/underscores
  • connection.dialect - Must be one of the four supported
  • connection.host - Required for non-SQLite
  • connection.port - Integer 1-65535
  • connection.database - Required

Default ports by dialect:

DialectDefault Port
postgres5432
mysql3306
mssql1433
sqliteN/A
typescript
import { validateConfig, parseConfig } from './core/config'

// Throws on invalid config
validateConfig(config)

// Returns config with defaults applied
const full = parseConfig(partial)

Access Roles

Config.protected: boolean was replaced by per-channel access roles. Roles live on the config, not the caller — the caller is a channel: user (a human) or agent (an AI agent, over MCP or the CLI). Each config declares a role per channel:

typescript
const config = {
    name: 'prod',
    access: {
        user: 'operator',   // what a human at the CLI/TUI gets
        agent: 'viewer',       // what a connected AI agent gets — can differ from user
    },
    // ...
}

Three roles, hard-coded, not user-extensible:

Permissionvieweroperatoradmin
explore, sql:readallowallowallow
sql:writedenyallowallow
sql:ddldenydenyallow
change:run, change:ff, change:revertdenyconfirmallow
run:build, run:file, run:dirdenyconfirmallow
db:create, db:resetdenyconfirmallow
db:destroydenydenyconfirm
config:rmdenyconfirmconfirm

agent: false is not a role — it makes the config invisible to agents on both MCP and the CLI (absent from list_configs, connect fails with the byte-identical error an unknown config produces).

Check policy before executing:

typescript
import { checkPolicy } from './core/policy'

const check = checkPolicy('user', config, 'change:run')

if (!check.allowed) {
    console.error(check.blockedReason)
    process.exit(1)
}

if (check.requiresConfirmation) {
    const input = await prompt(`Type "${check.confirmationPhrase}" to confirm:`)
    if (input !== check.confirmationPhrase) {
        process.exit(1)
    }
}

// Proceed with action

confirm resolves per channel: on user it prompts for yes-<config> (skippable with NOORM_YES=1); on agent it collapses straight to deny — an agent confirming its own destructive action is theater, and on the CLI it would need only --yes.

bash
export NOORM_YES=1
noorm change run  # No prompt, even on an operator-role config

Migration: a legacy protected: true maps to { user: 'operator', agent: 'viewer' }; protected: false or absent maps to the default { user: 'admin', agent: 'viewer' }. A config that already stores an explicit access is left as-is. The protected field is accepted on input for one version, then dropped — see the state migration in core/version/state/migrations/.

Stages

Stages are team-defined config templates from settings.yml. They provide defaults and enforce constraints.

yaml
# .noorm/settings.yml
stages:
    prod:
        description: Production database
        locked: true           # Cannot delete this config
        defaults:
            dialect: postgres
            protected: true    # Access ceiling: clamps resolved access to at most operator/viewer
        secrets:
            - key: DB_PASSWORD
              type: password
              required: true

When resolving a config linked to a stage, stage defaults merge in:

typescript
import { resolveConfig, SettingsProvider } from './core/config/resolver'

const config = resolveConfig(state, {
    name: 'prod',
    settings: new SettingsProvider(settingsManager),
    // stage: 'prod',  // optional — omit to auto-link by matching config name
})
// Stage defaults applied, then stored config, then env, then flags

Note: settings takes a SettingsProvider, not a SettingsManager — wrap the manager. The class must be imported from core/config/resolver directly; the core/config barrel re-exports it type-only. When stage is omitted, findStageForConfig auto-links a stage whose name matches the config name.

Config Completeness

A config is "complete" when all required secrets (from its stage) are set. Incomplete configs have limited functionality.

typescript
import { checkConfigCompleteness } from './core/config'

// Fourth argument is an options object: { stageName?, vaultSecretKeys? }
const check = checkConfigCompleteness(config, state, settings, {
    stageName: 'prod',
    vaultSecretKeys: ['DB_PASSWORD'],  // vault keys count as "set"
})

// Without stageName - auto-links a stage whose name matches the config name
const check = checkConfigCompleteness(config, state, settings)

if (!check.complete) {
    console.log('Missing secrets:', check.missingSecrets)
    console.log('Constraint violations:', check.violations)
}

Home Screen Status

The home screen displays setup status for all stage-linked configs:

Stage Configs:
  ✓ dev
  ✓ staging
    prod     ✗ secrets (2)
  • indicates all required secrets are set
  • ✗ secrets (N) shows how many secrets are missing

This helps track which environments are ready to use and which need secret values configured.

Stage constraints that can't be violated:

ConstraintBehavior
protected: true in defaultsClamps resolved access to at most { user: 'operator', agent: 'viewer' } — stricter survives, looser is clamped down (see Access Roles)
isTest: true in defaultsCannot set isTest: false
locked: trueConfig cannot be deleted
typescript
import { canDeleteConfig } from './core/config'

// Basic usage
const { allowed, reason } = canDeleteConfig('prod', settings)

// With explicit stage name (optional 3rd parameter)
const { allowed, reason } = canDeleteConfig('prod', settings, 'production')

if (!allowed) {
    console.error(reason)  // "Config 'prod' is linked to a locked stage..."
}

CI/CD Mode

In CI pipelines, configs can be built entirely from environment variables:

bash
# GitHub Actions example
env:
    NOORM_CONNECTION_DIALECT: postgres
    NOORM_CONNECTION_HOST: ${{ secrets.DB_HOST }}
    NOORM_CONNECTION_DATABASE: ${{ secrets.DB_NAME }}
    NOORM_CONNECTION_USER: ${{ secrets.DB_USER }}
    NOORM_CONNECTION_PASSWORD: ${{ secrets.DB_PASSWORD }}
    NOORM_YES: 1

steps:
    - run: noorm run build

Minimum required env vars:

  • NOORM_CONNECTION_DIALECT
  • NOORM_CONNECTION_DATABASE
typescript
// Check if in CI mode
import { isCi, shouldSkipConfirmations } from './core/environment'

if (isCi()) {
    // Running in CI environment
}

if (shouldSkipConfirmations()) {
    // NOORM_YES is set
}

Observer Events

Config operations emit events:

typescript
observer.on('config:created', ({ name }) => {
    console.log(`Created config: ${name}`)
})

observer.on('config:updated', ({ name, fields }) => {
    console.log(`Updated ${name}: ${fields.join(', ')}`)
})

observer.on('config:deleted', ({ name }) => {
    console.log(`Deleted config: ${name}`)
})

observer.on('config:activated', ({ name, previous }) => {
    console.log(`Switched from ${previous} to ${name}`)
})

Config Summary

For listings, use ConfigSummary which omits sensitive connection details:

typescript
const summaries = state.listConfigs()
// [
//     { name: 'dev', type: 'local', isTest: false, access: { user: 'admin', agent: 'admin' }, isActive: true, dialect: 'postgres', database: 'dev_db' },
//     { name: 'prod', type: 'remote', isTest: false, access: { user: 'operator', agent: 'viewer' }, isActive: false, dialect: 'postgres', database: 'prod_db' },
// ]

The ConfigSummary interface:

typescript
interface ConfigSummary {
    name: string
    type: 'local' | 'remote'
    isTest: boolean
    access: ConfigAccess
    isActive: boolean
    dialect: Dialect
    database: string
}