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
2 changes: 1 addition & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@cipherstash/wizard": "workspace:*"
},
"devDependencies": {
"@types/semver": "7.7.1",
"@types/semver": "7.8.0",
"semver": "^7.8.0",
"typescript": "catalog:repo",
"vitest": "catalog:repo",
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@
"test:scripts": "vitest run --config scripts/vitest.config.mjs"
},
"devDependencies": {
"@biomejs/biome": "^2.5.3",
"@changesets/cli": "^2.31.0",
"@biomejs/biome": "^2.5.9",
"@changesets/cli": "^2.31.1",
"@types/node": "^22.20.1",
"js-yaml": "^4.3.1",
"rimraf": "^6.1.3",
"turbo": "2.10.4",
"turbo": "2.10.10",
"vitest": "catalog:repo"
},
"packageManager": "pnpm@10.33.2",
Expand Down
59 changes: 31 additions & 28 deletions packages/cli/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,34 +54,37 @@ describe('loadStashConfig', () => {
it.each([
['stash', `Cannot find module 'stash'`],
['@cipherstash/stack', `Cannot find package '@cipherstash/stack'`],
])('translates a missing `%s` module into actionable guidance (#579)', async (pkg, message) => {
fs.writeFileSync(
path.join(tmpDir, 'stash.config.ts'),
`import 'stash'\nexport default {}`,
)
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const moduleErr = Object.assign(new Error(message), {
code: 'MODULE_NOT_FOUND',
})
const { createJiti } = await import('jiti')
vi.mocked(createJiti).mockReturnValue({
import: vi.fn().mockRejectedValue(moduleErr),
} as never)

const { loadStashConfig } = await import('@/config/index.ts')
await expect(loadStashConfig()).rejects.toThrow('process.exit')

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain(`\`${pkg}\` is not installed`)
expect(output).toContain('stash init')
// The raw jiti stack trace must NOT be forwarded to the user.
expect(output).not.toContain('Failed to load')
})
])(
'translates a missing `%s` module into actionable guidance (#579)',
async (pkg, message) => {
fs.writeFileSync(
path.join(tmpDir, 'stash.config.ts'),
`import 'stash'\nexport default {}`,
)
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const moduleErr = Object.assign(new Error(message), {
code: 'MODULE_NOT_FOUND',
})
const { createJiti } = await import('jiti')
vi.mocked(createJiti).mockReturnValue({
import: vi.fn().mockRejectedValue(moduleErr),
} as never)

const { loadStashConfig } = await import('@/config/index.ts')
await expect(loadStashConfig()).rejects.toThrow('process.exit')

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain(`\`${pkg}\` is not installed`)
expect(output).toContain('stash init')
// The raw jiti stack trace must NOT be forwarded to the user.
expect(output).not.toContain('Failed to load')
},
)

it('still surfaces the raw error for unrelated config load failures', async () => {
fs.writeFileSync(path.join(tmpDir, 'stash.config.ts'), 'export default {}')
Expand Down
36 changes: 17 additions & 19 deletions packages/cli/src/__tests__/database-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,25 +258,23 @@ describe('resolveDatabaseUrl — prompt source', () => {
})

describe('resolveDatabaseUrl — CI guard', () => {
it.each([
'true',
'TRUE',
'1',
' true ',
])('does not prompt and exits 1 when CI=%j (truthy)', async (ciValue) => {
process.env.CI = ciValue
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit')
}) as never)
await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit')
expect(exitSpy).toHaveBeenCalledWith(1)
expect(clack.text).not.toHaveBeenCalled()
expect(clack.log.error).toHaveBeenCalledWith(messages.db.urlMissingCi)
})
it.each(['true', 'TRUE', '1', ' true '])(
'does not prompt and exits 1 when CI=%j (truthy)',
async (ciValue) => {
process.env.CI = ciValue
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit')
}) as never)
await expect(resolveDatabaseUrl()).rejects.toThrow('process.exit')
expect(exitSpy).toHaveBeenCalledWith(1)
expect(clack.text).not.toHaveBeenCalled()
expect(clack.log.error).toHaveBeenCalledWith(messages.db.urlMissingCi)
},
)

