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
6 changes: 6 additions & 0 deletions .changeset/package-manager-aware-runner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cipherstash/cli": patch
"@cipherstash/wizard": patch
---

Show and execute commands using the detected package manager's runner (`npx` / `bunx` / `pnpm dlx` / `yarn dlx`) instead of always emitting `npx`. A user who runs `bunx @cipherstash/cli init` now sees a "Next Steps" panel that suggests `bunx @cipherstash/cli db install` and `bunx @cipherstash/wizard`, and the wizard's post-agent step both displays and shells out to `bunx @cipherstash/cli db push` (was: `Failed: npx @cipherstash/cli db push`). Wizard prerequisite messages and AI-agent error hints (e.g. on a 401, `Run: bunx @cipherstash/cli auth login`) follow the same rule. Detection sources are unchanged: `npm_config_user_agent` first, then lockfile, then `npx` fallback.
40 changes: 40 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,43 @@ jobs:
# deps declared on the `test:e2e` task are honored.
- name: Run CLI E2E tests
run: pnpm exec turbo run test:e2e --filter @cipherstash/cli

e2e-tests:
name: Run E2E Tests
runs-on: blacksmith-4vcpu-ubuntu-2404

# Auth-dependent suites in `e2e/` skip themselves unless these env vars
# are set. We expose them at the job level so the wizard subprocess
# picks them up via `process.env`.
env:
CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }}
CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }}
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
CS_ZEROKMS_HOST: https://ap-southeast-2.aws.zerokms.cipherstashmanaged.net
CS_CTS_HOST: https://ap-southeast-2.aws.cts.cipherstashmanaged.net

steps:
- name: Checkout Repo
uses: actions/checkout@v3

- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false

- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

# Run the standalone `e2e/` workspace via turbo so the `^build`
# dep on the `test:e2e` task builds cli + wizard first. CLI's own
# E2E (`packages/cli/tests/e2e/**`) is covered by the `run-tests`
# job above; we filter to the new workspace here to avoid duplication.
- name: Run E2E tests
run: pnpm exec turbo run test:e2e --filter @cipherstash/e2e
41 changes: 41 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# `@cipherstash/e2e`

End-to-end tests that exercise built CipherStash binaries and cross-package behaviour. Lives outside `packages/` because these tests are not tied to a single package — they verify how the published artefacts behave when a user actually runs them.

## Running

From the repo root:

```bash
pnpm run test:e2e
```

This delegates to turbo, which builds dependent packages first and then runs `vitest run` inside this workspace.

To run a single test file:

```bash
pnpm --filter @cipherstash/e2e exec vitest run tests/package-managers.e2e.test.ts
```

## What's covered

| Test file | Scope |
| --- | --- |
| `tests/package-managers.e2e.test.ts` | The `init` providers and the wizard binary render `bunx`/`pnpm dlx`/`yarn dlx`/`npx` based on detected package manager. |

## Auth-dependent suites

Some tests spawn the wizard binary, which runs an auth check before reaching the prerequisite path under test. These are wrapped in `describe.skipIf(!authConfigured)` and only run when:

- `~/.cipherstash/auth.json` exists (typical local dev), **or**
- `CS_CLIENT_ID` and `CS_CLIENT_KEY` are set in the environment (CI with secrets wired)

The CI job for this workspace exposes those env vars from repo secrets. Without them the wizard suite is skipped (the provider suite still runs).

## Adding a new e2e test

- File name must end in `.e2e.test.ts` to be picked up by `vitest.config.ts`.
- Prefer spawning the **built** binary (`packages/<pkg>/dist/bin/...`) over importing source — that's the value e2e gives over unit tests. If the binary isn't built when your test runs, fail fast with a clear message; turbo's `test:e2e` task declares `^build` + `build` deps so a top-level `pnpm run test:e2e` will build first.
- For tests that need a clean cwd, use `mkdtempSync(join(tmpdir(), 'stash-...-e2e-'))` and clean up in `afterEach`.
- Mock nothing. If you find yourself wanting to mock, the test belongs in a unit suite.
17 changes: 17 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@cipherstash/e2e",
"version": "0.0.0",
"private": true,
"description": "End-to-end tests that exercise built CipherStash binaries and cross-package behaviour.",
"type": "module",
"scripts": {
"test:e2e": "vitest run"
},
"dependencies": {
"@cipherstash/cli": "workspace:*",
"@cipherstash/wizard": "workspace:*"
},
"devDependencies": {
"vitest": "catalog:repo"
}
}
182 changes: 182 additions & 0 deletions e2e/tests/package-managers.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'

