Skip to content
Merged

Next #96

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/binary-no-dotenv-autoload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@noormdev/cli': patch
---

Stop the `noorm` binary from loading `.env` in the working directory. Bun's loader expanded `$` and cut values at `#`, so a `NOORM_CONNECTION_PASSWORD` containing those characters reached the database as a different password and login failed. `NOORM_*` variables now come only from the process environment: export them in the shell or set them in CI.
6 changes: 6 additions & 0 deletions .changeset/connection-error-reasons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@noormdev/cli': patch
'@noormdev/sdk': patch
---

Say why a database connection failed: refused port, unknown host, timeout, rejected TLS certificate, disabled, locked, or expired account, missing grant, connection limit, missing password, and SQLite file or directory faults. Where the server withholds the reason (SQL Server 18456, PostgreSQL 28P01, MySQL 1045), the message says so and lists the usual causes. Exhausted retries report the server's last error. `connection:error` log entries carry `serverCode` and `serverMessage`.
6 changes: 6 additions & 0 deletions .changeset/mssql-unprivileged-logins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@noormdev/cli': patch
'@noormdev/sdk': patch
---

Let MSSQL logins without server-level access connect. Connecting no longer detours through `master` to look the target up in `sys.databases`, so contained database users (the usual account on Azure SQL Database) and logins without `VIEW ANY DATABASE` can connect, and the config add/edit connection test passes for them. A database that is missing, or that the login cannot open, now fails with that reason instead of "Login failed".
6 changes: 6 additions & 0 deletions .changeset/tui-explore-duplicate-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@noormdev/cli': patch
'@noormdev/sdk': patch
---

Stop TUI lists from leaving stale rows on screen when two rows share an identity: explore indexes and foreign keys whose names repeat across tables (SQL Server's `IX_UserId`, MySQL's `PRIMARY`), PostgreSQL function and procedure overloads, and settings rules with the same description. `listFunctions` and `listProcedures` now return a `signature` on PostgreSQL that tells overloads apart.
5 changes: 5 additions & 0 deletions .changeset/tui-typing-blocks-shortcuts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@noormdev/cli': patch
---

