Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,20 @@ Init Flags:
--supabase Use Supabase-specific setup flow
--drizzle Use Drizzle-specific setup flow

Plan Flags:
--complete-rollout Plan the entire encryption lifecycle (schema-add through drop)
in one document. Skips the production-deploy gate that
normally separates rollout from cutover. Only safe when this
database is not backing a deployed application (local dev,
sandbox, freshly seeded test environment).

Status Flags:
--quest Force the quest-log output (emoji + progress bars)
even in non-TTY contexts. Default is auto: fancy
in a terminal, plain in CI / pipes / agents.
--plain Force the plain-text output even in TTY contexts.
--json Emit a structured JSON document instead.

Impl Flags:
--continue-without-plan Skip planning and go straight to implementation
(interactively confirms before proceeding)
Expand Down Expand Up @@ -382,13 +396,17 @@ async function main() {
await initCommand(flags)
break
case 'plan':
await planCommand()
await planCommand(flags)
break
case 'impl':
await implCommand(flags)
break
case 'status':
await statusCommand()
await statusCommand({
quest: flags.quest,
plain: flags.plain,
json: flags.json,
})
Comment on lines +399 to +409
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add an E2E test for the new CLI flag wiring

Line 399 and Lines 405-409 change top-level CLI dispatch behavior; this needs an accompanying E2E test to lock argv/command behavior and exit semantics.

As per coding guidelines, packages/cli/src/bin/stash.ts: “Add an E2E test when touching src/bin/stash.ts argv parsing, exit codes, or top-level error handling.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/bin/stash.ts` around lines 399 - 409, Add an end-to-end test
that exercises the top-level CLI dispatch in stash.ts to lock argv parsing and
exit behavior: spawn the CLI binary (or invoke the bin entry) with the commands
'plan', 'impl', and 'status' using flags that cover flags.quest, flags.plain,
and flags.json, assert the invoked handlers (planCommand, implCommand,
statusCommand) run with expected args, and verify correct process exit codes and
JSON/plain output semantics; ensure the test fails if unknown flags or incorrect
wiring change command selection or exit status.

break
case 'auth': {
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
Expand Down
114 changes: 114 additions & 0 deletions packages/cli/src/commands/encrypt/lib/db-readers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { latestByColumn } from '@cipherstash/migrate'
import type pg from 'pg'

/**
* `latestByColumn` from `@cipherstash/migrate`, but tolerant of the
* pre-install case where `cipherstash.cs_migrations` doesn't exist.
* The encryption-rollout commands all need to be readable on a fresh
* project; treating "table missing" as "no events" keeps them so.
*/
export async function latestByColumnSafe(
client: pg.ClientBase,
): Promise<ReturnType<typeof latestByColumn> extends Promise<infer T> ? T : never> {
try {
return (await latestByColumn(client)) as Awaited<ReturnType<typeof latestByColumn>>
} catch (err) {
if (
err instanceof Error &&
/cs_migrations|schema "cipherstash"/i.test(err.message)
) {
return new Map() as Awaited<ReturnType<typeof latestByColumn>>
}
throw err
}
}

export interface EqlColumnInfo {
/** Index kinds attached to this column in the EQL config (`unique`,
* `match`, `ore`, `ste_vec`). Empty when no indexes are configured. */
indexes: string[]
/** Lifecycle state of the EQL config row this column belongs to. */
state: 'active' | 'pending' | 'encrypting'
}

/**
* Read every column registered in `eql_v2_configuration` (active,
* pending, or encrypting) keyed by `<table>.<column>`. Active rows win
* when a column appears in more than one state.
*
* The call is best-effort: if `eql_v2_configuration` doesn't exist yet
* (EQL not installed), an empty map is returned instead of throwing.
*/
export async function fetchActiveEqlConfig(
client: pg.ClientBase,
): Promise<Map<string, EqlColumnInfo>> {
const out = new Map<string, EqlColumnInfo>()
try {
const result = await client.query<{ state: string; data: unknown }>(
`SELECT state, data FROM public.eql_v2_configuration
WHERE state IN ('active', 'pending', 'encrypting')
ORDER BY CASE state WHEN 'active' THEN 0 WHEN 'encrypting' THEN 1 ELSE 2 END`,
)
for (const row of result.rows) {
const data = row.data as {
tables?: Record<
string,
Record<string, { indexes?: Record<string, unknown> }>
>
} | null
if (!data?.tables) continue
for (const [tableName, columns] of Object.entries(data.tables)) {
for (const [columnName, column] of Object.entries(columns)) {
const key = `${tableName}.${columnName}`
if (out.has(key)) continue
out.set(key, {
indexes: Object.keys(column.indexes ?? {}),
state: row.state as 'active' | 'pending' | 'encrypting',
})
}
}
}
} catch (err) {
if (err instanceof Error && /eql_v2_configuration/i.test(err.message)) {
return out
}
throw err
}
return out
}

/**
* Read `information_schema.columns` and group column names by table.
* When `tables` is provided the query is constrained to that set —
* status's quest log only ever needs ~5 specific tables, so passing
* the manifest's tables avoids a full-schema scan.
*/
export async function fetchPhysicalColumns(
client: pg.ClientBase,
tables?: ReadonlyArray<string>,
): Promise<Map<string, Set<string>>> {
const out = new Map<string, Set<string>>()
try {
const result =
tables === undefined
? await client.query<{ table_name: string; column_name: string }>(
`SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = current_schema()`,
)
: await client.query<{ table_name: string; column_name: string }>(
`SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = ANY($1::text[])`,
[tables],
)
for (const row of result.rows) {
const set = out.get(row.table_name) ?? new Set<string>()
set.add(row.column_name)
out.set(row.table_name, set)
}
} catch {
// information_schema is always present; failures here are surprising
// enough to swallow rather than crash the read-only status path.
}
return out
}
90 changes: 8 additions & 82 deletions packages/cli/src/commands/encrypt/status.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import {
type MigrationPhase,
latestByColumn,
readManifest,
} from '@cipherstash/migrate'
import { type MigrationPhase, readManifest } from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
import {
type EqlColumnInfo,
fetchActiveEqlConfig,
fetchPhysicalColumns,
latestByColumnSafe,
} from './lib/db-readers.js'

interface Row {
table: string
Expand Down Expand Up @@ -53,7 +55,7 @@ export async function statusCommand() {
if (manifest) {
for (const [tableName, columns] of Object.entries(manifest.tables)) {
for (const column of columns) {
const key = `${tableName}.${column.column}`
const key: `${string}.${string}` = `${tableName}.${column.column}`
seen.add(key)
rows.push(
renderRow({
Expand Down Expand Up @@ -110,82 +112,6 @@ export async function statusCommand() {
if (exitCode) process.exit(exitCode)
}

async function latestByColumnSafe(client: pg.Client) {
try {
return await latestByColumn(client)
} catch (err) {
if (
err instanceof Error &&
/cs_migrations|schema "cipherstash"/i.test(err.message)
) {
return new Map()
}
throw err
}
}

interface EqlColumnInfo {
indexes: string[]
state: 'active' | 'pending' | 'encrypting'
}

async function fetchActiveEqlConfig(
client: pg.Client,
): Promise<Map<string, EqlColumnInfo>> {
const out = new Map<string, EqlColumnInfo>()
try {
const result = await client.query<{ state: string; data: unknown }>(
`SELECT state, data FROM public.eql_v2_configuration
WHERE state IN ('active', 'pending', 'encrypting')
ORDER BY CASE state WHEN 'active' THEN 0 WHEN 'encrypting' THEN 1 ELSE 2 END`,
)
for (const row of result.rows) {
const data = row.data as {
tables?: Record<
string,
Record<string, { indexes?: Record<string, unknown> }>
>
} | null
if (!data?.tables) continue
for (const [tableName, columns] of Object.entries(data.tables)) {
for (const [columnName, column] of Object.entries(columns)) {
const key = `${tableName}.${columnName}`
if (out.has(key)) continue
out.set(key, {
indexes: Object.keys(column.indexes ?? {}),
state: row.state as 'active' | 'pending' | 'encrypting',
})
}
}
}
} catch (err) {
if (err instanceof Error && /eql_v2_configuration/i.test(err.message)) {
return out
}
throw err
}
return out
}

async function fetchPhysicalColumns(
client: pg.Client,
): Promise<Map<string, Set<string>>> {
const out = new Map<string, Set<string>>()
const result = await client.query<{
table_name: string
column_name: string
}>(
`SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = current_schema()`,
)
for (const row of result.rows) {
const set = out.get(row.table_name) ?? new Set<string>()
set.add(row.column_name)
out.set(row.table_name, set)
}
return out
}

function renderRow(input: {
tableName: string
columnName: string
Expand Down
Loading