import { createBaseProvider } from '../../packages/cli/src/commands/init/providers/base.js'
import { createDrizzleProvider } from '../../packages/cli/src/commands/init/providers/drizzle.js'
import { createSupabaseProvider } from '../../packages/cli/src/commands/init/providers/supabase.js'
import type { PackageManager } from '../../packages/cli/src/commands/init/utils.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(__dirname, '../..')
const WIZARD_BIN = resolve(REPO_ROOT, 'packages/wizard/dist/bin/wizard.js')

const PMS: PackageManager[] = ['npm', 'bun', 'pnpm', 'yarn']
const RUNNER: Record<PackageManager, string> = {
npm: 'npx',
bun: 'bunx',
pnpm: 'pnpm dlx',
yarn: 'yarn dlx',
}

// Suite A — pure-function rendering of "Next Steps" via the CLI's init
// providers. Imports source so we exercise the production code path
// without needing the binary to be built.
describe('CLI init providers — package-manager-aware Next Steps', () => {
const cases: Array<{
label: string
create: () => ReturnType<typeof createBaseProvider>
firstStep: (runner: string) => string
}> = [
{
label: 'base',
create: createBaseProvider,
firstStep: (r) =>
`Set up your database: ${r} @cipherstash/cli db install`,
},
{
label: 'drizzle',
create: createDrizzleProvider,
firstStep: (r) =>
`Set up your database: ${r} @cipherstash/cli db install --drizzle`,
},
{
label: 'supabase',
create: createSupabaseProvider,
firstStep: (r) =>
`Install EQL: ${r} @cipherstash/cli db install --supabase (prompts for migration vs direct)`,
},
]

for (const { label, create, firstStep } of cases) {
for (const pm of PMS) {
it(`${label} provider renders ${RUNNER[pm]} for pm=${pm}`, () => {
const steps = create().getNextSteps({}, pm)
expect(steps[0]).toBe(firstStep(RUNNER[pm]))
// The wizard hint should also use the right runner.
expect(steps.find((s) => s.includes('@cipherstash/wizard'))).toContain(
`${RUNNER[pm]} @cipherstash/wizard`,
)
// No accidental npx leakage when the runner isn't npx.
if (RUNNER[pm] !== 'npx') {
for (const s of steps) expect(s).not.toMatch(/\bnpx\b/)
}
})
}
}
})

// Suite B — runs the BUILT wizard binary in throwaway sandbox dirs and
// asserts the runner-aware "Run: ..." line in the prerequisites output.
//
// Requires the user to be authenticated (the wizard's auth check runs
// before the prereq check). Skipped when no auth is configured locally
// and no auth env vars are present in the runner environment. The CI
// job exposes auth secrets explicitly to keep this assertion live.
const authConfigured = (() => {
if (process.env.CS_CLIENT_ID && process.env.CS_CLIENT_KEY) return true
const home = process.env.HOME
if (!home) return false
return existsSync(join(home, '.cipherstash', 'auth.json'))
})()