Stop global TUI shortcuts from firing while you type. A capital `L` or `Q` typed into a form field, search box, or the SQL editor used to open the log viewer or the SQL terminal, and `?`, `D`, and `F` could open help or toggle dry-run and force mode. While a text field is taking input, those keys now type their character.
12 changes: 8 additions & 4 deletions .claude/rules/tui-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ useInput((input, key) => {
useInput(handler, { isActive: isFocused });
```

`useFocusedInput(isFocused, handler)` in `src/tui/keyboard.tsx:249` wraps this correctly. Prefer it.
`useFocusedInput(isFocused, handler)` in `src/tui/keyboard.tsx:250` wraps this correctly. Prefer it.


### One focus owner per screen
Expand Down Expand Up @@ -172,9 +172,13 @@ if (key.downArrow) {

### Global keys

`GlobalKeyboard` (`src/tui/keyboard.tsx:114`) owns Ctrl+C, Shift+L, Shift+Q, `?`, `D`, and `F`. It deliberately does **not** handle Esc: each screen handles its own, because a global handler fires alongside the screen handler and pops history twice.
`GlobalKeyboard` (`src/tui/keyboard.tsx:115`) owns Ctrl+C, Shift+L, Shift+Q, `?`, `D`, and `F`. It deliberately does **not** handle Esc: each screen handles its own, because a global handler fires alongside the screen handler and pops history twice.

`?`, `D`, and `F` only fire when `stack.length <= 1`, so they stay inert while a text input is focused.
Every one of them but Ctrl+C stands down while a text field is taking keystrokes. A field says so with `useTextEntry(active)` from `src/tui/focus.tsx`, which counts it while `active` is true and uncounts it on blur or unmount; `GlobalKeyboard` reads `isTyping()` at keypress time. The focus stack cannot answer this: a `TextInput` never pushes its own scope, so `stack.length` stays 1 while you type into a screen's only field.

Registered: `TextInput` (while not `isDisabled`, which also covers `SearchableList` and `FilePicker` search), `SqlInput` (while active), the `LogViewerOverlay` search box, and the `ResultTable` filter box. **A new free-text entry must call `useTextEntry`**, or capital `L`/`Q`/`D`/`F` and `?` typed into it fire their shortcuts.

`?`, `D`, and `F` additionally require `stack.length <= 1`, which keeps them out of nested scopes such as dialogs.


## @inkjs/ui components
Expand Down Expand Up @@ -375,7 +379,7 @@ There is no `k` on Home. Secrets belong to a config, so `k` opens them from the
| `c` | config list | copy |
| `c` | DB list | create |

**Global (every screen, via `GlobalKeyboard`):**
**Global (every screen, via `GlobalKeyboard`; all but `Ctrl+C` inactive while typing in a field):**

| Key | Action |
|-----|--------|
Expand Down
1 change: 1 addition & 0 deletions docs/dev/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ interface FunctionSummary {
schema?: string
parameterCount: number
returnType: string
signature?: string // PostgreSQL only: argument list that tells overloads apart
}

interface IndexSummary {
Expand Down
23 changes: 23 additions & 0 deletions docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ skips every other prompt. Two commands:
See [Non-interactive operation](./automation/non-interactive.md).


## Login fails with a password I know is right

SQL Server, PostgreSQL, and MySQL each send one error for a wrong password and
an unknown account, so a client cannot learn which accounts exist. noorm says so
and lists what that error covers; the server's own log names the cause:

| Server | Error | Also sent for |
|------------|----------------------------------|------------------------------------------------------------------------|
| SQL Server | `Login failed` (18456) | unknown login, login denied `CONNECT SQL`, Windows-only authentication |
| PostgreSQL | `password authentication failed` | unknown role, password past `VALID UNTIL` |
| MySQL | `Access denied` (1045) | unknown user, no account for this client host, `REQUIRE SSL` account |

The message noorm shows is reworded for the user. What the server itself
sent is in the log (`.noorm/state/noorm.log`): the `connection:error`
entry carries `serverCode` (error number, SQLSTATE, or driver code) and
`serverMessage` (the server's text, including any wrapped socket error).

A password that reaches noorm through a `NOORM_CONNECTION_PASSWORD`
environment variable arrives exactly as the shell exported it. The
compiled `noorm` binary does not read `.env` files, so export the
variable or set it in CI.


## Related

- [CLI flag conventions](../cli/flags.md)
Expand Down
4 changes: 4 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ either way.
| `Escape` | Go back / Cancel |
| `Ctrl+C` | Quit |

While a text field is taking input (a form field in edit mode, a search box,
the SQL editor), `Shift+L`, `Shift+Q`, `?`, `D`, and `F` type their character
instead. They work again once you leave the field.


### Cancelling a Database Operation

Expand Down
5 changes: 3 additions & 2 deletions docs/wiki/core-db.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ description: Database lifecycle, connection factory, schema exploration, teardow
- [`src/core/db/policy.ts`](../../src/core/db/policy.ts) — `assertDbPolicy`, the shared destructive-lifecycle gate used by `core/db` and `core/teardown` (both reached directly by the TUI and indirectly by the CLI via the SDK).
- [`src/core/db/dual.ts`](../../src/core/db/dual.ts) — `withDualConnection`, generic two-connection lifecycle (connect both, run fn, always cleanup both) used by `transfer` and vault-copy.
- [`src/core/db/dialects/postgres.ts`](../../src/core/db/dialects/postgres.ts), `mysql.ts`, `mssql.ts`, `sqlite.ts` — per-dialect `databaseExists`/`createDatabase`/`dropDatabase`/`getSystemDatabase`.
- [`src/core/connection/factory.ts`](../../src/core/connection/factory.ts) — `createConnection` (retry/backoff via `@logosdx/utils` `retry`, `shouldRetry` skips auth/config failures), `testConnection` (`testServerOnly` swaps to the dialect's system database: `postgres`, `master`, none for mysql/sqlite).
- [`src/core/connection/factory.ts`](../../src/core/connection/factory.ts) — `createConnection` (retry/backoff via `@logosdx/utils` `retry`, `shouldRetry` skips auth/config failures), `testConnection` (`testServerOnly` tries the target first and falls back to the dialect's system database only when the target does not exist, because a contained MSSQL user cannot open `master`; the system databases are `postgres`, `master`, and none for mysql/sqlite).
- [`src/core/connection/manager.ts`](../../src/core/connection/manager.ts) — `ConnectionManager` singleton (`getConnectionManager`); tracks cached (by config name) and ephemeral connections plus `WorkerBridge` instances, closes everything on the `app:shutdown` observer event.
- [`src/core/connection/defaults.ts`](../../src/core/connection/defaults.ts) — `DEFAULT_PORTS` per dialect and the shared `PortSchema` (1-65535) used by `core/config` and `core/settings`.
- [`src/core/connection/dialects/mssql.ts`](../../src/core/connection/dialects/mssql.ts) — `resolveTlsServerName`/`buildTediousOptions`; connecting to MSSQL by IP address needs a synthetic SNI ServerName (`UNVERIFIED_TLS_SERVER_NAME`) because RFC 6066 forbids an IP literal as SNI, and `verifyDatabaseExists` probes `sys.databases` on `master` before opening the real pool to avoid a cryptic tedious/tarn ECONNRESET hang.
- [`src/core/connection/dialects/mssql.ts`](../../src/core/connection/dialects/mssql.ts) — `resolveTlsServerName`/`buildTediousOptions`; connecting to MSSQL by IP address needs a synthetic SNI ServerName (`UNVERIFIED_TLS_SERVER_NAME`) because RFC 6066 forbids an IP literal as SNI, and `createMssqlConnection` opens the first pooled connection itself because tedious keeps only the last login error; the dialect records every login error's number and message and hands them to `explainMssqlLoginFailure`.
- [`src/core/connection/errors.ts`](../../src/core/connection/errors.ts) — `explainConnectionError`, applied by `createConnection` to every failure, and `explainMssqlLoginFailure`. Per-dialect tables map a code (SQL Server error number, SQLSTATE, mysql2 code, SQLite code, or a Node network/TLS code found through `cause` and `AggregateError.errors`) to a user message; the codes where the server withholds the reason (18456, 28P01, 1045) list the usual causes. The result is a `DatabaseConnectionError` whose `serverCode`/`serverMessage` keep what the server said, which `createConnection` puts on the `connection:error` event and so into the log. Its messages never say "does not exist" unless a database is missing, because the TUI offers to create one on that phrase.
- [`src/core/connection/dialects/mssql-limit-plugin.ts`](../../src/core/connection/dialects/mssql-limit-plugin.ts) — `MssqlLimitPlugin`, a Kysely `OperationNodeTransformer` that rewrites `LimitNode` → `TopNode` because Kysely 0.28.x's `MssqlQueryCompiler` doesn't override `visitLimit()`.
- [`src/core/connection/dialects/sqlite.ts`](../../src/core/connection/dialects/sqlite.ts) / `sqlite-bun.ts` — `better-sqlite3` vs `bun:sqlite` adapters; `factory.ts` picks the Bun one when `globalThis.Bun` is defined.
- [`src/core/connection/dialects/bun-sqlite.d.ts`](../../src/core/connection/dialects/bun-sqlite.d.ts) — hand-written minimal `bun:sqlite` type declarations, to avoid depending on full `bun-types`.
Expand Down
6 changes: 5 additions & 1 deletion scripts/build-binary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ for (const { bun: target, suffix } of targets) {
const outfile = `packages/cli/bin/noorm-${suffix}`;
console.log(` Building ${outfile} (${target})...`);

await $`bun build --compile --target=${target} --minify src/cli/index.ts src/workers/connection.ts src/workers/compute.ts --outfile ${outfile} --define __CLI_VERSION__=\"${version}\"`.quiet();
// A compiled binary loads `.env` from the cwd by default, and Bun's parser
// expands `$` even inside single quotes and cuts unquoted values at `#`. A
// project `.env` holding NOORM_CONNECTION_PASSWORD reached the driver as a
// different password, so the binary reads only the real environment.
await $`bun build --compile --no-compile-autoload-dotenv --target=${target} --minify src/cli/index.ts src/workers/connection.ts src/workers/compute.ts --outfile ${outfile} --define __CLI_VERSION__=\"${version}\"`.quiet();

console.log(` ✓ ${outfile}`);

Expand Down
116 changes: 42 additions & 74 deletions src/core/connection/dialects/mssql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@
* Uses 'tedious' and 'tarn' packages for MSSQL connections.
* Install with: npm install tedious tarn
*
* Verifies database existence via sys.databases before connecting
* to the target database, avoiding cryptic ECONNRESET errors when
* the database doesn't exist.
* Connects straight to the target database, so a login needs no access to
* `master` or to other databases' rows in `sys.databases`.
*/
import { isIP } from 'node:net';

import { attempt } from '@logosdx/utils';
import { Kysely, MssqlDialect, sql } from 'kysely';
import type { ConnectionConfiguration } from 'tedious';

import type { ConnectionConfig, ConnectionResult } from '../types.js';
import { DEFAULT_PORTS, connectTimeoutFor } from '../defaults.js';
import { explainMssqlLoginFailure } from '../errors.js';
import type { MssqlServerError } from '../errors.js';
import { MssqlLimitPlugin } from './mssql-limit-plugin.js';

/**
Expand Down Expand Up @@ -119,16 +121,13 @@ export function resolveTlsServerName(config: ConnectionConfig): string | undefin
/**
* Build tedious connection options from noorm config.
*
* Centralizes the tedious config so both the preflight check
* and the real pool use the same settings.
* Kept separate from the pool so the TLS and timeout choices can be asserted
* without a server.
*
* @example
* const options = buildTediousOptions(config, 'master');
* const options = buildTediousOptions(config);
*/
export function buildTediousOptions(
config: ConnectionConfig,
database?: string,
): ConnectionConfiguration {
export function buildTediousOptions(config: ConnectionConfig): ConnectionConfiguration {

return {
server: config.host ?? 'localhost',
Expand All @@ -141,7 +140,7 @@ export function buildTediousOptions(
},
options: {
port: config.port ?? DEFAULT_PORTS.mssql,
database: database ?? config.database,
database: config.database,
trustServerCertificate: !config.ssl,
encrypt: true,
serverName: resolveTlsServerName(config),
Expand All @@ -157,78 +156,33 @@ export function buildTediousOptions(
}

/**
* Instantiate a tedious Connection for the given noorm config.
* Instantiate a tedious Connection that records every error the server sends
* while logging in. tedious keeps only the last one, which for a missing
* database or a withheld reason is the generic 18456 "Login failed".
*/
function buildTediousConfig(
function buildTediousConnection(
Tedious: typeof import('tedious'),
config: ConnectionConfig,
database?: string,
loginErrors: MssqlServerError[],
) {

return new Tedious.Connection(buildTediousOptions(config, database));

}

/**
* Verify the target database exists by querying sys.databases on master.
*
* Connects to 'master' first and checks sys.databases. Throws a clear
* error if the database is missing, instead of letting tedious hang
* with a cryptic ECONNRESET.
*/
async function verifyDatabaseExists(
Tedious: typeof import('tedious'),
Tarn: typeof import('tarn'),
config: ConnectionConfig,
): Promise<void> {

const masterDb = new Kysely<unknown>({
dialect: new MssqlDialect({
tarn: {
...Tarn,
options: {
min: 0,
max: 1,
propagateCreateError: true,
},
},
tedious: {
...Tedious,
connectionFactory: () => buildTediousConfig(Tedious, config, 'master'),
},
}),
plugins: [new MssqlLimitPlugin()],
});

try {
const connection = new Tedious.Connection(buildTediousOptions(config));
const record = (token: MssqlServerError) => loginErrors.push({ number: token.number, message: token.message });

const { rows } = await sql<{ name: string }>`
SELECT name FROM sys.databases WHERE name = ${config.database}
`.execute(masterDb);
// Pooled connections live on, and their query errors are not login errors.
connection.on('errorMessage', record);
connection.once('connect', () => connection.removeListener('errorMessage', record));

if (rows.length === 0) {

throw new Error(
`Database '${config.database}' does not exist on ${config.host ?? 'localhost'}:${config.port ?? DEFAULT_PORTS.mssql}`,
);

}

}
finally {

await masterDb.destroy();

}
return connection;

}

/**
* Create a SQL Server connection.
*
* Verifies the target database exists via master before opening
* the connection pool. This avoids the tedious/tarn hang that
* occurs when MSSQL rejects login for a non-existent database.
* Runs a first query before returning, so a failed login is reported here with
* the error numbers the server sent. They name a missing database, a login
* without access to it, or the causes a plain 18456 can stand for.
*
* @example
* ```typescript
Expand All @@ -251,9 +205,7 @@ export async function createMssqlConnection(config: ConnectionConfig): Promise<C
const TarnImport = await import('tarn');
const Tedious = TediousImport.default ?? TediousImport;
const Tarn = TarnImport.default ?? TarnImport;

// Preflight: verify database exists via master
await verifyDatabaseExists(Tedious, Tarn, config);
let loginErrors: MssqlServerError[] = [];

const db = new Kysely<unknown>({
dialect: new MssqlDialect({
Expand All @@ -267,12 +219,28 @@ export async function createMssqlConnection(config: ConnectionConfig): Promise<C
},
tedious: {
...Tedious,
connectionFactory: () => buildTediousConfig(Tedious, config),
connectionFactory: () => {

loginErrors = [];

return buildTediousConnection(Tedious, config, loginErrors);

},
},
}),
plugins: [new MssqlLimitPlugin()],
});

const [, connectErr] = await attempt(() => sql`SELECT 1`.execute(db));

if (connectErr) {

await db.destroy();

throw explainMssqlLoginFailure(loginErrors, connectErr, config);

}

return {
db,
dialect: 'mssql',
Expand Down
Loading
Loading