From b745a6850d6e7f779e1869552ffb4b5a0599e9e7 Mon Sep 17 00:00:00 2001 From: "a.khanteev" Date: Mon, 24 Aug 2026 19:24:09 +0400 Subject: [PATCH 1/2] feat: add read-only postgres agent tool --- .changeset/add-postgres-cli.md | 5 + README.md | 10 +- .../add-postgres-agent-tool/.openspec.yaml | 2 + .../changes/add-postgres-agent-tool/design.md | 79 ++++++ .../add-postgres-agent-tool/proposal.md | 38 +++ .../specs/postgres/data-comparison/spec.md | 51 ++++ .../specs/postgres/read-only-queries/spec.md | 61 +++++ .../specs/postgres/schema-exploration/spec.md | 104 ++++++++ .../specs/postgres/sessions/spec.md | 51 ++++ .../changes/add-postgres-agent-tool/tasks.md | 42 ++++ openspec/config.yaml | 20 ++ packages/postgres-cli/LICENSE | 21 ++ packages/postgres-cli/README.md | 95 +++++++ packages/postgres-cli/bin/pgc.js | 4 + packages/postgres-cli/package.json | 51 ++++ packages/postgres-cli/src/cli.js | 171 +++++++++++++ packages/postgres-cli/src/lib/compare.js | 169 +++++++++++++ packages/postgres-cli/src/lib/config.js | 198 +++++++++++++++ packages/postgres-cli/src/lib/errors.js | 14 ++ packages/postgres-cli/src/lib/output.js | 89 +++++++ packages/postgres-cli/src/lib/postgres.js | 98 ++++++++ packages/postgres-cli/src/lib/query.js | 217 ++++++++++++++++ packages/postgres-cli/src/lib/schema.js | 231 ++++++++++++++++++ packages/postgres-cli/test/cli.test.js | 29 +++ packages/postgres-cli/test/compare.test.js | 83 +++++++ packages/postgres-cli/test/config.test.js | 32 +++ packages/postgres-cli/test/postgres.test.js | 57 +++++ packages/postgres-cli/test/query.test.js | 32 +++ packages/postgres-cli/test/schema.test.js | 136 +++++++++++ packages/postgres-cli/test/support.js | 52 ++++ pnpm-lock.yaml | 117 +++++++++ scripts/dev-install.mjs | 4 + skills/pgc/SKILL.md | 76 ++++++ 33 files changed, 2435 insertions(+), 4 deletions(-) create mode 100644 .changeset/add-postgres-cli.md create mode 100644 openspec/changes/add-postgres-agent-tool/.openspec.yaml create mode 100644 openspec/changes/add-postgres-agent-tool/design.md create mode 100644 openspec/changes/add-postgres-agent-tool/proposal.md create mode 100644 openspec/changes/add-postgres-agent-tool/specs/postgres/data-comparison/spec.md create mode 100644 openspec/changes/add-postgres-agent-tool/specs/postgres/read-only-queries/spec.md create mode 100644 openspec/changes/add-postgres-agent-tool/specs/postgres/schema-exploration/spec.md create mode 100644 openspec/changes/add-postgres-agent-tool/specs/postgres/sessions/spec.md create mode 100644 openspec/changes/add-postgres-agent-tool/tasks.md create mode 100644 openspec/config.yaml create mode 100644 packages/postgres-cli/LICENSE create mode 100644 packages/postgres-cli/README.md create mode 100644 packages/postgres-cli/bin/pgc.js create mode 100644 packages/postgres-cli/package.json create mode 100644 packages/postgres-cli/src/cli.js create mode 100644 packages/postgres-cli/src/lib/compare.js create mode 100644 packages/postgres-cli/src/lib/config.js create mode 100644 packages/postgres-cli/src/lib/errors.js create mode 100644 packages/postgres-cli/src/lib/output.js create mode 100644 packages/postgres-cli/src/lib/postgres.js create mode 100644 packages/postgres-cli/src/lib/query.js create mode 100644 packages/postgres-cli/src/lib/schema.js create mode 100644 packages/postgres-cli/test/cli.test.js create mode 100644 packages/postgres-cli/test/compare.test.js create mode 100644 packages/postgres-cli/test/config.test.js create mode 100644 packages/postgres-cli/test/postgres.test.js create mode 100644 packages/postgres-cli/test/query.test.js create mode 100644 packages/postgres-cli/test/schema.test.js create mode 100644 packages/postgres-cli/test/support.js create mode 100644 skills/pgc/SKILL.md diff --git a/.changeset/add-postgres-cli.md b/.changeset/add-postgres-cli.md new file mode 100644 index 0000000..9137bc5 --- /dev/null +++ b/.changeset/add-postgres-cli.md @@ -0,0 +1,5 @@ +--- +"@khaale/postgres-cli": minor +--- + +Add the read-only `pgc` PostgreSQL explorer for named sessions, progressive schema discovery (including comment-aware search, explicit continuation, and metadata availability), bounded queries, and cross-environment comparison. diff --git a/README.md b/README.md index 63d3b89..a927294 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The following packages are available on npm under the `@khaale` scope: |:---|:---|:---| | `glc` | [`@khaale/gitlab-cli`](https://www.npmjs.com/package/@khaale/gitlab-cli) | GitLab exploration and merge-request review workflows | | `ktc` | [`@khaale/kaiten-cli`](https://www.npmjs.com/package/@khaale/kaiten-cli) | Kaiten task exploration workflows | +| `pgc` | [`@khaale/postgres-cli`](https://www.npmjs.com/package/@khaale/postgres-cli) | Read-only PostgreSQL exploration and cross-environment comparison | ## Installation @@ -17,10 +18,10 @@ You can install these tools globally using your preferred package manager: ```bash # Using npm -npm install -g @khaale/gitlab-cli @khaale/kaiten-cli +npm install -g @khaale/gitlab-cli @khaale/kaiten-cli @khaale/postgres-cli # Using pnpm -pnpm add -g @khaale/gitlab-cli @khaale/kaiten-cli +pnpm add -g @khaale/gitlab-cli @khaale/kaiten-cli @khaale/postgres-cli ``` Alternatively, you can run them directly without installation using `npx`: @@ -28,6 +29,7 @@ Alternatively, you can run them directly without installation using `npx`: ```bash npx @khaale/gitlab-cli --help npx @khaale/kaiten-cli --help +npx @khaale/postgres-cli --help ``` ## Repository Layout @@ -57,11 +59,11 @@ Install dependencies from the repository root to configure the pre-commit hook. pnpm install ``` -To create development commands for `glc` and `ktc` under `~/.local/bin/`: +To create development commands for `glc`, `ktc`, and `pgc` under `~/.local/bin/`: ```bash pnpm dev:install export PATH="$HOME/.local/bin:$PATH" ``` -That gives you direct `glc` and `ktc` commands without typing `node ...`. +That gives you direct `glc`, `ktc`, and `pgc` commands without typing `node ...`. diff --git a/openspec/changes/add-postgres-agent-tool/.openspec.yaml b/openspec/changes/add-postgres-agent-tool/.openspec.yaml new file mode 100644 index 0000000..4102db8 --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-24 diff --git a/openspec/changes/add-postgres-agent-tool/design.md b/openspec/changes/add-postgres-agent-tool/design.md new file mode 100644 index 0000000..072e46c --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/design.md @@ -0,0 +1,79 @@ +## Context + +The repository contains two read-oriented agent CLIs with shared configuration, output, and diagnostic conventions. The new tool must work with PostgreSQL servers rather than an HTTP API, must support multiple environments, and must treat credentials and potentially very large result sets as sensitive. See `proposal.md` and the four capability specs for the motivation and observable contract. + +## Goals / Non-Goals + +**Goals:** + +- Add a standalone workspace package for a PostgreSQL agent CLI, using the repository's existing command, configuration, JSON output, testing, and packaging conventions. +- Make named sessions the only agent-facing connection handle; credentials are resolved inside the process and never accepted as query arguments or emitted in output. +- Establish defense-in-depth read-only behavior for every agent operation. +- Make schema exploration and data comparison bounded, deterministic, and explicit about incomplete results. + +**Non-Goals:** + +- Supporting migrations, backups, restores, replication administration, or arbitrary database administration. +- Providing a general-purpose SQL console or a write-enabled escape hatch in the first version. +- Synchronizing or modifying data between environments. +- Creating database roles, changing grants, or requiring schema changes in connected databases. + +## Decisions + +### Use a dedicated `postgres-cli` package with a `pgc` executable + +The tool will be a new package at `packages/postgres-cli`, distributed under the short executable name `pgc`. It will follow the existing CLI command conventions and reuse `createCliArgParser` plus other utilities from `@khaale/cli-core` for platform-agnostic configuration paths, common errors, field projection, and output conventions. A dedicated package keeps PostgreSQL dependencies and security-sensitive connection code out of the existing GitLab/Kaiten tools. + +An MCP server or database-specific shell was considered, but the repository currently distributes self-contained CLIs and the agent can invoke stable JSON commands directly. An MCP adapter can be added later without changing the capability contracts. + +### Store named sessions in a restrictive local configuration + +The configuration will contain named session definitions, including host, port, database, user, and a password or secret reference, plus safe defaults such as statement timeout and result limits. The configuration path will use the shared platform-agnostic resolver, be created with restrictive permissions where supported, and never be rendered in full. + +The runtime will resolve a session name to an internal connection object. Commands will accept only the session name, not a password or raw credential-bearing connection string. Configuration inspection and errors will return redacted metadata. Environment overrides may change non-secret settings and secret references, but raw secrets will not be included in diagnostic or query output. + +### Enforce read-only at both the command and database transaction layers + +The query execution path will accept only one bounded statement and will reject known mutating, session-changing, transaction-changing, and multi-statement inputs before opening the query. The actual operation will run inside a PostgreSQL read-only transaction with a local statement timeout and will always roll back/close the transaction after the result is collected. + +The statement guard is an early, understandable failure mode; PostgreSQL's transaction-level read-only setting is the final database-side enforcement. This is preferred over relying only on the configured role's grants, because the tool must retain its safety behavior even when a session has write privileges. The tool will not expose a write command or a flag that disables these protections. + +### Query catalogs in small, navigable slices + +Schema commands will query PostgreSQL catalog views for one level at a time. The default response will be an overview with counts and continuation/narrowing information. Catalog list queries will fetch one sentinel row beyond the requested limit so truncation is explicit. Follow-up requests will select a fully qualified schema, table, view, or column and a bounded detail level. Results will use stable ordering and explicit limits; no command will dump the complete catalog into one response by default. + +Schema exploration will also provide a catalog search operation that matches object names, including table, view, routine, and column names, with optional schema/object-type filters. Search results will return compact fully qualified references that can be passed to a detail request, rather than expanding every match inline. + +Table detail will include PostgreSQL comments for the selected table and each returned column. Catalog search will match both object names and available comments and will return the comment as a compact description, so business terminology documented in the database can be used to discover technical objects without expanding the full schema. + +Table detail will include an availability status of `available`, `inaccessible`, or `not_found`. This prevents an empty catalog slice caused by a missing or unauthorized table from being mistaken for a valid table with no columns or relationships. + +Table detail will expose foreign-key relationships in two directions: `outgoing` relationships from the selected table to referenced tables, and `incoming` relationships from tables that reference it. Each relationship will include its constraint name, fully qualified endpoints, and ordered source/target column pairs so composite keys remain unambiguous. + +Schema metadata will not be cached as a correctness requirement in the first version. A future cache can be added only with a clear invalidation policy, because QA/UAT schemas can diverge and catalog permissions can change. + +### Compare bounded results of two queries in memory using explicit keys + +The first comparison mode will run independently supplied `leftQuery` and `rightQuery` against two distinct named sessions. The caller supplies one or more key/primary-key columns; those columns must be present under the same names in both result sets. Non-key columns are matched by name as well, so the caller can use SQL aliases to align different source schemas. Each side is bounded by the same row, byte, and timeout limits; the tool validates compatible result shapes before building indexes and reports left-only, right-only, equal, and changed rows. + +Values will be normalized into a stable JSON comparison representation while preserving type information where PostgreSQL values cannot be represented safely as plain JSON. Comparison output will include both non-secret source session names and per-source completeness status. If either side is truncated, timed out, unavailable, or incompatible, the result will be marked incomplete rather than reported as equal. + +### Use one stable machine-readable output envelope + +Successful commands will emit JSON by default and support compact JSON/field projection consistent with the existing tools. Errors will use the repository's `ok: false` envelope with a stable code and sanitized message/details. Human-readable output, if added, will be a rendering of the same bounded result and will not create a separate behavioral contract. + +### Ship a companion agent skill with the CLI + +The package will include `skills/pgc/SKILL.md` following the existing `glc`/`ktc` companion-skill pattern. It will instruct an agent to run `pgc --json doctor` first, select a named session instead of handling credentials, start schema exploration with an overview or name search, expand only required objects and relationships, keep queries read-only and bounded, and compare two independently supplied query results using same-named key columns. The skill will document JSON as the canonical format and mention Markdown/CSV only as explicit renderings where supported. + +## Risks / Trade-offs + +- **[Risk] A configured password remains sensitive at rest in a local file.** → Use restrictive file permissions, avoid command-line arguments and logs, support secret references, redact configuration/error output, and document that local filesystem access remains authoritative. +- **[Risk] SQL functions or unusual PostgreSQL statements can have side effects that a textual guard cannot classify perfectly.** → Reject multi-statement/session-control forms, run every operation in a read-only transaction, and treat the database transaction setting as the final safety boundary. +- **[Risk] Large tables make comparison expensive or misleading.** → Enforce shared row/byte/time limits, require explicit keys or a deterministic identity, include truncation/incompleteness markers, and never claim equality from a partial result. +- **[Risk] Catalog visibility differs between QA and UAT roles.** → Return per-object availability and structured permission errors instead of treating inaccessible metadata as an empty schema. +- **[Risk] A PostgreSQL driver increases package size and packaging complexity.** → Keep the dependency isolated to `postgres-cli`, exercise the self-contained pack check in CI, and avoid adding a shared abstraction until another package needs it. + +## Migration Plan + +No database migration is required. The rollout adds a new package and a local configuration file; existing GitLab/Kaiten packages and connected databases remain unchanged. Rollback is removal of the new package/configuration or reverting the release; because all first-version operations are read-only, rollback does not require data repair. diff --git a/openspec/changes/add-postgres-agent-tool/proposal.md b/openspec/changes/add-postgres-agent-tool/proposal.md new file mode 100644 index 0000000..8b0d753 --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/proposal.md @@ -0,0 +1,38 @@ +## Why + +Агенту нужна безопасная и предсказуемая работа с PostgreSQL в окружениях QA/UAT, но большая схема базы быстро переполняет контекст, а передача connection string или пароля агенту создаёт лишний риск. Сейчас в репозитории нет инструмента, который скрывает секреты, ограничивает операции, раскрывает схему по запросу и помогает сопоставлять данные между двумя базами. + +## What Changes + +- Добавить отдельный PostgreSQL-инструмент `pgc` для агентской работы с именованными сессиями (`qa`, `uat` и т.п.). +- Хранить параметры подключений в конфигурации инструмента и использовать их внутри процесса, не возвращая пароли, connection strings и другие секреты в вывод агенту. +- Ввести read-only режим по умолчанию с защитой на уровне инструмента и PostgreSQL-сессии; операции, меняющие данные или схему, должны отклоняться. +- Предоставить progressive disclosure для схемы: сначала компактный обзор, затем выборочные схемы, таблицы, колонок, индексов и ограничений. +- Добавить bounded-поиск по названиям таблиц, views, колонок и других доступных объектов схемы. +- Показывать входящие и исходящие связи таблиц по внешним ключам, включая соответствия колонок и составные ключи. +- Позволить выполнять параметризованные read-only SQL-запросы с ограничениями на объём результата и стабильным JSON-выводом. +- Добавить сравнение выбранных данных из двух именованных PostgreSQL-сессий с явным указанием источников и пригодным для агента результатом. +- Добавить диагностику соединения, режима безопасности и доступности объектов без раскрытия секретов. +- Добавить companion skill `pgc`, описывающий безопасный workflow агента: preflight, выбор сессии, progressive schema exploration, read-only queries и сравнение результатов. + +## Capabilities + +### New Capabilities + +- `postgres/sessions`: именованные подключения к PostgreSQL, безопасное разрешение конфигурации, выбор сессии и диагностика без раскрытия секретов. +- `postgres/schema-exploration`: progressive disclosure метаданных PostgreSQL с навигацией от обзора к выбранным объектам. +- `postgres/read-only-queries`: выполнение read-only запросов с защитой от мутаций, ограничением результата и машиночитаемым выводом. +- `postgres/data-comparison`: сравнение результатов или выбранных строк между двумя именованными сессиями. + +### Modified Capabilities + +Изменений требований существующих capability не планируется. + +## Impact + +- Новый пакет и CLI-инструмент в монорепозитории, по структуре аналогичный `gitlab-cli` и `kaiten-cli`. +- Новая конфигурационная схема для именованных PostgreSQL-сессий и интеграция с общими правилами разрешения конфигурации и редактирования секретов. +- Новая PostgreSQL-клиентская зависимость, пул/управление соединениями, нормализация типов и безопасное форматирование результатов. +- Новые команды или tool-интерфейс для обзора схемы, запросов, сравнения данных и диагностики. +- Новый агентский skill `skills/pgc/SKILL.md`, синхронизированный с CLI-командами и ограничениями безопасности. +- Изменения не должны требовать миграций в подключаемых базах данных и не должны изменять данные в read-only режиме. diff --git a/openspec/changes/add-postgres-agent-tool/specs/postgres/data-comparison/spec.md b/openspec/changes/add-postgres-agent-tool/specs/postgres/data-comparison/spec.md new file mode 100644 index 0000000..39bd92c --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/specs/postgres/data-comparison/spec.md @@ -0,0 +1,51 @@ +## Purpose + +Compare read-only PostgreSQL results from two named sessions so agents can investigate differences between environments such as QA and UAT. + +## ADDED Requirements + +### Requirement: Compare results of two read-only queries + +The tool SHALL accept two distinct named sessions, an independently supplied read-only query for each side, and one or more key/primary-key columns used to align rows. + +#### Scenario: Compare results of different queries across environments + +- **WHEN** the caller provides left and right session names, a read-only query for each side, and key columns present under the same names in both results +- **THEN** the tool executes both queries independently, returns both source identities and a comparison result without exposing either session's credentials + +#### Scenario: Compare the same session with itself + +- **WHEN** both comparison sides resolve to the same session +- **THEN** the tool rejects the request unless an explicit diagnostic mode allows it, and explains that two distinct sources are required + +### Requirement: Report row-level differences deterministically + +The tool SHALL use caller-provided key/primary-key columns to align rows by the same-named values and SHALL distinguish matching rows, rows present only on the left, rows present only on the right, and rows whose same-named non-key values differ. + +#### Scenario: Rows differ between sessions + +- **WHEN** a key identifies rows in both results and one or more non-key values differ +- **THEN** the result identifies the key, changed fields, and left/right values in stable order + +#### Scenario: A row exists on only one side + +- **WHEN** a keyed row appears in only one result +- **THEN** the result classifies it as left-only or right-only and includes the bounded row representation for that side + +### Requirement: Make comparison limits and incompleteness explicit + +The tool SHALL apply the same safety limits as read-only queries and SHALL identify when either source was truncated, timed out, unavailable, or otherwise unsuitable for a complete comparison. + +#### Scenario: One source is incomplete + +- **WHEN** one query is limited, fails, or returns incompatible columns +- **THEN** the tool reports an incomplete comparison with per-source status and does not present the result as a complete equality assertion + +### Requirement: Support compatible query shapes + +The tool SHALL validate that both query results contain the requested key columns and compatible same-named non-key columns before calculating row differences, and SHALL report incompatible shapes as a structured error. + +#### Scenario: Key or column shapes are incompatible + +- **WHEN** a requested key is missing on one side or same-named comparison columns cannot be aligned +- **THEN** the tool returns a structured compatibility error describing the mismatch without returning credentials diff --git a/openspec/changes/add-postgres-agent-tool/specs/postgres/read-only-queries/spec.md b/openspec/changes/add-postgres-agent-tool/specs/postgres/read-only-queries/spec.md new file mode 100644 index 0000000..97fd0b8 --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/specs/postgres/read-only-queries/spec.md @@ -0,0 +1,61 @@ +## Purpose + +Allow agents to inspect PostgreSQL data through bounded, machine-readable queries while making mutating operations unavailable through the tool by default. + +## ADDED Requirements + +### Requirement: Enforce read-only query execution + +The tool SHALL execute every agent query in a read-only PostgreSQL transaction and SHALL reject statements that can mutate data, schema, session security, or transaction safety before execution. + +#### Scenario: Execute a read query + +- **WHEN** the caller submits an allowed read-only query to a selected session +- **THEN** the tool executes it in a read-only transaction and returns the result without changing database state + +#### Scenario: Submit a mutating query + +- **WHEN** the caller submits an INSERT, UPDATE, DELETE, MERGE, DDL, transaction-setting, or equivalent mutating statement +- **THEN** the tool rejects it before execution with a stable read-only error + +#### Scenario: Database role has write privileges + +- **WHEN** the selected database role has write privileges but the tool session is read-only +- **THEN** the tool still rejects mutating statements and the database transaction remains read-only + +### Requirement: Bound query resource usage and result size + +The tool SHALL apply a statement timeout, result row limit, and result byte limit to agent queries, and SHALL report when a result is limited or cancelled. + +#### Scenario: Query returns more rows than allowed + +- **WHEN** an allowed query produces more rows than the configured limit +- **THEN** the tool returns the bounded result with an explicit truncation indicator and safe continuation guidance + +#### Scenario: Query exceeds the execution timeout + +- **WHEN** an allowed query exceeds the session's statement timeout +- **THEN** the tool cancels or terminates the operation and returns a timeout error without leaking raw connection details + +### Requirement: Support parameterized queries and stable output + +The tool SHALL accept query parameters separately from SQL text, return column metadata and rows in stable JSON form, and support a compact output mode. + +#### Scenario: Query with parameters + +- **WHEN** the caller supplies SQL text and a matching parameter list +- **THEN** the tool binds the parameters without string interpolation and returns the selected columns and rows in the requested output format + +#### Scenario: Invalid query or parameter shape + +- **WHEN** SQL cannot be parsed or the supplied parameters do not match the query +- **THEN** the tool returns a machine-readable query error with safe diagnostics and does not execute a partial operation + +### Requirement: Protect sensitive result data by default + +The tool SHALL support explicit field selection and bounded output, and SHALL avoid including connection credentials in query diagnostics or metadata. + +#### Scenario: Query error contains a sensitive target + +- **WHEN** PostgreSQL returns an error containing a credential-bearing connection target +- **THEN** the tool sanitizes the error before returning it to the agent diff --git a/openspec/changes/add-postgres-agent-tool/specs/postgres/schema-exploration/spec.md b/openspec/changes/add-postgres-agent-tool/specs/postgres/schema-exploration/spec.md new file mode 100644 index 0000000..5bc1254 --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/specs/postgres/schema-exploration/spec.md @@ -0,0 +1,104 @@ +## Purpose + +Expose large PostgreSQL schemas through bounded, navigable metadata views so an agent can progressively request only the detail needed for a task. + +## ADDED Requirements + +### Requirement: Return a bounded schema overview + +The tool SHALL provide a compact overview of a selected session's databases, schemas, and object counts without returning every column or object definition by default. + +#### Scenario: Request the initial schema view + +- **WHEN** the caller requests a schema overview for a session +- **THEN** the result contains stable names, object counts, and navigation references for the available schemas and object types, subject to a documented result limit + +#### Scenario: Overview exceeds the result limit + +- **WHEN** the number of schemas or objects exceeds the configured limit +- **THEN** the result returns an explicit continuation marker or narrowing requirement and does not silently truncate the list + +### Requirement: Disclose selected schema metadata on demand + +The tool SHALL allow the caller to expand a selected schema, table, view, or routine and SHALL return only the requested metadata level. + +#### Scenario: Expand a table + +- **WHEN** the caller requests metadata for a specific table +- **THEN** the result includes its table comment, columns, column comments, types, nullability, keys, indexes, and relevant constraints within the requested limit + +#### Scenario: Expand a selected column + +- **WHEN** the caller requests details for a specific column +- **THEN** the result contains that column's type, nullability, default information, and relation context without returning unrelated table metadata + +### Requirement: Keep schema navigation deterministic + +The tool SHALL use stable ordering, fully qualified object identifiers, and explicit pagination or narrowing parameters for schema exploration. + +#### Scenario: Repeat the same schema request + +- **WHEN** the caller repeats an equivalent request against an unchanged session +- **THEN** objects and fields appear in the same order and use the same identifiers + +### Requirement: Handle inaccessible or unsupported metadata safely + +The tool SHALL distinguish unavailable metadata from an empty result and SHALL not expose credentials or unnecessary server error details when catalog access is restricted. + +#### Scenario: Catalog access is restricted + +- **WHEN** the connected role cannot inspect a requested catalog object +- **THEN** the result identifies the metadata as unavailable with a safe availability status/reason and preserves the rest of the navigable schema response + +#### Scenario: Requested table does not exist + +- **WHEN** the caller requests metadata for a table that is not present in the selected schema +- **THEN** the result identifies the table as `not_found` instead of presenting empty metadata as a successful table description + +### Requirement: Search schema objects by name + +The tool SHALL support bounded, case-aware or case-insensitive search by object name or PostgreSQL comment across supported objects, including tables, views, routines, and columns, with optional schema and object-type filters. Search results SHALL include the matching object's comment when available. + +#### Scenario: Search for a table or column by name + +- **WHEN** the caller supplies a name pattern and an optional object type +- **THEN** the tool returns matching fully qualified object references with object type and parent context, ordered deterministically and limited by the request + +#### Scenario: Search for an object by comment + +- **WHEN** the caller supplies a pattern that matches a table or column comment +- **THEN** the tool returns the fully qualified object reference, its object type, parent context where applicable, and the stored comment + +#### Scenario: Search returns more matches than allowed + +- **WHEN** the pattern matches more objects than the configured result limit +- **THEN** the result includes an explicit continuation marker or narrowing guidance and does not silently discard matches + +#### Scenario: Search with no matches + +- **WHEN** the pattern matches no accessible schema object +- **THEN** the tool returns an explicit empty result that is distinguishable from a catalog access failure + +### Requirement: Expose directional foreign-key relationships + +The tool SHALL expose incoming and outgoing foreign-key relationships for a selected table, including fully qualified endpoints, constraint identity, and ordered source/target column pairs. + +#### Scenario: Inspect outgoing relationships + +- **WHEN** the caller requests outgoing relationships for a table +- **THEN** the result lists foreign keys owned by that table and the referenced table/columns they target + +#### Scenario: Inspect incoming relationships + +- **WHEN** the caller requests incoming relationships for a table +- **THEN** the result lists foreign keys from other tables that reference the selected table and identifies their source columns + +#### Scenario: Inspect a composite foreign key + +- **WHEN** a relationship contains multiple key columns +- **THEN** the result preserves the constraint's column order and returns each source column paired with its corresponding target column + +#### Scenario: Table has no relationships + +- **WHEN** the selected table has no incoming or outgoing foreign keys +- **THEN** the tool returns an explicit empty relationship list for the requested direction diff --git a/openspec/changes/add-postgres-agent-tool/specs/postgres/sessions/spec.md b/openspec/changes/add-postgres-agent-tool/specs/postgres/sessions/spec.md new file mode 100644 index 0000000..4baddca --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/specs/postgres/sessions/spec.md @@ -0,0 +1,51 @@ +## Purpose + +Provide safe, named PostgreSQL connection sessions that agents can select without receiving passwords or other connection secrets. + +## ADDED Requirements + +### Requirement: Resolve a PostgreSQL session by name + +The tool SHALL allow a caller to select a configured PostgreSQL connection using a non-secret session name such as `qa` or `uat`. + +#### Scenario: Select a configured session + +- **WHEN** the caller requests a known session name +- **THEN** the tool uses that session's effective connection configuration and returns the session name and non-secret connection metadata + +#### Scenario: Unknown session name + +- **WHEN** the caller requests a session name that is not configured +- **THEN** the tool returns a stable configuration error identifying the missing session name without exposing any configured secrets + +### Requirement: Keep connection secrets out of agent-visible output + +The tool SHALL resolve passwords, secret references, and credential-bearing connection values only for establishing the connection, and SHALL redact them from results, diagnostics, errors, logs, and configuration views. + +#### Scenario: Inspect configured sessions + +- **WHEN** the caller lists or inspects configured sessions +- **THEN** the output contains session names and safe metadata such as host, port, database, and read-only policy, but no password or credential-bearing connection string + +#### Scenario: Connection failure with credentials configured + +- **WHEN** a connection attempt fails for a session that uses a password +- **THEN** the error identifies the session and sanitized connection target without including the password or raw driver error text that contains it + +### Requirement: Validate session configuration before connecting + +The tool SHALL validate session names, required connection fields, secret references, and read-only policy values before opening a connection, and SHALL report invalid configuration as a machine-readable error. + +#### Scenario: Invalid session definition + +- **WHEN** a configured session lacks a required connection field or references an unavailable secret +- **THEN** the tool rejects the session with an actionable validation error and does not attempt a connection + +### Requirement: Provide secret-safe connection diagnostics + +The tool SHALL report whether a named session is configured and reachable, including server/version checks and effective safety settings, without returning database credentials. + +#### Scenario: Successful session diagnosis + +- **WHEN** the caller diagnoses a configured session +- **THEN** the output reports connectivity, PostgreSQL identity/version metadata, and read-only enforcement status while keeping credentials redacted diff --git a/openspec/changes/add-postgres-agent-tool/tasks.md b/openspec/changes/add-postgres-agent-tool/tasks.md new file mode 100644 index 0000000..140365a --- /dev/null +++ b/openspec/changes/add-postgres-agent-tool/tasks.md @@ -0,0 +1,42 @@ +## 1. Package foundation + +- [x] 1.1 Scaffold `packages/postgres-cli` with the `pgc` binary, repository-standard lint/test/build/pack scripts, and workspace metadata; verify the package is discovered by `pnpm -r` and `node --check` passes. +- [x] 1.2 Add the PostgreSQL driver and required CLI/core workspace dependencies, update the lockfile, and verify `pnpm install --frozen-lockfile` plus the package pack check succeed. +- [x] 1.3 Establish the package's JSON success/error envelope and command dispatch for sessions, schema, query, compare, and doctor operations; verify unknown commands and validation failures return stable non-zero JSON errors. +- [x] 1.4 Add `pgc` to the existing `dev-install` local command wrappers; verify `pnpm dev:install`, `command -v pgc`, and `pgc --json doctor` succeed. + +## 2. Named sessions and secret handling + +- [x] 2.1 Implement platform-agnostic PostgreSQL configuration resolution and validation for named sessions, defaults, secret references, and bounded execution settings; verify precedence, invalid configuration, and missing-session tests. +- [x] 2.2 Implement secret-safe session listing, configuration inspection, diagnostics, and error sanitization; verify passwords, credential-bearing URLs, and raw driver errors never appear in captured stdout/stderr or JSON payloads. +- [x] 2.3 Implement connection creation and cleanup for a selected named session, including safe metadata and server/version diagnosis; verify mocked connection success/failure and cleanup behavior without requiring a live database. +- [x] 2.4 Centralize database error sanitization so configured secrets, credential-bearing URLs, and password assignments are consistently redacted; verify the active PostgreSQL execution path uses the shared sanitizer. + +## 3. Read-only query execution + +- [x] 3.1 Implement the single-statement/read-only query guard for mutating SQL, session/transaction control, and multi-statement input; verify representative DML, DDL, function/control, and allowed read-query cases are classified correctly. +- [x] 3.2 Implement bounded PostgreSQL transaction execution with read-only mode, local statement timeout, row/byte limits, rollback, and connection cleanup; verify a write-capable role still cannot mutate through the tool and limit/timeout states are explicit. +- [x] 3.3 Implement parameter binding, PostgreSQL value normalization, field projection, compact JSON, and sanitized query errors; verify parameter-shape failures do not execute and output remains stable for supported scalar and typed values. + +## 4. Progressive schema exploration + +- [x] 4.1 Implement bounded catalog queries for overview, schema/object expansion, and selected column/table metadata with fully qualified identifiers and deterministic ordering; verify catalog fixtures cover tables, views, routines, keys, indexes, and constraints. +- [x] 4.2 Add continuation, narrowing, and inaccessible-metadata handling without silently truncating results; verify oversized fixtures return explicit continuation markers and permission failures remain distinguishable from empty results. +- [x] 4.3 Add bounded catalog search by object name with schema/type filters and detail-navigation references; verify table, view, routine, and column matches, empty results, pagination, and inaccessible catalog cases. +- [x] 4.4 Add incoming/outgoing foreign-key relationship discovery with ordered column pairs and composite-key support; verify both directions, no-relation results, and fully qualified endpoints. +- [x] 4.5 Include PostgreSQL comments for tables and columns in table detail, and match comments in bounded schema search; verify comments are returned when present and null/empty comments do not create false matches. +- [x] 4.6 Make schema overview/search truncation explicit with a sentinel row and continuation marker, and distinguish available, inaccessible, and not-found table metadata; verify low-limit and missing-table requests remain machine-readable. + +## 5. Cross-environment data comparison + +- [x] 5.1 Implement comparison input validation for two distinct named sessions, independent left/right read-only queries, caller-provided key/primary-key columns, and same-named result columns; verify same-session, missing-key, and incompatible-shape requests return structured errors. +- [x] 5.2 Implement bounded left/right result collection and deterministic diff classification for equal, changed, left-only, and right-only rows; verify changed fields, normalized values, stable ordering, and per-source completeness status. +- [x] 5.3 Ensure comparison never reports complete equality for truncated, timed-out, unavailable, or otherwise incomplete inputs; verify partial-result fixtures produce an incomplete result with per-source reasons. + +## 6. Agent-facing documentation and release readiness + +- [x] 6.1 Add `skills/pgc/SKILL.md` following the existing companion-skill format; document `pgc --json doctor`, named sessions, secret handling, schema overview/search/detail/relationships, bounded read-only queries, two-query comparison with key columns, and output formats; verify examples contain no real credentials and match the CLI help. +- [x] 6.2 Document named session configuration, secret handling, read-only guarantees, progressive schema workflow, query limits, and comparison examples in the package README; verify examples contain no real credentials and match the CLI help. +- [x] 6.3 Add a changeset describing the new public PostgreSQL CLI package and verify the release metadata includes the package without versioning private core packages. +- [x] 6.4 Add integration/packaging coverage for the new workspace package and run `pnpm check`; verify lint, all unit tests, and self-contained dry-run packaging pass across the monorepo. +- [x] 6.5 Document table/column comments and comment-aware schema search in the package README and companion skill; verify examples remain secret-free and match the CLI behavior. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/packages/postgres-cli/LICENSE b/packages/postgres-cli/LICENSE new file mode 100644 index 0000000..aea79dd --- /dev/null +++ b/packages/postgres-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Khaale + +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/postgres-cli/README.md b/packages/postgres-cli/README.md new file mode 100644 index 0000000..5458677 --- /dev/null +++ b/packages/postgres-cli/README.md @@ -0,0 +1,95 @@ +# `pgc` + +Read-only PostgreSQL explorer for agents. + +`pgc` works with named sessions such as `qa` and `uat`. The agent selects a session name; passwords are resolved inside the process and are never printed by the CLI. + +## Configuration + +The platform-specific config path is shown by: + +```bash +pgc --json config path +``` + +Initialize an empty config with: + +```bash +pgc --json config init +``` + +A config contains named sessions and bounded read defaults: + +```json +{ + "sessions": { + "qa": { + "host": "qa.example.internal", + "port": 5432, + "database": "app", + "user": "agent", + "passwordEnv": "PGC_QA_PASSWORD", + "ssl": true + }, + "uat": { + "host": "uat.example.internal", + "database": "app", + "user": "agent", + "passwordEnv": "PGC_UAT_PASSWORD" + } + }, + "defaults": { + "statementTimeoutMs": 30000, + "rowLimit": 1000, + "byteLimit": 1048576 + } +} +``` + +Use `password` instead of `passwordEnv` only when the local config policy permits storing a password. The config file is written with restrictive permissions where supported. `pgc` never returns either value in JSON, Markdown, CSV, errors, or diagnostics. + +## Agent workflow + +Start with the preflight check: + +```bash +pgc --json doctor +``` + +Explore a large schema progressively: + +```bash +pgc --json schema overview --session qa +pgc --json schema search --session qa --query user --type table +pgc --json schema table --session qa --schema public --table users +pgc --json schema relations --session qa --schema public --table users --direction both +``` + +`schema table` includes PostgreSQL comments for the table and its columns when they are defined. `schema search` matches both object names and comments, which makes documented business terms useful for finding tables and columns. + +Schema list responses include `continuation` when the requested limit is reached. Table details expose `table.availability` as `available`, `inaccessible`, or `not_found`. + +Run a bounded read-only query: + +```bash +pgc --json query --session qa --sql 'SELECT id, email FROM public.users WHERE id = $1' --params '[42]' +``` + +Compare two independently supplied queries by same-named key columns. Use SQL aliases when the source column names differ: + +```bash +pgc --json compare \ + --left-session qa \ + --right-session uat \ + --left-query 'SELECT id, status FROM public.users' \ + --right-query 'SELECT user_id AS id, status FROM public.accounts' \ + --key id +``` + +## Safety and output + +- Every query runs in a PostgreSQL read-only transaction. +- Mutating, session-control, transaction-control, and multi-statement SQL is rejected before execution. +- Queries are bounded by statement timeout, row limit, and result byte limit. +- JSON is the canonical agent format. `--md` renders a human-readable view; `--csv` is for tabular query results only. +- Truncated, timed-out, unavailable, or incompatible comparison inputs are marked incomplete and are never reported as complete equality. diff --git a/packages/postgres-cli/bin/pgc.js b/packages/postgres-cli/bin/pgc.js new file mode 100644 index 0000000..3981c57 --- /dev/null +++ b/packages/postgres-cli/bin/pgc.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { main } from "../src/cli.js"; + +main(process.argv.slice(2)); diff --git a/packages/postgres-cli/package.json b/packages/postgres-cli/package.json new file mode 100644 index 0000000..3fd4f3b --- /dev/null +++ b/packages/postgres-cli/package.json @@ -0,0 +1,51 @@ +{ + "name": "@khaale/postgres-cli", + "version": "0.1.0", + "description": "Read-only PostgreSQL explorer CLI for agents", + "type": "module", + "main": "./dist/cli.js", + "bin": { + "pgc": "./dist/bin/pgc.js" + }, + "files": [ + "dist", + "docs", + "README.md", + "LICENSE" + ], + "scripts": { + "start": "node ./bin/pgc.js", + "build:pack": "node ../../scripts/build-self-contained-cli.mjs packages/postgres-cli pgc", + "lint": "node --check ./bin/pgc.js && node --check ./src/cli.js", + "test": "node --test test/*.test.js", + "pack:check": "pnpm build:pack && npm pack --dry-run --cache ./.npm-cache", + "prepublishOnly": "pnpm lint && pnpm test && pnpm pack:check" + }, + "engines": { + "node": ">=22" + }, + "keywords": [ + "postgresql", + "postgres", + "cli", + "agent", + "read-only" + ], + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/", + "provenance": true + }, + "repository": { + "type": "git", + "url": "git+https://github.com/khaale/agentic-cli-tools.git", + "directory": "packages/postgres-cli" + }, + "dependencies": { + "pg": "^8.16.3" + }, + "devDependencies": { + "@khaale/cli-core": "workspace:*" + } +} diff --git a/packages/postgres-cli/src/cli.js b/packages/postgres-cli/src/cli.js new file mode 100644 index 0000000..c46fe64 --- /dev/null +++ b/packages/postgres-cli/src/cli.js @@ -0,0 +1,171 @@ +import { createCliArgParser } from "@khaale/cli-core"; +import { CliError } from "./lib/errors.js"; +import { loadConfig } from "./lib/config.js"; +import { resolveFormat, writeOutput } from "./lib/output.js"; +import { executeReadQuery, diagnoseSession } from "./lib/postgres.js"; +import { compareQueries } from "./lib/compare.js"; +import { relationships, schemaOverview, schemaSearch, tableDetail } from "./lib/schema.js"; + +const parseArgs = createCliArgParser({ + booleanFlags: ["json", "md", "csv", "compact", "force", "help"] +}); + +export async function main(argv, dependencies = {}) { + const result = await run(argv, dependencies); + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + } +} + +export async function run(argv, dependencies = {}) { + const stdout = dependencies.stdout || process.stdout; + const stderr = dependencies.stderr || process.stderr; + const wantsJson = !argv.includes("--md") && !argv.includes("--csv"); + + try { + const parsed = parseArgs(argv); + if (parsed.options.help || argv.length === 0) { + stdout.write(`${HELP_TEXT}\n`); + return { exitCode: 0 }; + } + const data = await dispatch(parsed, dependencies); + writeOutput(data, resolveFormat(parsed.options), { + compact: parsed.options.compact, + fields: parseCsv(parsed.options.fields), + stdout + }); + return { exitCode: 0, data }; + } catch (error) { + const normalized = normalizeError(error); + if (wantsJson) { + writeError(stdout, normalized); + } else { + stderr.write(`${normalized.message}\n`); + } + + return { exitCode: normalized.exitCode, error: normalized }; + } +} + +async function dispatch(parsed, dependencies) { + const { resource, verb, options } = parsed; + const config = await loadConfig(dependencies); + + if (resource === "doctor") { + const base = { ok: true, tool: "pgc", config: config.safeView() }; + if (!options.session) { + return base; + } + + const session = config.getSession(options.session); + return { ...base, session: session.name, diagnosis: await diagnoseSession(session, dependencies) }; + } + + if (resource === "sessions" && verb === "list") { + return { ok: true, sessions: config.listSessions() }; + } + + if (resource === "config" && verb === "path") { + return { ok: true, path: config.path }; + } + + if (resource === "config" && verb === "get") { + return { ok: true, ...config.safeView() }; + } + + if (resource === "config" && verb === "init") { + return config.init({ force: options.force }); + } + + if (resource === "query") { + const session = config.getSession(options.session); + const result = await executeReadQuery( + session, + options.sql, + parseJsonArray(options.params, "query parameters"), + { ...dependencies, ...options } + ); + return { ok: true, kind: "query", session: session.name, ...result }; + } + + if (resource === "schema") { + if (verb === "overview") { + return schemaOverview(config, { ...options, ...dependencies }); + } + + if (verb === "search") { + return schemaSearch(config, { ...options, ...dependencies }); + } + + if (verb === "table") { + return tableDetail(config, { ...options, ...dependencies }); + } + + if (verb === "relations") { + const result = await relationships(config, { ...options, ...dependencies }); + return { ok: true, kind: "schema-relations", session: options.session, table: { schema: options.schema, name: options.table }, ...result }; + } + } + + if (resource === "compare") { + return compareQueries(config, { ...options, ...dependencies }); + } + + throw new CliError(`unsupported command: ${[resource, verb].filter(Boolean).join(" ") || "(empty)"}`, 2); +} + +function parseCsv(value) { + if (!value) { + return null; + } + + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +function normalizeError(error) { + if (error instanceof CliError) { + return { message: error.message, exitCode: error.exitCode, code: error.code || "cli_error" }; + } + + return { message: error?.message || String(error), exitCode: 1, code: "internal_error" }; +} + +function parseJsonArray(value, label) { + if (value === undefined || value === null || value === "") { + return []; + } + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + throw new Error("expected an array"); + } + return parsed; + } catch { + throw new CliError(`${label} must be a JSON array`, 2); + } +} + +function writeError(stdout, error) { + stdout.write(`${JSON.stringify({ + ok: false, + error: { + code: error.code, + message: error.message + } + }, null, 2)}\n`); +} + +const HELP_TEXT = `pgc - read-only PostgreSQL explorer for agents + +Commands: + pgc --json doctor [--session NAME] + pgc --json sessions list + pgc --json schema overview --session NAME + pgc --json schema search --session NAME --query TEXT [--type TYPE] [--schema NAME] + pgc --json schema table --session NAME --schema NAME --table NAME + pgc --json schema relations --session NAME --schema NAME --table NAME [--direction incoming|outgoing|both] + pgc --json query --session NAME --sql SQL [--params JSON_ARRAY] + pgc --json compare --left-session NAME --right-session NAME --left-query SQL --right-query SQL --key COLUMN[,COLUMN] + +Output is JSON by default. Use --md for human-readable output or --csv for tabular query results.`; diff --git a/packages/postgres-cli/src/lib/compare.js b/packages/postgres-cli/src/lib/compare.js new file mode 100644 index 0000000..5ec4ab0 --- /dev/null +++ b/packages/postgres-cli/src/lib/compare.js @@ -0,0 +1,169 @@ +import { CliError } from "./errors.js"; +import { stableStringify } from "./query.js"; +import { executeReadQuery } from "./postgres.js"; + +export async function compareQueries(config, options = {}) { + const leftSession = config.getSession(options.leftSession); + const rightSession = config.getSession(options.rightSession); + if (leftSession.name === rightSession.name) { + throw new CliError("comparison requires two distinct PostgreSQL sessions", 2); + } + + const keyColumns = normalizeKeys(options.key); + if (keyColumns.length === 0) { + throw new CliError("comparison requires at least one key column", 2); + } + + const execute = options.execute || executeReadQuery; + const [left, right] = await Promise.all([ + runSide(execute, leftSession, options.leftQuery, options), + runSide(execute, rightSession, options.rightQuery, options) + ]); + + if (left.status !== "ok" || right.status !== "ok") { + return { + ok: true, + kind: "data-comparison", + complete: false, + equal: false, + left: sourceStatus(leftSession, left), + right: sourceStatus(rightSession, right), + differences: [] + }; + } + + const comparison = compareResults(left.result, right.result, keyColumns); + return { + ok: true, + kind: "data-comparison", + ...comparison, + left: sourceStatus(leftSession, left), + right: sourceStatus(rightSession, right) + }; +} + +export function compareResults(left, right, keyColumns) { + const leftColumns = columnNames(left); + const rightColumns = columnNames(right); + const missingKeys = keyColumns.filter((key) => !leftColumns.includes(key) || !rightColumns.includes(key)); + if (missingKeys.length > 0) { + throw new CliError(`comparison key columns are missing: ${missingKeys.join(", ")}`, 2); + } + + if (leftColumns.length !== rightColumns.length || [...leftColumns].sort().join("\u0000") !== [...rightColumns].sort().join("\u0000")) { + throw new CliError("comparison query results must expose the same column names", 2); + } + + const leftIndex = indexRows(left.rows || [], keyColumns, "left"); + const rightIndex = indexRows(right.rows || [], keyColumns, "right"); + const keys = [...new Set([...leftIndex.keys(), ...rightIndex.keys()])].sort(); + const differences = []; + let equalCount = 0; + + for (const key of keys) { + const leftRow = leftIndex.get(key); + const rightRow = rightIndex.get(key); + if (!leftRow) { + differences.push({ kind: "right-only", key: keyValue(rightRow, keyColumns), right: rightRow }); + continue; + } + + if (!rightRow) { + differences.push({ kind: "left-only", key: keyValue(leftRow, keyColumns), left: leftRow }); + continue; + } + + const changed = {}; + for (const column of leftColumns) { + if (keyColumns.includes(column)) { + continue; + } + + if (stableStringify(leftRow[column]) !== stableStringify(rightRow[column])) { + changed[column] = { left: leftRow[column], right: rightRow[column] }; + } + } + + if (Object.keys(changed).length === 0) { + equalCount += 1; + } else { + differences.push({ kind: "changed", key: keyValue(leftRow, keyColumns), changed }); + } + } + + const complete = !left.truncated && !right.truncated; + return { + complete, + equal: complete && differences.length === 0, + counts: { + equal: equalCount, + changed: differences.filter((item) => item.kind === "changed").length, + leftOnly: differences.filter((item) => item.kind === "left-only").length, + rightOnly: differences.filter((item) => item.kind === "right-only").length + }, + keyColumns, + columns: leftColumns, + differences + }; +} + +function normalizeKeys(keys) { + if (Array.isArray(keys)) { + return keys.map((key) => String(key).trim()).filter(Boolean); + } + + return String(keys || "").split(",").map((key) => key.trim()).filter(Boolean); +} + +async function runSide(execute, session, sql, options) { + try { + if (!sql) { + throw new CliError(`${session.name} comparison query is required`, 2); + } + return { status: "ok", result: await execute(session, sql, parseParameters(options, session), options) }; + } catch (error) { + return { status: "error", error: { code: error.code || "query_error", message: error.message } }; + } +} + +function parseParameters(options, session) { + const value = options[session.name === options.leftSession ? "leftParams" : "rightParams"]; + if (value === undefined || value === null || Array.isArray(value)) { + return value || []; + } + + try { + return JSON.parse(value); + } catch { + throw new CliError("query parameters must be a JSON array", 2); + } +} + +function sourceStatus(session, side) { + return { + session: session.name, + status: side.status, + ...(side.result ? { truncated: side.result.truncated, rowCount: side.result.rowCount } : {}), + ...(side.error ? { error: side.error } : {}) + }; +} + +function columnNames(result) { + return (result.columns || []).map((column) => typeof column === "string" ? column : column.name); +} + +function indexRows(rows, keyColumns, side) { + const index = new Map(); + for (const row of rows) { + const key = stableStringify(keyColumns.map((column) => row[column])); + if (index.has(key)) { + throw new CliError(`duplicate comparison key in ${side} query result`, 2); + } + index.set(key, row); + } + return index; +} + +function keyValue(row, keyColumns) { + return Object.fromEntries(keyColumns.map((column) => [column, row[column]])); +} diff --git a/packages/postgres-cli/src/lib/config.js b/packages/postgres-cli/src/lib/config.js new file mode 100644 index 0000000..e7ea6f0 --- /dev/null +++ b/packages/postgres-cli/src/lib/config.js @@ -0,0 +1,198 @@ +import { + configExists, + normalizeStringValue, + resolveRuntime, + writeStoredConfig +} from "@khaale/cli-core"; +import { fail } from "./errors.js"; + +export const TOOL_NAME = "pgc"; +export const DEFAULT_LIMITS = Object.freeze({ + statementTimeoutMs: 30_000, + rowLimit: 1_000, + byteLimit: 1_048_576 +}); + +export async function loadConfig(options = {}) { + const env = options.env || process.env; + const runtime = resolveRuntime(TOOL_NAME, { + ...options, + env, + configPath: options.configPath || env.PGC_CONFIG_PATH + }); + const raw = await readConfig(runtime); + const defaults = normalizeLimits(raw.defaults || {}, "defaults"); + const sessions = {}; + + for (const [name, value] of Object.entries(raw.sessions || {})) { + sessions[name] = normalizeSession(name, value, runtime.env, defaults); + } + + return { + path: runtime.configPath, + env: runtime.env, + fsImpl: runtime.fsImpl, + platform: runtime.platform, + defaults, + sessions, + listSessions() { + return Object.values(sessions).map(toSafeSession); + }, + safeView() { + return { + path: runtime.configPath, + sessions: Object.values(sessions).map(toSafeSession), + defaults + }; + }, + getSession(name) { + const session = sessions[name]; + if (!session) { + fail(`unknown PostgreSQL session: ${name}`, 3); + } + + return resolveSessionSecret(session, runtime.env); + }, + async init({ force = false } = {}) { + if (!force && await configExists(runtime.fsImpl, runtime.configPath)) { + fail(`config file already exists: ${runtime.configPath}`, 3); + } + + const values = { + sessions: {}, + defaults: DEFAULT_LIMITS + }; + await writeStoredConfig(values, runtime); + return { ok: true, path: runtime.configPath, configured: ["sessions", "defaults"] }; + } + }; +} + +async function readConfig(runtime) { + let text; + try { + text = await runtime.fsImpl.readFile(runtime.configPath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return { sessions: {}, defaults: {} }; + } + + throw error; + } + + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + fail(`invalid config JSON at ${runtime.configPath}: ${error.message}`, 3); + } + + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + fail(`invalid config JSON at ${runtime.configPath}: expected an object`, 3); + } + + if (parsed.sessions !== undefined && (!parsed.sessions || typeof parsed.sessions !== "object" || Array.isArray(parsed.sessions))) { + fail("invalid config value for sessions: expected an object", 3); + } + + return parsed; +} + +function normalizeSession(name, value, env, defaults) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`invalid configuration for session ${name}: expected an object`, 3); + } + + const host = requiredString(value.host, `${name}.host`); + const database = requiredString(value.database, `${name}.database`); + const user = requiredString(value.user, `${name}.user`); + const port = positiveInteger(value.port ?? 5432, `${name}.port`); + const password = value.password === undefined ? null : requiredString(value.password, `${name}.password`); + const passwordEnv = value.passwordEnv === undefined ? null : requiredString(value.passwordEnv, `${name}.passwordEnv`); + + if (password && passwordEnv) { + fail(`invalid configuration for session ${name}: set password or passwordEnv, not both`, 3); + } + + const limits = normalizeLimits(value, `${name}.limits`, defaults); + return { + name, + host, + port, + database, + user, + password, + passwordEnv, + ssl: normalizeSsl(value.ssl), + ...limits + }; +} + +function resolveSessionSecret(session, env) { + if (!session.passwordEnv) { + return { ...session }; + } + + const password = env[session.passwordEnv]; + if (!password) { + fail(`missing secret for PostgreSQL session ${session.name}: ${session.passwordEnv}`, 3); + } + + return { ...session, password }; +} + +function toSafeSession(session) { + return { + name: session.name, + host: session.host, + port: session.port, + database: session.database, + user: session.user, + ssl: Boolean(session.ssl), + readOnly: true, + limits: { + statementTimeoutMs: session.statementTimeoutMs, + rowLimit: session.rowLimit, + byteLimit: session.byteLimit + }, + secret: session.password || session.passwordEnv ? "configured" : "not-configured" + }; +} + +function normalizeLimits(value, prefix, fallback = DEFAULT_LIMITS) { + return { + statementTimeoutMs: positiveInteger(value.statementTimeoutMs ?? fallback.statementTimeoutMs, `${prefix}.statementTimeoutMs`), + rowLimit: positiveInteger(value.rowLimit ?? fallback.rowLimit, `${prefix}.rowLimit`), + byteLimit: positiveInteger(value.byteLimit ?? fallback.byteLimit, `${prefix}.byteLimit`) + }; +} + +function normalizeSsl(value) { + if (value === undefined || value === null || value === false) { + return false; + } + + if (value === true || typeof value === "object") { + return value; + } + + fail("invalid configuration value for ssl: expected boolean or object", 3); +} + +function requiredString(value, key) { + const normalized = normalizeStringValue(value, key, { exitCode: 3 }); + if (!normalized) { + fail(`missing required configuration value: ${key}`, 3); + } + + return normalized; +} + +function positiveInteger(value, key) { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + fail(`invalid configuration value for ${key}: expected a positive integer`, 3); + } + + return number; +} diff --git a/packages/postgres-cli/src/lib/errors.js b/packages/postgres-cli/src/lib/errors.js new file mode 100644 index 0000000..4efa4a8 --- /dev/null +++ b/packages/postgres-cli/src/lib/errors.js @@ -0,0 +1,14 @@ +export { CliError, fail } from "@khaale/cli-core"; + +export function sanitizeDatabaseError(error, session) { + let message = String(error?.message || error || "database request failed"); + const secrets = [session?.password].filter(Boolean); + + for (const secret of secrets) { + message = message.replaceAll(secret, ""); + } + + message = message.replace(/(postgres(?:ql)?:\/\/)([^/@\s]+):([^/@\s]+)@/giu, "$1@"); + message = message.replace(/password\s*=\s*[^\s,;]+/giu, "password="); + return message; +} diff --git a/packages/postgres-cli/src/lib/output.js b/packages/postgres-cli/src/lib/output.js new file mode 100644 index 0000000..38b945c --- /dev/null +++ b/packages/postgres-cli/src/lib/output.js @@ -0,0 +1,89 @@ +import { pickFields } from "@khaale/cli-core"; + +export function resolveFormat(options = {}) { + if (options.csv) { + return "csv"; + } + + if (options.md) { + return "md"; + } + + return "json"; +} + +export function writeOutput(value, format, { compact = false, fields, stdout = process.stdout } = {}) { + const projected = fields ? pickFields(value, fields) : value; + + if (format === "csv") { + stdout.write(`${renderCsv(projected)}\n`); + return; + } + + if (format === "md") { + stdout.write(`${renderMarkdown(projected)}\n`); + return; + } + + stdout.write(`${JSON.stringify(projected, null, compact ? 0 : 2)}\n`); +} + +export function renderCsv(value) { + const rows = Array.isArray(value?.rows) ? value.rows : Array.isArray(value) ? value : null; + const columns = Array.isArray(value?.columns) + ? value.columns.map((column) => typeof column === "string" ? column : column.name) + : rows && rows.length > 0 + ? Object.keys(rows[0]) + : []; + + if (!rows || columns.length === 0) { + throw new Error("CSV output is supported only for tabular query results"); + } + + const lines = [columns.map(csvCell).join(",")]; + for (const row of rows) { + lines.push(columns.map((column) => csvCell(row?.[column])).join(",")); + } + + return lines.join("\n"); +} + +export function renderMarkdown(value) { + if (Array.isArray(value)) { + return renderMarkdownTable(value); + } + + if (Array.isArray(value?.rows)) { + const table = renderMarkdownTable(value.rows, value.columns); + const status = value.truncated ? "\n\n> Result truncated by pgc limits." : ""; + return `${table}${status}`; + } + + return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; +} + +function renderMarkdownTable(rows, declaredColumns) { + const columns = Array.isArray(declaredColumns) + ? declaredColumns.map((column) => typeof column === "string" ? column : column.name) + : rows.length > 0 + ? Object.keys(rows[0]) + : []; + + if (columns.length === 0) { + return "(empty)"; + } + + const header = `| ${columns.join(" | ")} |`; + const separator = `| ${columns.map(() => "---").join(" | ")} |`; + const body = rows.map((row) => `| ${columns.map((column) => markdownCell(row?.[column])).join(" | ")} |`); + return [header, separator, ...body].join("\n"); +} + +function csvCell(value) { + const text = value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value); + return /[",\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function markdownCell(value) { + return csvCell(value).replaceAll("|", "\\|").replaceAll("\n", " "); +} diff --git a/packages/postgres-cli/src/lib/postgres.js b/packages/postgres-cli/src/lib/postgres.js new file mode 100644 index 0000000..51e7f5a --- /dev/null +++ b/packages/postgres-cli/src/lib/postgres.js @@ -0,0 +1,98 @@ +import { Client } from "pg"; +import { fail, sanitizeDatabaseError } from "./errors.js"; +import { assertParameters, assertReadOnlySql, boundedQueryText, normalizeQueryResult } from "./query.js"; + +export async function executeReadQuery(session, sql, parameters = [], options = {}) { + assertReadOnlySql(sql); + const values = assertParameters(parameters); + const limits = { + rowLimit: options.rowLimit ?? session.rowLimit, + byteLimit: options.byteLimit ?? session.byteLimit, + statementTimeoutMs: options.statementTimeoutMs ?? session.statementTimeoutMs + }; + const client = await connectSession(session, options.clientFactory); + let result; + + try { + await client.query("BEGIN"); + await client.query("SET TRANSACTION READ ONLY"); + await client.query(`SET LOCAL statement_timeout TO ${limits.statementTimeoutMs}`); + result = await client.query({ + text: boundedQueryText(sql, limits.rowLimit), + values + }); + return normalizeQueryResult(result, limits); + } catch (error) { + throw databaseFailure(error, session); + } finally { + await rollbackQuietly(client); + await endQuietly(client); + } +} + +export async function diagnoseSession(session, options = {}) { + const client = await connectSession(session, options.clientFactory); + try { + await client.query("BEGIN"); + await client.query("SET TRANSACTION READ ONLY"); + const result = await client.query("SELECT current_database() AS database, current_user AS user, version() AS version"); + return { + reachable: true, + readOnly: true, + database: result.rows?.[0]?.database || null, + user: result.rows?.[0]?.user || null, + version: result.rows?.[0]?.version || null + }; + } catch (error) { + throw databaseFailure(error, session); + } finally { + await rollbackQuietly(client); + await endQuietly(client); + } +} + +async function connectSession(session, clientFactory = defaultClientFactory) { + const client = clientFactory({ + host: session.host, + port: session.port, + database: session.database, + user: session.user, + password: session.password || undefined, + ssl: session.ssl || undefined + }); + + try { + await client.connect(); + return client; + } catch (error) { + await endQuietly(client); + throw databaseFailure(error, session); + } +} + +function defaultClientFactory(config) { + return new Client(config); +} + +function databaseFailure(error, session) { + const failure = new Error(sanitizeDatabaseError(error, session)); + failure.code = error?.code || "database_error"; + failure.exitCode = 4; + return failure; +} + +async function rollbackQuietly(client) { + try { + await client.query("ROLLBACK"); + } catch { + // Preserve the operation error; cleanup is best effort. + } +} + +async function endQuietly(client) { + try { + await client.end(); + } catch { + // Preserve the operation error; cleanup is best effort. + } +} diff --git a/packages/postgres-cli/src/lib/query.js b/packages/postgres-cli/src/lib/query.js new file mode 100644 index 0000000..065f025 --- /dev/null +++ b/packages/postgres-cli/src/lib/query.js @@ -0,0 +1,217 @@ +import { fail } from "./errors.js"; + +const ALLOWED_STARTS = new Set(["select", "with", "values", "show", "explain", "table"]); +const FORBIDDEN_WORDS = /\b(insert|update|delete|merge|create|alter|drop|truncate|grant|revoke|copy|vacuum|analyze|call|do|set|reset|discard|begin|commit|rollback|savepoint|release|prepare|execute|listen|notify|unlisten|lock)\b/iu; +const FORBIDDEN_FUNCTIONS = /\b(nextval|setval|pg_terminate_backend|pg_cancel_backend|dblink_exec)\s*\(/iu; + +export function assertReadOnlySql(sql) { + if (typeof sql !== "string" || !sql.trim()) { + fail("query SQL is required", 2); + } + + const cleaned = stripSqlCommentsAndLiterals(sql).trim(); + if (!cleaned) { + fail("query SQL is empty", 2); + } + + if (cleaned.includes(";")) { + fail("read-only queries must contain exactly one statement without semicolons", 2); + } + + const firstWord = cleaned.match(/^([a-z]+)/iu)?.[1]?.toLowerCase(); + if (!ALLOWED_STARTS.has(firstWord)) { + fail("query is not allowed in read-only mode", 2); + } + + if (FORBIDDEN_WORDS.test(cleaned) || FORBIDDEN_FUNCTIONS.test(cleaned)) { + fail("query is not allowed in read-only mode", 2); + } + + return sql.trim(); +} + +export function assertParameters(parameters) { + if (parameters === undefined || parameters === null) { + return []; + } + + if (!Array.isArray(parameters)) { + fail("query parameters must be an array", 2); + } + + return parameters; +} + +export function boundedQueryText(sql, rowLimit) { + const firstWord = stripSqlCommentsAndLiterals(sql).trim().match(/^([a-z]+)/iu)?.[1]?.toLowerCase(); + if (!["select", "with", "values", "table"].includes(firstWord)) { + return sql; + } + + return `SELECT * FROM (${sql}) AS pgc_result LIMIT ${rowLimit + 1}`; +} + +export function normalizeQueryResult(result, { rowLimit, byteLimit } = {}) { + const columns = (result.fields || []).map((field) => ({ + name: field.name, + typeId: field.dataTypeID ?? null + })); + const sourceRows = Array.isArray(result.rows) ? result.rows : []; + const rows = []; + let bytes = 0; + let truncated = sourceRows.length > rowLimit; + + for (const sourceRow of sourceRows.slice(0, rowLimit)) { + const row = {}; + const names = columns.length > 0 ? columns.map((column) => column.name) : Object.keys(sourceRow); + for (const name of names) { + row[name] = normalizeValue(sourceRow[name]); + } + + const rowBytes = Buffer.byteLength(JSON.stringify(row)); + if (bytes + rowBytes > byteLimit) { + truncated = true; + break; + } + + rows.push(row); + bytes += rowBytes; + } + + return { + columns, + rows, + rowCount: rows.length, + truncated, + bytes + }; +} + +export function normalizeValue(value) { + if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (typeof value === "bigint") { + return { type: "bigint", value: String(value) }; + } + + if (value instanceof Date) { + return { type: "timestamp", value: value.toISOString() }; + } + + if (Buffer.isBuffer(value)) { + return { type: "bytea", value: value.toString("base64") }; + } + + if (Array.isArray(value)) { + return value.map(normalizeValue); + } + + if (typeof value === "object") { + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, nested]) => [key, normalizeValue(nested)])); + } + + return String(value); +} + +export function stableStringify(value) { + return JSON.stringify(normalizeValue(value)); +} + +function stripSqlCommentsAndLiterals(sql) { + const output = []; + let index = 0; + let state = "code"; + + while (index < sql.length) { + const current = sql[index]; + const next = sql[index + 1]; + + if (state === "code" && current === "-" && next === "-") { + state = "line-comment"; + output.push(" "); + index += 2; + continue; + } + + if (state === "code" && current === "/" && next === "*") { + state = "block-comment"; + output.push(" "); + index += 2; + continue; + } + + if (state === "code" && current === "'") { + state = "single-quote"; + output.push(" "); + index += 1; + continue; + } + + if (state === "code" && current === '"') { + state = "double-quote"; + output.push(" "); + index += 1; + continue; + } + + if (state === "line-comment") { + if (current === "\n") { + state = "code"; + output.push("\n"); + } else { + output.push(" "); + } + index += 1; + continue; + } + + if (state === "block-comment") { + if (current === "*" && next === "/") { + state = "code"; + output.push(" "); + index += 2; + } else { + output.push(current === "\n" ? "\n" : " "); + index += 1; + } + continue; + } + + if (state === "single-quote") { + if (current === "'" && next === "'") { + output.push(" "); + index += 2; + } else if (current === "'") { + state = "code"; + output.push(" "); + index += 1; + } else { + output.push(current === "\n" ? "\n" : " "); + index += 1; + } + continue; + } + + if (state === "double-quote") { + if (current === '"' && next === '"') { + output.push(" "); + index += 2; + } else if (current === '"') { + state = "code"; + output.push(" "); + index += 1; + } else { + output.push(current === "\n" ? "\n" : " "); + index += 1; + } + continue; + } + + output.push(current); + index += 1; + } + + return output.join(""); +} diff --git a/packages/postgres-cli/src/lib/schema.js b/packages/postgres-cli/src/lib/schema.js new file mode 100644 index 0000000..f399d5c --- /dev/null +++ b/packages/postgres-cli/src/lib/schema.js @@ -0,0 +1,231 @@ +import { executeReadQuery } from "./postgres.js"; + +export async function schemaOverview(config, options = {}) { + const session = config.getSession(options.session); + const execute = options.execute || executeReadQuery; + const limit = resolveCatalogLimit(options.limit, session.rowLimit); + const result = await execute(session, OVERVIEW_SQL, [limit + 1], { ...options, rowLimit: limit }); + return { ok: true, session: session.name, kind: "schema-overview", ...withContinuation(result, "narrow by schema or increase --limit") }; +} + +export async function schemaSearch(config, options = {}) { + if (!String(options.query || "").trim()) { + throw new Error("schema search query is required"); + } + + const session = config.getSession(options.session); + const execute = options.execute || executeReadQuery; + const pattern = `%${String(options.query || "").trim()}%`; + const limit = resolveCatalogLimit(options.limit, session.rowLimit); + const result = await execute(session, SEARCH_SQL, [pattern, options.schema || null, options.type || null, limit + 1], { ...options, rowLimit: limit }); + return { ok: true, session: session.name, kind: "schema-search", ...withContinuation(result, "narrow by schema/type or increase --limit") }; +} + +export async function tableDetail(config, options = {}) { + requireOption(options.schema, "schema"); + requireOption(options.table, "table"); + const session = config.getSession(options.session); + const execute = options.execute || executeReadQuery; + const queryOptions = { ...options, execute: undefined }; + const [tableMetadata, columns, indexes, constraints, relations] = await Promise.all([ + execute(session, TABLE_METADATA_SQL, [options.schema, options.table], queryOptions), + execute(session, COLUMNS_SQL, [options.schema, options.table], queryOptions), + execute(session, INDEXES_SQL, [options.schema, options.table], queryOptions), + execute(session, CONSTRAINTS_SQL, [options.schema, options.table], queryOptions), + relationships(config, { ...options, execute }) + ]); + + const metadata = tableMetadata.rows?.[0]; + const availability = !metadata + ? "not_found" + : metadata.can_select === true || metadata.can_select === "true" + ? "available" + : "inaccessible"; + + return { + ok: true, + session: session.name, + kind: "table-detail", + table: { schema: options.schema, name: options.table, comment: metadata?.comment ?? null, availability }, + columns, + indexes, + constraints, + relationships: relations + }; +} + +export async function relationships(config, options = {}) { + requireOption(options.schema, "schema"); + requireOption(options.table, "table"); + const session = config.getSession(options.session); + const execute = options.execute || executeReadQuery; + const direction = options.direction || "both"; + if (!["incoming", "outgoing", "both"].includes(direction)) { + throw new Error(`unsupported relationship direction: ${direction}`); + } + const directions = direction === "both" ? ["incoming", "outgoing"] : [direction]; + const result = {}; + + for (const item of directions) { + const sql = item === "incoming" ? INCOMING_RELATIONS_SQL : OUTGOING_RELATIONS_SQL; + result[item] = groupRelationships(await execute(session, sql, [options.schema, options.table], options)); + } + + return result; +} + +export function groupRelationships(result) { + const groups = new Map(); + for (const row of result.rows || []) { + const key = [row.constraint_name, row.source_schema, row.source_table, row.target_schema, row.target_table].join("\u0000"); + const relationship = groups.get(key) || { + constraintName: row.constraint_name, + source: { schema: row.source_schema, table: row.source_table }, + target: { schema: row.target_schema, table: row.target_table }, + columns: [] + }; + relationship.columns.push({ + position: row.column_position, + source: row.source_column, + target: row.target_column + }); + groups.set(key, relationship); + } + + const rows = [...groups.values()].map((relationship) => ({ + ...relationship, + columns: relationship.columns.sort((left, right) => left.position - right.position) + })); + return { ...result, rows, rowCount: rows.length }; +} + +const OVERVIEW_SQL = ` +SELECT + n.nspname AS schema_name, + (SELECT count(*) FROM pg_catalog.pg_class r WHERE r.relnamespace = n.oid AND r.relkind IN ('r', 'p'))::int AS table_count, + (SELECT count(*) FROM pg_catalog.pg_class v WHERE v.relnamespace = n.oid AND v.relkind IN ('v', 'm'))::int AS view_count, + (SELECT count(*) FROM pg_catalog.pg_proc p WHERE p.pronamespace = n.oid)::int AS routine_count +FROM pg_catalog.pg_namespace n +WHERE n.nspname NOT LIKE 'pg_toast%' +ORDER BY n.nspname +LIMIT $1`; + +const SEARCH_SQL = ` +WITH objects AS ( + SELECT ns.nspname AS schema_name, rel.relname AS object_name, 'table' AS object_type, NULL::text AS parent_name, + obj_description(rel.oid, 'pg_class') AS comment + FROM pg_catalog.pg_class rel + JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace + WHERE rel.relkind IN ('r', 'p') + UNION ALL + SELECT ns.nspname, rel.relname, 'view', NULL::text, + obj_description(rel.oid, 'pg_class') + FROM pg_catalog.pg_class rel + JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace + WHERE rel.relkind IN ('v', 'm') + UNION ALL + SELECT ns.nspname, proc.proname, 'routine', NULL::text, + obj_description(proc.oid, 'pg_proc') + FROM pg_catalog.pg_proc proc + JOIN pg_catalog.pg_namespace ns ON ns.oid = proc.pronamespace + UNION ALL + SELECT ns.nspname, att.attname, 'column', rel.relname, + col_description(rel.oid, att.attnum) + FROM pg_catalog.pg_attribute att + JOIN pg_catalog.pg_class rel ON rel.oid = att.attrelid + JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace + WHERE rel.relkind IN ('r', 'p', 'v', 'm') AND att.attnum > 0 AND NOT att.attisdropped +) +SELECT schema_name, object_name, object_type, parent_name, comment +FROM objects +WHERE (object_name ILIKE $1 OR comment ILIKE $1) + AND ($2::text IS NULL OR schema_name = $2) + AND ($3::text IS NULL OR object_type = $3) +ORDER BY schema_name, object_type, object_name, parent_name NULLS FIRST +LIMIT $4`; + +const TABLE_METADATA_SQL = ` +SELECT obj_description(rel.oid, 'pg_class') AS comment, + has_table_privilege(format('%I.%I', ns.nspname, rel.relname), 'SELECT') AS can_select +FROM pg_catalog.pg_class rel +JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace +WHERE ns.nspname = $1 AND rel.relname = $2`; + +const COLUMNS_SQL = ` +SELECT info.column_name, info.ordinal_position, info.data_type, info.udt_name, info.is_nullable, info.column_default, + col_description(rel.oid, att.attnum) AS comment +FROM information_schema.columns info +JOIN pg_catalog.pg_class rel ON rel.relname = info.table_name +JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace AND ns.nspname = info.table_schema +JOIN pg_catalog.pg_attribute att ON att.attrelid = rel.oid AND att.attname = info.column_name +WHERE info.table_schema = $1 AND info.table_name = $2 +ORDER BY info.ordinal_position`; + +const INDEXES_SQL = ` +SELECT indexname, indexdef +FROM pg_catalog.pg_indexes +WHERE schemaname = $1 AND tablename = $2 +ORDER BY indexname`; + +const CONSTRAINTS_SQL = ` +SELECT con.conname AS constraint_name, con.contype AS constraint_type, pg_get_constraintdef(con.oid) AS definition +FROM pg_catalog.pg_constraint con +JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid +JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace +WHERE ns.nspname = $1 AND rel.relname = $2 +ORDER BY con.conname`; + +const OUTGOING_RELATIONS_SQL = relationshipSql("source"); +const INCOMING_RELATIONS_SQL = relationshipSql("target"); + +function relationshipSql(direction) { + const filter = direction === "source" + ? "src_ns.nspname = $1 AND src.relname = $2" + : "dst_ns.nspname = $1 AND dst.relname = $2"; + + return ` +SELECT + con.conname AS constraint_name, + src_ns.nspname AS source_schema, + src.relname AS source_table, + dst_ns.nspname AS target_schema, + dst.relname AS target_table, + src_att.attname AS source_column, + dst_att.attname AS target_column, + src_key.ord AS column_position +FROM pg_catalog.pg_constraint con +JOIN pg_catalog.pg_class src ON src.oid = con.conrelid +JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace +JOIN pg_catalog.pg_class dst ON dst.oid = con.confrelid +JOIN pg_catalog.pg_namespace dst_ns ON dst_ns.oid = dst.relnamespace +JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS src_key(attnum, ord) ON true +JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS dst_key(attnum, ord) ON dst_key.ord = src_key.ord +JOIN pg_catalog.pg_attribute src_att ON src_att.attrelid = src.oid AND src_att.attnum = src_key.attnum +JOIN pg_catalog.pg_attribute dst_att ON dst_att.attrelid = dst.oid AND dst_att.attnum = dst_key.attnum +WHERE con.contype = 'f' AND ${filter} +ORDER BY constraint_name, column_position`; +} + +function requireOption(value, name) { + if (!String(value || "").trim()) { + throw new Error(`${name} is required`); + } +} + +function resolveCatalogLimit(value, fallback) { + const limit = Number(value ?? fallback); + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error("schema result limit must be a positive integer"); + } + + return limit; +} + +function withContinuation(result, hint) { + return { + ...result, + continuation: result.truncated + ? { required: true, reason: "result_limit", hint } + : null + }; +} diff --git a/packages/postgres-cli/test/cli.test.js b/packages/postgres-cli/test/cli.test.js new file mode 100644 index 0000000..c9c2e72 --- /dev/null +++ b/packages/postgres-cli/test/cli.test.js @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { run } from "../src/cli.js"; +import { captureStream, createConfigFile } from "./support.js"; + +test("CLI lists named sessions in a stable JSON envelope", async () => { + const { configPath } = await createConfigFile({ + sessions: { qa: { host: "qa.example", database: "app", user: "agent", password: "secret" } } + }); + const stdout = captureStream(); + const result = await run(["--json", "sessions", "list"], { configPath, stdout }); + const payload = JSON.parse(stdout.value); + + assert.equal(result.exitCode, 0); + assert.equal(payload.ok, true); + assert.equal(payload.sessions[0].name, "qa"); + assert.equal(stdout.value.includes('"password"'), false); + assert.equal(stdout.value.includes('"secret":"secret"'), false); +}); + +test("CLI returns a JSON error for unsupported commands", async () => { + const stdout = captureStream(); + const result = await run(["--json", "unknown"], { stdout }); + const payload = JSON.parse(stdout.value); + + assert.equal(result.exitCode, 2); + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "cli_error"); +}); diff --git a/packages/postgres-cli/test/compare.test.js b/packages/postgres-cli/test/compare.test.js new file mode 100644 index 0000000..1685403 --- /dev/null +++ b/packages/postgres-cli/test/compare.test.js @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compareQueries, compareResults } from "../src/lib/compare.js"; + +test("compareResults classifies equal, changed, left-only, and right-only rows", () => { + const result = compareResults( + { + columns: [{ name: "id" }, { name: "name" }], + rows: [{ id: 1, name: "same" }, { id: 2, name: "left" }, { id: 3, name: "gone" }], + truncated: false + }, + { + columns: [{ name: "id" }, { name: "name" }], + rows: [{ id: 1, name: "same" }, { id: 2, name: "right" }, { id: 4, name: "new" }], + truncated: false + }, + ["id"] + ); + + assert.equal(result.equal, false); + assert.deepEqual(result.counts, { equal: 1, changed: 1, leftOnly: 1, rightOnly: 1 }); + assert.deepEqual(result.differences.map((item) => item.kind), ["changed", "left-only", "right-only"]); +}); + +test("compareResults requires same-named columns and requested keys", () => { + assert.throws(() => compareResults({ columns: [{ name: "id" }], rows: [] }, { columns: [{ name: "uuid" }], rows: [] }, ["id"]), /key columns are missing/); + assert.throws(() => compareResults({ columns: [{ name: "id" }, { name: "name" }], rows: [] }, { columns: [{ name: "id" }, { name: "title" }], rows: [] }, ["id"]), /same column names/); +}); + +test("compareQueries executes independent left and right queries", async () => { + const calls = []; + const config = { + getSession(name) { + return { name, rowLimit: 10, byteLimit: 10_000, statementTimeoutMs: 1_000 }; + } + }; + const result = await compareQueries(config, { + leftSession: "qa", + rightSession: "uat", + leftQuery: "SELECT id, name FROM users", + rightQuery: "SELECT id, name FROM accounts", + key: "id", + execute: async (session, sql) => { + calls.push([session.name, sql]); + return { columns: [{ name: "id" }, { name: "name" }], rows: [{ id: 1, name: "same" }], truncated: false, rowCount: 1 }; + } + }); + + assert.deepEqual(calls, [["qa", "SELECT id, name FROM users"], ["uat", "SELECT id, name FROM accounts"]]); + assert.equal(result.equal, true); + assert.equal(result.complete, true); +}); + +test("compareResults aligns non-key values by column name regardless of order", () => { + const result = compareResults( + { columns: [{ name: "id" }, { name: "name" }, { name: "status" }], rows: [{ id: 1, name: "same", status: "active" }], truncated: false }, + { columns: [{ name: "status" }, { name: "id" }, { name: "name" }], rows: [{ status: "active", id: 1, name: "same" }], truncated: false }, + ["id"] + ); + + assert.equal(result.equal, true); +}); + +test("compareQueries reports an incomplete source instead of equality", async () => { + const config = { getSession: (name) => ({ name, rowLimit: 10, byteLimit: 10_000, statementTimeoutMs: 1_000 }) }; + const result = await compareQueries(config, { + leftSession: "qa", + rightSession: "uat", + leftQuery: "SELECT id FROM users", + rightQuery: "SELECT id FROM users", + key: "id", + execute: async (session) => { + if (session.name === "uat") { + throw Object.assign(new Error("timeout"), { code: "query_timeout" }); + } + return { columns: [{ name: "id" }], rows: [{ id: 1 }], truncated: false, rowCount: 1 }; + } + }); + + assert.equal(result.complete, false); + assert.equal(result.equal, false); + assert.equal(result.right.status, "error"); +}); diff --git a/packages/postgres-cli/test/config.test.js b/packages/postgres-cli/test/config.test.js new file mode 100644 index 0000000..90f0fe7 --- /dev/null +++ b/packages/postgres-cli/test/config.test.js @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadConfig } from "../src/lib/config.js"; +import { createConfigFile } from "./support.js"; + +test("loadConfig resolves named sessions and redacts secrets from safe views", async () => { + const { configPath } = await createConfigFile({ + sessions: { + qa: { host: "qa.example", port: 5433, database: "app", user: "agent", password: "secret" }, + uat: { host: "uat.example", database: "app", user: "agent", passwordEnv: "PGC_UAT_PASSWORD" } + }, + defaults: { rowLimit: 25 } + }); + + const config = await loadConfig({ configPath, env: { PGC_UAT_PASSWORD: "uat-secret" } }); + assert.deepEqual(config.listSessions().map((session) => session.name), ["qa", "uat"]); + assert.equal(config.listSessions()[0].secret, "configured"); + assert.equal(config.safeView().sessions[0].password, undefined); + assert.equal(config.getSession("qa").password, "secret"); + assert.equal(config.getSession("uat").password, "uat-secret"); + assert.equal(config.getSession("qa").rowLimit, 25); +}); + +test("loadConfig rejects a missing named session or secret reference", async () => { + const { configPath } = await createConfigFile({ + sessions: { qa: { host: "qa.example", database: "app", user: "agent", passwordEnv: "MISSING" } } + }); + const config = await loadConfig({ configPath, env: {} }); + + assert.throws(() => config.getSession("uat"), /unknown PostgreSQL session/); + assert.throws(() => config.getSession("qa"), /missing secret/); +}); diff --git a/packages/postgres-cli/test/postgres.test.js b/packages/postgres-cli/test/postgres.test.js new file mode 100644 index 0000000..9a5e047 --- /dev/null +++ b/packages/postgres-cli/test/postgres.test.js @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { executeReadQuery, diagnoseSession } from "../src/lib/postgres.js"; +import { sanitizeDatabaseError } from "../src/lib/errors.js"; +import { fakeClient } from "./support.js"; + +const session = { + name: "qa", + host: "qa.example", + port: 5432, + database: "app", + user: "agent", + password: "secret", + rowLimit: 10, + byteLimit: 10_000, + statementTimeoutMs: 1_000 +}; + +test("executeReadQuery uses a read-only transaction and always cleans up", async () => { + const client = fakeClient({ rows: [{ id: 1 }] }); + const result = await executeReadQuery(session, "SELECT 1 AS id", [], { clientFactory: client.factory }); + + assert.deepEqual(result.rows, [{ id: 1 }]); + assert.deepEqual(client.calls.map(([name]) => name), ["connect", "query", "query", "query", "query", "query", "end"]); + assert.equal(client.calls[2][1], "SET TRANSACTION READ ONLY"); + assert.equal(client.calls[3][1], "SET LOCAL statement_timeout TO 1000"); + assert.equal(client.calls[4][1].text.includes("LIMIT 11"), true); +}); + +test("executeReadQuery passes parameters separately from SQL", async () => { + const client = fakeClient({ rows: [{ id: 1 }] }); + await executeReadQuery(session, "SELECT $1::int AS id", [7], { clientFactory: client.factory }); + + const queryCall = client.calls.find(([, query]) => typeof query === "object"); + assert.deepEqual(queryCall[1].values, [7]); +}); + +test("executeReadQuery sanitizes database errors and still closes the client", async () => { + const client = fakeClient({ errorOnQuery: "SELECT" }); + + await assert.rejects( + executeReadQuery(session, "SELECT 1", [], { clientFactory: client.factory }), + (error) => error.message.includes("") && !error.message.includes("secret") + ); + assert.equal(client.calls.at(-1)[0], "end"); +}); + +test("diagnoseSession reports database identity without credentials", async () => { + const client = fakeClient({ rows: [{ database: "app", user: "agent", version: "PostgreSQL 16" }] }); + const result = await diagnoseSession(session, { clientFactory: client.factory }); + + assert.deepEqual(result, { reachable: true, readOnly: true, database: "app", user: "agent", version: "PostgreSQL 16" }); +}); + +test("database error sanitization redacts password assignments", () => { + assert.equal(sanitizeDatabaseError(new Error("password=other-secret"), session), "password="); +}); diff --git a/packages/postgres-cli/test/query.test.js b/packages/postgres-cli/test/query.test.js new file mode 100644 index 0000000..3733215 --- /dev/null +++ b/packages/postgres-cli/test/query.test.js @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertParameters, assertReadOnlySql, boundedQueryText, normalizeQueryResult } from "../src/lib/query.js"; + +test("assertReadOnlySql allows bounded read statements and ignores quoted text", () => { + assert.equal(assertReadOnlySql("SELECT 'update;'"), "SELECT 'update;'"); + assert.equal(assertReadOnlySql("-- select update\nSELECT 1"), "-- select update\nSELECT 1"); + assert.equal(boundedQueryText("SELECT 1", 10), "SELECT * FROM (SELECT 1) AS pgc_result LIMIT 11"); +}); + +test("assertReadOnlySql rejects mutation and control statements", () => { + for (const sql of ["INSERT INTO users VALUES (1)", "UPDATE users SET name = 'x'", "DROP TABLE users", "SELECT nextval('seq')", "SELECT 1; SELECT 2", "SET statement_timeout = 1"]) { + assert.throws(() => assertReadOnlySql(sql), /read-only/); + } +}); + +test("normalizeQueryResult preserves typed values and enforces output limits", () => { + const result = normalizeQueryResult({ + fields: [{ name: "id", dataTypeID: 20 }, { name: "created_at", dataTypeID: timestamptzId() }, { name: "payload", dataTypeID: 17 }], + rows: [{ id: 1n, created_at: new Date("2026-01-01T00:00:00.000Z"), payload: Buffer.from("ok") }, { id: 2n, created_at: null, payload: Buffer.from("too-large") }] + }, { rowLimit: 1, byteLimit: 1_000 }); + + assert.equal(result.truncated, true); + assert.deepEqual(result.rows[0].id, { type: "bigint", value: "1" }); + assert.deepEqual(result.rows[0].created_at, { type: "timestamp", value: "2026-01-01T00:00:00.000Z" }); + assert.deepEqual(result.rows[0].payload, { type: "bytea", value: "b2s=" }); + assert.deepEqual(assertParameters(undefined), []); +}); + +function timestamptzId() { + return 1184; +} diff --git a/packages/postgres-cli/test/schema.test.js b/packages/postgres-cli/test/schema.test.js new file mode 100644 index 0000000..33a7e2f --- /dev/null +++ b/packages/postgres-cli/test/schema.test.js @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { groupRelationships, relationships, schemaOverview, schemaSearch, tableDetail } from "../src/lib/schema.js"; + +const session = { + name: "qa", + rowLimit: 20, + byteLimit: 10_000, + statementTimeoutMs: 1_000 +}; + +function stubConfig(calls) { + return { getSession: () => session }; +} + +function executeFactory(calls) { + return async (_session, sql, parameters) => { + calls.push({ sql, parameters }); + return { columns: [], rows: [], rowCount: 0, truncated: false }; + }; +} + +test("schema overview and search use bounded, deterministic catalog queries", async () => { + const calls = []; + const execute = executeFactory(calls); + await schemaOverview(stubConfig(calls), { session: "qa", limit: 10, execute }); + await schemaSearch(stubConfig(calls), { session: "qa", query: "user", schema: "public", type: "table", limit: 5, execute }); + + assert.equal(calls[0].sql.includes("pg_catalog.pg_namespace"), true); + assert.deepEqual(calls[0].parameters, [11]); + assert.equal(calls[1].sql.includes("comment ILIKE $1"), true); + assert.equal(calls[1].sql.includes("obj_description"), true); + assert.equal(calls[1].sql.includes("col_description"), true); + assert.deepEqual(calls[1].parameters, ["%user%", "public", "table", 6]); +}); + +test("table detail exposes columns, indexes, constraints, and both relationship directions", async () => { + const calls = []; + const execute = executeFactory(calls); + const result = await tableDetail(stubConfig(calls), { session: "qa", schema: "public", table: "orders", execute }); + + assert.equal(calls.length, 6); + assert.equal(calls.some(({ sql }) => sql.includes("obj_description(rel.oid, 'pg_class') AS comment")), true); + assert.equal(calls.some(({ sql }) => sql.includes("information_schema.columns")), true); + assert.equal(calls.some(({ sql }) => sql.includes("pg_catalog.pg_indexes")), true); + assert.equal(calls.filter(({ sql }) => sql.includes("pg_catalog.pg_constraint")).length, 3); + assert.deepEqual(Object.keys(result.relationships).sort(), ["incoming", "outgoing"]); +}); + +test("table detail returns table and column comments when available", async () => { + const execute = async (_session, sql) => { + if (sql.includes("obj_description(rel.oid, 'pg_class') AS comment")) { + return { columns: ["comment", "can_select"], rows: [{ comment: "Orders currently being processed", can_select: true }], rowCount: 1, truncated: false }; + } + if (sql.includes("col_description(rel.oid, att.attnum) AS comment")) { + return { columns: ["column_name", "comment"], rows: [{ column_name: "id", comment: "Stable order identifier" }], rowCount: 1, truncated: false }; + } + return { columns: [], rows: [], rowCount: 0, truncated: false }; + }; + + const result = await tableDetail(stubConfig([]), { session: "qa", schema: "public", table: "orders", execute }); + + assert.equal(result.table.comment, "Orders currently being processed"); + assert.equal(result.table.availability, "available"); + assert.equal(result.columns.rows[0].comment, "Stable order identifier"); +}); + +test("schema lists expose continuation when the requested limit is reached", async () => { + const calls = []; + const execute = async (_session, sql, parameters, options) => { + calls.push({ sql, parameters, options }); + return { + columns: [{ name: "schema_name" }], + rows: [{ schema_name: "first" }], + rowCount: 1, + truncated: true + }; + }; + + const result = await schemaOverview(stubConfig(calls), { session: "qa", limit: 1, execute }); + + assert.deepEqual(calls[0].parameters, [2]); + assert.equal(calls[0].options.rowLimit, 1); + assert.deepEqual(result.continuation, { + required: true, + reason: "result_limit", + hint: "narrow by schema or increase --limit" + }); +}); + +test("table detail distinguishes a missing table from an accessible table", async () => { + const result = await tableDetail(stubConfig([]), { + session: "qa", + schema: "public", + table: "missing", + execute: executeFactory([]) + }); + + assert.equal(result.table.availability, "not_found"); +}); + +test("table detail reports inaccessible metadata without treating it as empty success", async () => { + const execute = async (_session, sql) => { + if (sql.includes("has_table_privilege")) { + return { columns: ["comment", "can_select"], rows: [{ comment: null, can_select: false }], rowCount: 1, truncated: false }; + } + return { columns: [], rows: [], rowCount: 0, truncated: false }; + }; + + const result = await tableDetail(stubConfig([]), { session: "qa", schema: "public", table: "restricted", execute }); + + assert.equal(result.table.availability, "inaccessible"); +}); + +test("relationships validate direction and return empty directional results", async () => { + const calls = []; + const result = await relationships(stubConfig(calls), { session: "qa", schema: "public", table: "users", direction: "incoming", execute: executeFactory(calls) }); + + assert.deepEqual(Object.keys(result), ["incoming"]); + await assert.rejects(relationships(stubConfig(calls), { session: "qa", schema: "public", table: "users", direction: "sideways", execute: executeFactory(calls) }), /unsupported relationship direction/); +}); + +test("groupRelationships keeps composite foreign-key column order", () => { + const result = groupRelationships({ + rows: [ + { constraint_name: "fk_pair", source_schema: "public", source_table: "child", target_schema: "public", target_table: "parent", source_column: "b", target_column: "y", column_position: 2 }, + { constraint_name: "fk_pair", source_schema: "public", source_table: "child", target_schema: "public", target_table: "parent", source_column: "a", target_column: "x", column_position: 1 } + ], + rowCount: 2 + }); + + assert.deepEqual(result.rows[0].columns, [ + { position: 1, source: "a", target: "x" }, + { position: 2, source: "b", target: "y" } + ]); +}); diff --git a/packages/postgres-cli/test/support.js b/packages/postgres-cli/test/support.js new file mode 100644 index 0000000..a1fa337 --- /dev/null +++ b/packages/postgres-cli/test/support.js @@ -0,0 +1,52 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export async function createConfigFile(value) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pgc-test-")); + const configPath = path.join(directory, "config.json"); + await fs.writeFile(configPath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); + return { directory, configPath }; +} + +export function captureStream() { + let value = ""; + return { + write(chunk) { + value += String(chunk); + return true; + }, + get value() { + return value; + } + }; +} + +export function fakeClient({ rows = [{ id: 1 }], fields = [{ name: "id", dataTypeID: 23 }], errorOnQuery = null } = {}) { + const calls = []; + return { + calls, + factory: () => ({ + async connect() { + calls.push(["connect"]); + }, + async query(query) { + calls.push(["query", query]); + const text = typeof query === "string" ? query : query?.text; + if (errorOnQuery && text?.includes(errorOnQuery)) { + throw new Error(`password=secret ${errorOnQuery}`); + } + if (text?.includes("current_database")) { + return { rows, fields }; + } + if (typeof query === "object") { + return { rows, fields }; + } + return { rows: [], fields: [] }; + }, + async end() { + calls.push(["end"]); + } + }) + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9de0475..74a1a01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,16 @@ importers: specifier: ^0.15.0 version: 0.15.0 + packages/postgres-cli: + dependencies: + pg: + specifier: ^8.16.3 + version: 8.23.0 + devDependencies: + '@khaale/cli-core': + specifier: workspace:* + version: link:../cli-core + packages: '@babel/runtime@7.29.2': @@ -503,6 +513,40 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -514,6 +558,22 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -567,6 +627,10 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -599,6 +663,10 @@ packages: engines: {node: '>= 8'} hasBin: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + snapshots: '@babel/runtime@7.29.2': {} @@ -1080,12 +1148,57 @@ snapshots: path-type@4.0.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} pify@4.0.1: {} + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prettier@2.8.8: {} quansync@0.2.11: {} @@ -1126,6 +1239,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + split2@4.2.0: {} + sprintf-js@1.0.3: {} strip-ansi@6.0.1: @@ -1149,3 +1264,5 @@ snapshots: which@2.0.2: dependencies: isexe: 2.0.0 + + xtend@4.0.2: {} diff --git a/scripts/dev-install.mjs b/scripts/dev-install.mjs index 8d48a48..9d48ff5 100644 --- a/scripts/dev-install.mjs +++ b/scripts/dev-install.mjs @@ -13,6 +13,10 @@ const commands = [ { name: "ktc", target: path.join(repoRoot, "packages", "kaiten-cli", "bin", "ktc.js") + }, + { + name: "pgc", + target: path.join(repoRoot, "packages", "postgres-cli", "bin", "pgc.js") } ]; diff --git a/skills/pgc/SKILL.md b/skills/pgc/SKILL.md new file mode 100644 index 0000000..41ddb59 --- /dev/null +++ b/skills/pgc/SKILL.md @@ -0,0 +1,76 @@ +--- +name: pgc +description: Companion skill for the `pgc` PostgreSQL CLI. Run `pgc --json doctor` first, use named sessions without handling credentials, explore large schemas progressively, execute bounded read-only queries, and compare two query results by key columns. +--- + +# `pgc` Companion Skill + +Use `pgc` for read-only PostgreSQL exploration from an agent runtime. Execute it through the available shell or terminal tool; do not read the PostgreSQL config file or handle its passwords directly. + +## First-run order + +1. Verify the command exists: + +```bash +command -v pgc +``` + +2. Run the preflight check before real reads: + +```bash +pgc --json doctor +``` + +3. Select a configured session by name, such as `qa` or `uat`. Never pass a password or credential-bearing connection string as an argument. + +## Session and output rules + +- Use `pgc --json sessions list` or `pgc --json config get` to inspect safe session metadata. +- JSON is the canonical format for agent processing. +- Use `--md` only when a human-readable summary is needed. +- Use `--csv` only for flat tabular query results; use JSON for schema and comparison results. +- Treat `truncated: true`, timeout, unavailable, or compatibility errors as incomplete data. +- Do not copy secrets into prompts, shell history, logs, or task comments. + +## Progressive schema exploration + +Start narrow and expand only the needed object: + +```bash +pgc --json schema overview --session qa +pgc --json schema search --session qa --query order --type table +pgc --json schema table --session qa --schema public --table orders +pgc --json schema relations --session qa --schema public --table orders --direction both +``` + +Search supports table, view, routine, and column names and PostgreSQL comments with optional `--schema` and `--type` filters. Table detail returns table and column comments when available. Relationship results distinguish `incoming` and `outgoing` foreign keys and preserve composite-key column order. + +Treat a non-null `continuation` in schema overview/search as an incomplete list and narrow the request or increase `--limit`. Check `table.availability` before interpreting empty table metadata: it can be `available`, `inaccessible`, or `not_found`. + +## Safe query path + +Use parameterized, bounded read queries: + +```bash +pgc --json query \ + --session qa \ + --sql 'SELECT id, status FROM public.orders WHERE id = $1' \ + --params '[42]' +``` + +The CLI rejects writes, DDL, session/transaction control, and multi-statement SQL. Every query runs in a read-only transaction with timeout, row, and byte limits. If more detail is needed, narrow the query explicitly rather than trying to bypass the limits. + +## Compare two query results + +Provide distinct sessions, independent read-only queries, and same-named key columns: + +```bash +pgc --json compare \ + --left-session qa \ + --right-session uat \ + --left-query 'SELECT id, status FROM public.orders' \ + --right-query 'SELECT order_id AS id, status FROM public.orders' \ + --key id +``` + +The comparison reports equal, changed, left-only, and right-only rows. If either query is truncated, times out, fails, or has incompatible columns, the result is incomplete and must not be described as equality. From e377805a3ac51f3ddb5e31599981f00e456ba970 Mon Sep 17 00:00:00 2001 From: "a.khanteev" Date: Mon, 24 Aug 2026 19:26:14 +0400 Subject: [PATCH 2/2] docs: rename postgres companion skill directory --- openspec/changes/add-postgres-agent-tool/design.md | 2 +- openspec/changes/add-postgres-agent-tool/proposal.md | 2 +- openspec/changes/add-postgres-agent-tool/tasks.md | 2 +- skills/{pgc => postgres-cli}/SKILL.md | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename skills/{pgc => postgres-cli}/SKILL.md (100%) diff --git a/openspec/changes/add-postgres-agent-tool/design.md b/openspec/changes/add-postgres-agent-tool/design.md index 072e46c..e76acf7 100644 --- a/openspec/changes/add-postgres-agent-tool/design.md +++ b/openspec/changes/add-postgres-agent-tool/design.md @@ -64,7 +64,7 @@ Successful commands will emit JSON by default and support compact JSON/field pro ### Ship a companion agent skill with the CLI -The package will include `skills/pgc/SKILL.md` following the existing `glc`/`ktc` companion-skill pattern. It will instruct an agent to run `pgc --json doctor` first, select a named session instead of handling credentials, start schema exploration with an overview or name search, expand only required objects and relationships, keep queries read-only and bounded, and compare two independently supplied query results using same-named key columns. The skill will document JSON as the canonical format and mention Markdown/CSV only as explicit renderings where supported. +The package will include `skills/postgres-cli/SKILL.md` following the existing `glc`/`ktc` companion-skill pattern. It will instruct an agent to run `pgc --json doctor` first, select a named session instead of handling credentials, start schema exploration with an overview or name search, expand only required objects and relationships, keep queries read-only and bounded, and compare two independently supplied query results using same-named key columns. The skill will document JSON as the canonical format and mention Markdown/CSV only as explicit renderings where supported. ## Risks / Trade-offs diff --git a/openspec/changes/add-postgres-agent-tool/proposal.md b/openspec/changes/add-postgres-agent-tool/proposal.md index 8b0d753..21392a1 100644 --- a/openspec/changes/add-postgres-agent-tool/proposal.md +++ b/openspec/changes/add-postgres-agent-tool/proposal.md @@ -34,5 +34,5 @@ - Новая конфигурационная схема для именованных PostgreSQL-сессий и интеграция с общими правилами разрешения конфигурации и редактирования секретов. - Новая PostgreSQL-клиентская зависимость, пул/управление соединениями, нормализация типов и безопасное форматирование результатов. - Новые команды или tool-интерфейс для обзора схемы, запросов, сравнения данных и диагностики. -- Новый агентский skill `skills/pgc/SKILL.md`, синхронизированный с CLI-командами и ограничениями безопасности. +- Новый агентский skill `skills/postgres-cli/SKILL.md`, синхронизированный с CLI-командами и ограничениями безопасности. - Изменения не должны требовать миграций в подключаемых базах данных и не должны изменять данные в read-only режиме. diff --git a/openspec/changes/add-postgres-agent-tool/tasks.md b/openspec/changes/add-postgres-agent-tool/tasks.md index 140365a..b4c67f7 100644 --- a/openspec/changes/add-postgres-agent-tool/tasks.md +++ b/openspec/changes/add-postgres-agent-tool/tasks.md @@ -35,7 +35,7 @@ ## 6. Agent-facing documentation and release readiness -- [x] 6.1 Add `skills/pgc/SKILL.md` following the existing companion-skill format; document `pgc --json doctor`, named sessions, secret handling, schema overview/search/detail/relationships, bounded read-only queries, two-query comparison with key columns, and output formats; verify examples contain no real credentials and match the CLI help. +- [x] 6.1 Add `skills/postgres-cli/SKILL.md` following the existing companion-skill format; document `pgc --json doctor`, named sessions, secret handling, schema overview/search/detail/relationships, bounded read-only queries, two-query comparison with key columns, and output formats; verify examples contain no real credentials and match the CLI help. - [x] 6.2 Document named session configuration, secret handling, read-only guarantees, progressive schema workflow, query limits, and comparison examples in the package README; verify examples contain no real credentials and match the CLI help. - [x] 6.3 Add a changeset describing the new public PostgreSQL CLI package and verify the release metadata includes the package without versioning private core packages. - [x] 6.4 Add integration/packaging coverage for the new workspace package and run `pnpm check`; verify lint, all unit tests, and self-contained dry-run packaging pass across the monorepo. diff --git a/skills/pgc/SKILL.md b/skills/postgres-cli/SKILL.md similarity index 100% rename from skills/pgc/SKILL.md rename to skills/postgres-cli/SKILL.md