describe.skipIf(!authConfigured)(
'wizard binary — package-manager-aware prerequisites',
() => {
let sandbox: string

beforeAll(() => {
// The binary must be built — wizard's build is fast (~16ms tsup esbuild).
// Caller is expected to run it; surface a clear error if absent.
if (!existsSync(WIZARD_BIN)) {
throw new Error(
`Wizard binary not found at ${WIZARD_BIN}. Run \`pnpm --filter @cipherstash/wizard build\` first (turbo's test:e2e task does this automatically).`,
)
}
})

beforeEach(() => {
sandbox = mkdtempSync(join(tmpdir(), 'stash-pm-e2e-'))
})

afterEach(() => {
rmSync(sandbox, { recursive: true, force: true })
})

function runWizard(opts: {
lockfile?: string
userAgent?: string
}): string {
if (opts.lockfile) writeFileSync(join(sandbox, opts.lockfile), '')
try {
return execFileSync(process.execPath, [WIZARD_BIN], {
cwd: sandbox,
env: {
...process.env,
npm_config_user_agent: opts.userAgent ?? '',
},
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
})
} catch (err) {
// Wizard exits non-zero when prereqs are missing — that's the path
// we're testing. Surface the captured stdout/stderr from the error.
const e = err as NodeJS.ErrnoException & {
stdout?: Buffer | string
stderr?: Buffer | string
}
return [e.stdout?.toString() ?? '', e.stderr?.toString() ?? ''].join(
'\n',
)
}
}

describe('lockfile-driven detection', () => {
it.each([
{ pm: 'bun' as const, lockfile: 'bun.lock' },
{ pm: 'pnpm' as const, lockfile: 'pnpm-lock.yaml' },
{ pm: 'yarn' as const, lockfile: 'yarn.lock' },
])('uses $pm runner when $lockfile is present', ({ pm, lockfile }) => {
const out = runWizard({ lockfile })
expect(out).toContain(`Run: ${RUNNER[pm]} @cipherstash/cli db install`)
})

it('falls back to npx when no lockfile and no user agent', () => {
const out = runWizard({})
expect(out).toContain('Run: npx @cipherstash/cli db install')
})
})

describe('user-agent driven detection', () => {
it.each([
{ pm: 'bun' as const, userAgent: 'bun/1.1.40 npm/? node/v22.3.0' },
{ pm: 'pnpm' as const, userAgent: 'pnpm/9.0.0 npm/? node/v20.0.0' },
{ pm: 'yarn' as const, userAgent: 'yarn/4.0.0 npm/? node/v20.0.0' },
])('uses $pm runner when UA is $userAgent', ({ pm, userAgent }) => {
const out = runWizard({ userAgent })
expect(out).toContain(`Run: ${RUNNER[pm]} @cipherstash/cli db install`)
})
})

describe('precedence', () => {
it('non-npm user agent wins over a mismatched lockfile', () => {
const out = runWizard({
lockfile: 'pnpm-lock.yaml',
userAgent: 'bun/1.1.40 npm/? node/v22.3.0',
})
expect(out).toContain('Run: bunx @cipherstash/cli db install')
})

it('npm user agent is ignored in favour of a lockfile', () => {
const out = runWizard({
lockfile: 'bun.lock',
userAgent: 'npm/10.2.4 node/v20.0.0',
})
expect(out).toContain('Run: bunx @cipherstash/cli db install')
})
})
},
)
4 changes: 4 additions & 0 deletions e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["tests/**/*.ts", "vitest.config.ts"]
}
14 changes: 14 additions & 0 deletions e2e/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'

// E2E tests spawn child processes (built binaries) and may hit the network.
// Use the forks pool so each test gets a clean process; longer timeouts to
// accommodate subprocess startup + I/O.
export default defineConfig({
test: {
globals: true,
include: ['tests/**/*.e2e.test.ts'],
pool: 'forks',
testTimeout: 30_000,
hookTimeout: 30_000,
},
})
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules",
"code:fix": "biome check --write",
"release": "pnpm run build && changeset publish",
"test": "turbo test --filter './packages/*'"
"test": "turbo test --filter './packages/*'",
"test:e2e": "turbo run test:e2e"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/init/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
detectPackageManager,
devInstallCommand,
prodInstallCommand,
runnerCommand,
} from '../utils.js'

describe('detectPackageManager', () => {
Expand Down Expand Up @@ -133,3 +134,35 @@ describe('devInstallCommand', () => {
)
})
})

describe('runnerCommand', () => {
it('returns npx for npm', () => {
expect(runnerCommand('npm', '@cipherstash/cli')).toBe(
'npx @cipherstash/cli',
)
})

it('returns bunx for bun', () => {
expect(runnerCommand('bun', '@cipherstash/cli')).toBe(
'bunx @cipherstash/cli',
)
})

it('returns pnpm dlx for pnpm', () => {
expect(runnerCommand('pnpm', '@cipherstash/cli')).toBe(
'pnpm dlx @cipherstash/cli',
)
})

it('returns yarn dlx for yarn', () => {
expect(runnerCommand('yarn', '@cipherstash/cli')).toBe(
'yarn dlx @cipherstash/cli',
)
})

it('passes the package reference through verbatim (multi-word args allowed)', () => {
expect(runnerCommand('bun', '@cipherstash/cli db install')).toBe(
'bunx @cipherstash/cli db install',
)
})
})
Loading
Loading