it('does not prompt when stdin is not a TTY (e.g. piped)', async () => {
Object.defineProperty(process.stdin, 'isTTY', {
Expand Down
51 changes: 25 additions & 26 deletions packages/cli/src/__tests__/release-train.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,32 +93,31 @@ describe('release train coverage', () => {
expect(skillFiles.length).toBeGreaterThan(0)
})

it.each(
skillFiles,
)('$skill pins release-train packages at a stable version on the current major', ({
body,
}) => {
// Exact pins only (`pkg@1.2.3`, optionally `npm:`-prefixed and with a
// trailing subpath). A range spec (`^1.0.0`) carries its own semantics
// and is not a pin to check.
const PIN =
/(?:npm:)?(stash|@cipherstash\/[a-z0-9-]+)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g

for (const [, pkg, pinned] of body.matchAll(PIN)) {
if (!isReleaseTrainPackage(pkg)) continue
const current = manifestVersion(pkg)

expect(
pinned,
`${pkg}@${pinned} pins a prerelease — skills ship to customers, so pin the stable release (${stableVersion(current)})`,
).not.toContain('-')

expect(
pinned.split('.')[0],
`${pkg}@${pinned} is off this release line (${current}) — update the pin and the prose around it`,
).toBe(stableVersion(current).split('.')[0])
}
})
it.each(skillFiles)(
'$skill pins release-train packages at a stable version on the current major',
({ body }) => {
// Exact pins only (`pkg@1.2.3`, optionally `npm:`-prefixed and with a
// trailing subpath). A range spec (`^1.0.0`) carries its own semantics
// and is not a pin to check.
const PIN =
/(?:npm:)?(stash|@cipherstash\/[a-z0-9-]+)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g

for (const [, pkg, pinned] of body.matchAll(PIN)) {
if (!isReleaseTrainPackage(pkg)) continue
const current = manifestVersion(pkg)

expect(
pinned,
`${pkg}@${pinned} pins a prerelease — skills ship to customers, so pin the stable release (${stableVersion(current)})`,
).not.toContain('-')

expect(
pinned.split('.')[0],
`${pkg}@${pinned} is off this release line (${current}) — update the pin and the prose around it`,
).toBe(stableVersion(current).split('.')[0])
}
},
)
})

it('every train manifest exists and carries a version (what tsup will embed)', () => {
Expand Down
111 changes: 59 additions & 52 deletions packages/cli/src/__tests__/rewrite-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,26 +238,26 @@ describe('rewriteEncryptedAlterColumns', () => {
// DOMAIN_RE is derived from ENCRYPTED_DOMAIN, so drift between the two can't
// silently leave a domain unrewritten. Prove every domain the alternation
// recognises is actually extracted into the emitted ADD COLUMN.
it.each([
...V3_DOMAINS,
'eql_v2_encrypted',
])('extracts the bare domain %s from a mangled ALTER', async (domain) => {
declarePlaintext('"t"', 'c')
const filePath = path.join(tmpDir, '0015_drift.sql')
fs.writeFileSync(
filePath,
`ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`,
)
it.each([...V3_DOMAINS, 'eql_v2_encrypted'])(
'extracts the bare domain %s from a mangled ALTER',
async (domain) => {
declarePlaintext('"t"', 'c')
const filePath = path.join(tmpDir, '0015_drift.sql')
fs.writeFileSync(
filePath,
`ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`,
)

const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)

expect(rewritten).toEqual([filePath])
const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
`ALTER TABLE "t" ADD COLUMN "c_encrypted" "public"."${domain}";`,
)
expect(updated).not.toContain('SET DATA TYPE')
})
expect(rewritten).toEqual([filePath])
const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
`ALTER TABLE "t" ADD COLUMN "c_encrypted" "public"."${domain}";`,
)
expect(updated).not.toContain('SET DATA TYPE')
},
)

// The mangled forms are the cross product of what `dataType()` returns and
// which drizzle-kit era renders it. Verified against drizzle-kit 0.24.2,
Expand Down Expand Up @@ -312,22 +312,25 @@ describe('rewriteEncryptedAlterColumns', () => {
'dotted inside "undefined", drizzle-kit >=0.31.0',
'"undefined"."public.eql_v2_encrypted"',
],
])('rewrites the previously unmatched v2 %s form', async (_label, emitted) => {
declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0009_v2form.sql')
fs.writeFileSync(
filePath,
`ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`,
)
])(
'rewrites the previously unmatched v2 %s form',
async (_label, emitted) => {
declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0009_v2form.sql')
fs.writeFileSync(
filePath,
`ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`,
)

await rewriteEncryptedAlterColumns(tmpDir)
await rewriteEncryptedAlterColumns(tmpDir)

const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
'ALTER TABLE "users" ADD COLUMN "email_encrypted" "public"."eql_v2_encrypted";',
)
expect(updated).not.toContain('SET DATA TYPE')
})
const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
'ALTER TABLE "users" ADD COLUMN "email_encrypted" "public"."eql_v2_encrypted";',
)
expect(updated).not.toContain('SET DATA TYPE')
},
)

it('names the target domain in the guidance comment', async () => {
declarePlaintext('"users"', 'email')
Expand Down Expand Up @@ -925,26 +928,30 @@ describe('rewriteEncryptedAlterColumns', () => {
it.each([
['tagged', 'price$usd$cents'],
['untagged', 'price$$cents'],
])('does not treat a %s dollar delimiter inside an unquoted identifier as a dollar-quoted body', async (_kind, identifier) => {
declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, `0033_${_kind}-identifier.sql`)
fs.writeFileSync(
filePath,
[
`SELECT ${identifier} FROM "prices";`,
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
'',
].join('\n'),
)

const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)

expect(rewritten).toEqual([filePath])
expect(skipped).toEqual([])
const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain('ADD COLUMN "email_encrypted"')
expect(updated).not.toContain('SET DATA TYPE')
})
])(
'does not treat a %s dollar delimiter inside an unquoted identifier as a dollar-quoted body',
async (_kind, identifier) => {
declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, `0033_${_kind}-identifier.sql`)
fs.writeFileSync(
filePath,
[
`SELECT ${identifier} FROM "prices";`,
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
'',
].join('\n'),
)

const { rewritten, skipped } =
await rewriteEncryptedAlterColumns(tmpDir)

expect(rewritten).toEqual([filePath])
expect(skipped).toEqual([])
const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain('ADD COLUMN "email_encrypted"')
expect(updated).not.toContain('SET DATA TYPE')
},
)
})

/**
Expand Down
Loading
Loading