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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-doctor-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/cli": minor
---

Add `stash doctor` command — a single read-only diagnostic that checks the health of a CipherStash install across project state, config, auth, environment, database, and ORM integration. Prints a categorised human report by default, or `--json` for a stable machine-readable shape; `--only <category>` narrows the run and `--skip-db` avoids DB-opening checks. `--fix` is reserved for a follow-up PR.
10 changes: 8 additions & 2 deletions packages/cli/src/__tests__/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ describe('EQLInstaller', () => {
switch (queryCall) {
// pg_roles query — not superuser
case 1:
return { rows: [{ rolsuper: false, rolcreatedb: false }], rowCount: 1 }
return {
rows: [{ rolsuper: false, rolcreatedb: false }],
rowCount: 1,
}
// has_database_privilege — no CREATE
case 2:
return { rows: [{ has_create: false }], rowCount: 1 }
Expand Down Expand Up @@ -130,7 +133,10 @@ describe('EQLInstaller', () => {
expect(mockQuery).toHaveBeenCalledWith('BEGIN')
// The second query should be the bundled SQL (a large string)
const sqlCall = mockQuery.mock.calls.find(
(call: string[]) => typeof call[0] === 'string' && call[0] !== 'BEGIN' && call[0] !== 'COMMIT',
(call: string[]) =>
typeof call[0] === 'string' &&
call[0] !== 'BEGIN' &&
call[0] !== 'COMMIT',
)
expect(sqlCall).toBeDefined()
expect(sqlCall[0]).toContain('eql_v2')
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Commands:
init Initialize CipherStash for your project
auth <subcommand> Authenticate with CipherStash
wizard AI-powered encryption setup (reads your codebase)
doctor Diagnose install issues across project, config, auth, env, and database

db install Scaffold stash.config.ts (if missing) and install EQL extensions
db upgrade Upgrade EQL extensions to the latest version
Expand Down Expand Up @@ -91,6 +92,14 @@ DB Flags:
--exclude-operator-family (install, upgrade, validate) Skip operator family creation
--latest (install, upgrade) Fetch the latest EQL from GitHub

Doctor Flags:
--json Emit a JSON report (suppresses interactive output)
--verbose Show cause chains for failing checks
--skip-db Skip checks that open a DB connection
--only <category> Only run one category: project, config, auth, env, database, integration (comma-separated)
--fix (reserved — not implemented yet)
--yes (reserved — not implemented yet)

Examples:
npx @cipherstash/cli init
npx @cipherstash/cli init --supabase
Expand Down Expand Up @@ -198,6 +207,24 @@ async function runDbCommand(
}
}

async function runDoctorCommand(
flags: Record<string, boolean>,
values: Record<string, string>,
) {
// Lazy-load so a broken project (e.g. missing @cipherstash/stack) still
// reaches the doctor command rather than tripping on a top-level import.
const { runDoctor, parseDoctorFlags } = await import(
'../commands/doctor/index.js'
)
const parsed = parseDoctorFlags(flags, values)
const code = await runDoctor({
flags: parsed,
cwd: process.cwd(),
cliVersion: pkg.version,
})
if (code !== 0) process.exit(code)
}

async function runSchemaCommand(
sub: string | undefined,
flags: Record<string, boolean>,
Expand Down Expand Up @@ -255,6 +282,9 @@ async function main() {
case 'db':
await runDbCommand(subcommand, flags, values)
break
case 'doctor':
await runDoctorCommand(flags, values)
break
case 'schema':
await runSchemaCommand(subcommand, flags)
break
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/db/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export async function statusCommand() {
p.log.success(`EQL installed: yes (version: ${version ?? 'unknown'})`)
} else {
s.stop('EQL is not installed.')
p.log.warn('EQL is not installed. Run `npx @cipherstash/cli db install` to install it.')
p.log.warn(
'EQL is not installed. Run `npx @cipherstash/cli db install` to install it.',
)
p.outro('Status check complete.')
return
}
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/commands/db/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ interface ValidationIssue {
}

/** Cast-as types that are not string-like — free-text search is meaningless for these. */
const NON_STRING_CAST_TYPES = new Set(['int', 'small_int', 'big_int', 'real', 'double', 'boolean', 'date', 'number', 'bigint'])
const NON_STRING_CAST_TYPES = new Set([
'int',
'small_int',
'big_int',
'real',
'double',
'boolean',
'date',
'number',
'bigint',
])

/**
* Validate an EncryptConfig against common misconfiguration rules.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { CredentialsResult } from '@/lib/auth-state.js'
import { describe, expect, it } from 'vitest'
import { authAuthenticated } from '../../checks/auth/authenticated.js'
import type { CheckContext, TokenInfo } from '../../types.js'

function ctxWith(
result: CredentialsResult & { token?: TokenInfo },
): CheckContext {
return {
cwd: '/tmp/p',
cliVersion: '0',
flags: {
json: false,
fix: false,
yes: false,
verbose: false,
skipDb: false,
only: [],
},
cache: {
cwd: '/tmp/p',
packageJson: () => undefined,
stashConfig: async () => ({ ok: false, reason: 'not-found' }),
encryptClient: async () => ({ ok: false, reason: 'no-config' }),
token: async () => result,
integration: () => undefined,
hasTypeScript: () => false,
},
}
}

describe('auth.authenticated', () => {
it('passes with the token workspace surfaced in message', async () => {
const ctx = ctxWith({
ok: true,
token: {
workspaceId: 'WS123',
subject: 'CS|user',
issuer: 'https://cts.example',
services: {},
},
})
const result = await authAuthenticated.run(ctx)
expect(result.status).toBe('pass')
expect(result.message).toContain('WS123')
})

it('fails with login hint when not authenticated', async () => {
const ctx = ctxWith({ ok: false, code: 'NOT_AUTHENTICATED' })
const result = await authAuthenticated.run(ctx)
expect(result.status).toBe('fail')
expect(result.fixHint).toContain('stash auth login')
expect(result.message).toBe('Not authenticated')
})

it('surfaces unknown auth codes in the failure message', async () => {
const ctx = ctxWith({
ok: false,
code: 'REQUEST_ERROR',
cause: new Error('timeout'),
})
const result = await authAuthenticated.run(ctx)
expect(result.status).toBe('fail')
expect(result.message).toContain('REQUEST_ERROR')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { CredentialsResult } from '@/lib/auth-state.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { authWorkspaceIdMatchesConfig } from '../../checks/auth/workspace-id-matches-config.js'
import type { CheckContext, TokenInfo } from '../../types.js'

function ctxWith(token: TokenInfo | undefined): CheckContext {
const tokenResult: CredentialsResult & { token?: TokenInfo } = token
? { ok: true, token }
: { ok: false }
return {
cwd: '/tmp/p',
cliVersion: '0',
flags: {
json: false,
fix: false,
yes: false,
verbose: false,
skipDb: false,
only: [],
},
cache: {
cwd: '/tmp/p',
packageJson: () => undefined,
stashConfig: async () => ({ ok: false, reason: 'not-found' }),
encryptClient: async () => ({ ok: false, reason: 'no-config' }),
token: async () => tokenResult,
integration: () => undefined,
hasTypeScript: () => false,
},
}
}

const TOKEN: TokenInfo = {
workspaceId: 'ABC123',
subject: 'CS|user',
issuer: 'https://cts.example',
services: {},
}

describe('auth.workspace-id-matches-config', () => {
let original: string | undefined

beforeEach(() => {
original = process.env.CS_WORKSPACE_CRN
})
afterEach(() => {
if (original === undefined) delete process.env.CS_WORKSPACE_CRN
else process.env.CS_WORKSPACE_CRN = original
})

it('passes (skipped message) when CS_WORKSPACE_CRN is not set', async () => {
delete process.env.CS_WORKSPACE_CRN
const result = await authWorkspaceIdMatchesConfig.run(ctxWith(TOKEN))
expect(result.status).toBe('pass')
expect(result.message).toContain('CS_WORKSPACE_CRN not set')
})

it('passes when the token workspace matches the CRN', async () => {
process.env.CS_WORKSPACE_CRN = 'crn:aws-eu-central-1:ABC123'
expect(
(await authWorkspaceIdMatchesConfig.run(ctxWith(TOKEN))).status,
).toBe('pass')
})

it('fails when workspaces differ', async () => {
process.env.CS_WORKSPACE_CRN = 'crn:aws-eu-central-1:OTHER'
const result = await authWorkspaceIdMatchesConfig.run(ctxWith(TOKEN))
expect(result.status).toBe('fail')
expect(result.message).toContain('ABC123')
expect(result.message).toContain('OTHER')
})

it('fails when CRN is malformed', async () => {
process.env.CS_WORKSPACE_CRN = 'not-a-crn'
const result = await authWorkspaceIdMatchesConfig.run(ctxWith(TOKEN))
expect(result.status).toBe('fail')
expect(result.message).toContain('valid CRN')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { configEncryptionClientLoadable } from '../../checks/config/encryption-client-loadable.js'
import type { CheckContext, EncryptClientLoadResult } from '../../types.js'

function ctxWith(result: EncryptClientLoadResult): CheckContext {
return {
cwd: '/tmp/p',
cliVersion: '0',
flags: {
json: false,
fix: false,
yes: false,
verbose: false,
skipDb: false,
only: [],
},
cache: {
cwd: '/tmp/p',
packageJson: () => undefined,
stashConfig: async () => ({ ok: false, reason: 'not-found' }),
encryptClient: async () => result,
token: async () => ({ ok: false }),
integration: () => undefined,
hasTypeScript: () => false,
},
}
}

describe('config.encryption-client-loadable', () => {
it('passes when the module loaded with a valid client export', async () => {
const ctx = ctxWith({
ok: true,
resolvedPath: '/tmp/enc.ts',
config: { databaseUrl: 'x', client: './enc.ts' },
tableCount: 1,
})
const result = await configEncryptionClientLoadable.run(ctx)
expect(result.status).toBe('pass')
})

it('skips when the config is missing', async () => {
const ctx = ctxWith({ ok: false, reason: 'no-config' })
expect((await configEncryptionClientLoadable.run(ctx)).status).toBe('skip')
})

it('skips when the file is missing (covered by earlier check)', async () => {
const ctx = ctxWith({
ok: false,
reason: 'file-missing',
resolvedPath: '/tmp/nope.ts',
})
expect((await configEncryptionClientLoadable.run(ctx)).status).toBe('skip')
})

it('fails when the import threw', async () => {
const ctx = ctxWith({
ok: false,
reason: 'import-failed',
resolvedPath: '/tmp/enc.ts',
cause: new Error('syntax'),
})
const result = await configEncryptionClientLoadable.run(ctx)
expect(result.status).toBe('fail')
expect(result.cause).toBeInstanceOf(Error)
})

it('fails when no EncryptionClient export found', async () => {
const ctx = ctxWith({
ok: false,
reason: 'no-export',
resolvedPath: '/tmp/enc.ts',
})
const result = await configEncryptionClientLoadable.run(ctx)
expect(result.status).toBe('fail')
expect(result.fixHint).toContain('getEncryptConfig')
})
})
Loading
Loading