Skip to content

feat(cli): onboarding fixes batch for launch (CIP-2982…CIP-3001)#349

Merged
calvinbrewer merged 1 commit intomainfrom
claude/blissful-wu-b957fc
Apr 17, 2026
Merged

feat(cli): onboarding fixes batch for launch (CIP-2982…CIP-3001)#349
calvinbrewer merged 1 commit intomainfrom
claude/blissful-wu-b957fc

Conversation

@calvinbrewer
Copy link
Copy Markdown
Contributor

@calvinbrewer calvinbrewer commented Apr 17, 2026

Summary

Addresses 16 Linear issues filed after the 2026-04-17 end-to-end onboarding test. Scope: packages/cli and packages/stack only.

Closes (in-scope)

  • CIP-2982, CIP-2983, CIP-2984, CIP-2985, CIP-2986, CIP-2987, CIP-2988, CIP-2989, CIP-2990, CIP-2991, CIP-2992, CIP-2993, CIP-2994, CIP-2995, CIP-2997, CIP-3001

Deferred (tracked, out of scope) — CIP-2998 (Supabase OAuth), CIP-2999 (analytics research), CIP-3000 (QA matrix).

Blocked upstream — CIP-2996. @cipherstash/auth@0.35.0 is the latest on npm; TODO(CIP-2996) marker added in login.ts. Bump the catalog pin once a fixed release ships.

What changed

CLI command consolidation

  • db setup removed entirely. db install now scaffolds stash.config.ts on the fly when it's missing, so onboarding is one command.
  • db install auto-detects Supabase (from DATABASE_URL host) and Drizzle (from drizzle.config.* or drizzle-orm/drizzle-kit in package.json). --supabase / --drizzle stay as explicit overrides.
  • Non-superuser roles auto-fall back to the no-operator-family (OPE) install variant instead of aborting — unblocks Supabase/Neon/RDS onboarding.
  • --supabase now threads through the --drizzle migration path (previously silently dropped).
  • db install prints a "what next" note pointing at the wizard and direct SDK usage.

Drizzle migration correctness

  • encryptedType emits "public"."eql_v2_encrypted" so generated migrations no longer contain "undefined"."eql_v2_encrypted".
  • Generated ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted statements are automatically rewritten to an ADD + UPDATE-placeholder + DROP + RENAME sequence. Safe on empty tables; non-empty tables need backfill via encryptModel — the comment in the rewritten SQL explains this. Rewrite lives at packages/cli/src/commands/db/rewrite-migrations.ts and runs in both the CLI drizzle path and the wizard's post-agent step.

Wizard enhancements

  • Prompts to copy integration-appropriate Claude skills into ./.claude/skills/ (new install-skills.ts). Skills are bundled into the CLI at build time via tsup.config.ts.
  • Appends a timestamped markdown log to .cipherstash/wizard-log.md on each run (new changelog.ts).
  • Scans src/app, src/lib, app/, lib/ for Drizzle/Supabase insert/update/select call sites on encrypted tables and prints a report with recommended encryptModel/decryptModel patterns (new wire-call-sites.ts). Report-only — no file mutations.

CLI runtime polish

  • init streams npm/pnpm/yarn output instead of hiding it behind a silent spinner.
  • CLI loads .env.local.env.development.local.env.development.env (Next.js precedence, first-win).
  • "Forge" branding removed from user-facing CLI strings.

New experimental command

  • env command scaffold registered, gated behind STASH_EXPERIMENTAL_ENV_CMD=1. CTS mint endpoint is TBD; command returns a clear "not ready" message until then.

Files

Modified: bin/stash.ts, commands/db/install.ts, commands/auth/login.ts, commands/index.ts, commands/init/providers/{base,drizzle,supabase}.ts, commands/init/steps/install-forge.ts, commands/wizard/{run.ts,lib/post-agent.ts,lib/prerequisites.ts}, installer/index.ts, tsup.config.ts, packages/cli/README.md, packages/stack/src/drizzle/index.ts.

New: commands/db/{config-scaffold,detect,rewrite-migrations}.ts, commands/env/index.ts, commands/wizard/lib/{changelog,install-skills,wire-call-sites}.ts, plus tests __tests__/detect.test.ts and __tests__/rewrite-migrations.test.ts.

Deleted: commands/db/setup.ts.

Test plan

  • pnpm -C packages/cli build — clean
  • pnpm -C packages/stack build — clean
  • pnpm -C packages/cli test — 124 pass / 5 skipped (20 new tests covering detect + rewrite-migrations)
  • Biome clean on all 19 touched/new files
  • --help output verified: no db setup, no secrets subcommand, no "Forge"
  • Manual smoke: fresh Next.js + Drizzle + Supabase project — set DATABASE_URL in .env.local only, run npx @cipherstash/cli db install without flags, verify auto-detection, OPE fallback, next-steps note, and absence of "undefined"."eql_v2_encrypted" in the generated migration
  • Manual smoke: run wizard end-to-end — verify .cipherstash/wizard-log.md is written, skills prompt fires, call-site report lists src/app/... entries
  • Companion docs PR in cipherstash/docs (drafted but not opened yet — forge/cli/ rename + behavior prose updates)

Notes for the reviewer

  • The 18 stack integration tests that fail in CI without a CTS token fail the same way on main — they're not regressions from this PR.
  • packages/cli/src/commands/auth/login.ts has a TODO(CIP-2996) marker explaining the upstream fix we're waiting on. Please don't patch around it in the CLI; the right fix is in @cipherstash/auth.
  • The migration rewrite emits a comment reminding users to backfill via encryptModel for non-empty tables. On empty tables it behaves like DROP + ADD.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added automatic detection of database providers and frameworks; installation flags are now optional when settings can be inferred from your project.
    • Introduced experimental env command for managing environment variables.
    • Enhanced wizard with call-site scanning to identify where encryption helpers are needed, and integrated Claude skill installation.
    • Added automatic configuration scaffolding workflow.
  • Documentation

    • Updated README to reflect simplified setup flow.
  • Tests

    • Added comprehensive test coverage for database and framework detection logic.

Closes or addresses 16 Linear tickets from the end-to-end onboarding test
(CIP-2982 … CIP-3001). Scope is `packages/cli` and `packages/stack`.

Highlights
- `db install` absorbs the old `db setup` scaffolding and is now the single
  canonical command. `db setup` is removed.
- `db install` auto-detects Supabase (from DATABASE_URL host) and Drizzle
  (from drizzle.config.* or package.json deps), so the `--supabase` and
  `--drizzle` flags default on when relevant.
- Non-superuser roles now auto-fall back to the no-operator-family (OPE)
  install variant instead of aborting, which unblocks Supabase/Neon/RDS
  onboarding.
- `--supabase` now threads through the `--drizzle` migration path (it was
  silently dropped before).
- Drizzle's `encryptedType` emits `"public"."eql_v2_encrypted"` so
  generated migrations no longer contain `"undefined"."eql_v2_encrypted"`.
- Drizzle migrations that use `ALTER COLUMN ... SET DATA TYPE` (which
  fails because there's no implicit cast) are automatically rewritten to
  ADD + UPDATE placeholder + DROP + RENAME — safe on empty tables, with
  a comment reminding users to backfill via encryptModel for non-empty
  tables. Rewrite runs in both the CLI drizzle path and the wizard's
  post-agent step.
- Wizard now: prompts to install Claude skills into `./.claude/skills/`,
  writes a timestamped markdown log to `.cipherstash/wizard-log.md`, and
  scans src/app, src/lib, app/, lib/ for encrypt/decrypt call sites
  (report-only, no file mutations).
- `init` streams npm/pnpm/yarn output during package install instead of
  hiding it behind a silent spinner.
- CLI loads `.env.local` before `.env` (Next.js precedence).
- `db install` prints a "what next" block pointing at the wizard and
  direct SDK usage.
- Forge branding removed from user-facing CLI strings.
- Experimental `env` command scaffold gated behind
  `STASH_EXPERIMENTAL_ENV_CMD=1`. The CTS mint endpoint is TBD; command
  returns a clear "not ready" message until then.

Known-issue markers
- CIP-2996 (auth login profile dir mismatch) is upstream in
  `@cipherstash/auth`. TODO marker added in login.ts; bump the catalog
  pin once a fixed release ships.

Tests
- 20 new tests: `detect.test.ts` (detectSupabase/detectDrizzle) and
  `rewrite-migrations.test.ts` (ALTER COLUMN rewrite cases).
- Full CLI suite: 124 pass / 5 skipped.
- Stack tests unchanged: `drizzle/index.ts` edit verified via existing
  drizzle-operators suite. Auth-gated integration tests fail the same
  way on `main` without a CTS token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 17, 2026

⚠️ No Changeset found

Latest commit: a47a63e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The pull request refactors the CLI database setup workflow by removing the standalone db setup command and consolidating functionality into an expanded db install command. This includes introducing automatic provider detection (Supabase, Drizzle), on-demand configuration scaffolding, encrypted migration rewriting, and a new experimental env command. Supporting infrastructure is added for wizard enhancements (changelog tracking, Claude skills installation, and call-site scanning) and Drizzle type changes to use fully-qualified encrypted type names.

Changes

Cohort / File(s) Summary
README & Documentation
packages/cli/README.md, packages/cli/src/commands/init/providers/..., packages/cli/src/commands/wizard/lib/prerequisites.ts
Updated documentation and user-facing CLI instructions from db setup to db install, clarified auto-detection behavior for Drizzle and Supabase flags, and removed standalone setup section documentation.
Database Command Restructuring
packages/cli/src/commands/db/install.ts, packages/cli/src/commands/db/config-scaffold.ts, packages/cli/src/commands/db/detect.ts, packages/cli/src/commands/db/rewrite-migrations.ts, packages/cli/src/commands/db/setup.ts
Removed setup.ts entirely and refactored install flow to include automatic config scaffolding, provider detection (Supabase via URL patterns, Drizzle via config files/package.json), and post-generation migration rewriting for encrypted ALTER TABLE statements. Added three new helper modules.
CLI Wiring & Entry Point
packages/cli/src/bin/stash.ts, packages/cli/src/commands/index.ts
Updated dotenv loading to check multiple .env.* files in priority order, replaced setupCommand with envCommand imports, removed db setup subcommand branch, added new experimental env top-level command, and updated help text.
Environment Command
packages/cli/src/commands/env/index.ts
New experimental command that fetches production credentials and outputs/writes them as environment variables to .env.production.local when STASH_EXPERIMENTAL_ENV_CMD is enabled.
Installer & Type System
packages/cli/src/installer/index.ts, packages/stack/src/drizzle/index.ts
Updated downloadEqlSql API to accept provider options object including supabase flag and expose superuser detection status. Changed Drizzle encrypted type emission from bare eql_v2_encrypted to fully-qualified "public"."eql_v2_encrypted" format.
Wizard Enhancements
packages/cli/src/commands/wizard/lib/changelog.ts, packages/cli/src/commands/wizard/lib/install-skills.ts, packages/cli/src/commands/wizard/lib/wire-call-sites.ts, packages/cli/src/commands/wizard/lib/post-agent.ts, packages/cli/src/commands/wizard/run.ts
Added session changelog tracking, Claude skills installation, call-site scanning for remaining encryption wiring, and integrated these steps into wizard post-agent flow with comprehensive reporting.
Build & UI Updates
packages/cli/tsup.config.ts, packages/cli/src/commands/init/steps/install-forge.ts, packages/cli/src/commands/auth/login.ts
Bundled skills directory into CLI distribution, replaced spinner-based install UI with step-based logging with inherited output streams, and added upstream auth library issue documentation.
Test Coverage
packages/cli/src/__tests__/detect.test.ts, packages/cli/src/__tests__/rewrite-migrations.test.ts
Added comprehensive test suites for Supabase/Drizzle detection helpers and encrypted migration rewriting functionality, covering both success and edge-case scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

This diff involves substantial refactoring across multiple functional areas (CLI wiring, command restructuring, wizard flow), introduces several new modules with interdependencies, and modifies public APIs (installer options, Drizzle types). While many individual changes are straightforward, the heterogeneity of modifications across the setup/install flow, detection logic, migration rewriting, and wizard integration—combined with 10+ file modifications and 3 new modules—requires careful verification of control flow interactions, provider flag threading, and backward-compatibility implications.

Possibly related PRs

  • feat: consolidate cli tools into @cipherstash/cli #336: Directly overlaps in CLI module modifications (bin/stash.ts, db install, detect utilities, wizard integration, installer APIs) with shared code-level changes in command wiring and setup flow refactoring.
  • feat: improve cli dx #320: Related to CLI onboarding refactoring, client-path detection utilities, install-option resolution, and stash.config scaffolding logic.
  • chore: usability improvements #317: Overlaps in EQL installer surface modifications (downloadEqlSql options, supabase/excludeOperatorFamily handling, migration processing).

Suggested reviewers

  • coderdan
  • tobyhede
  • auxesis

Poem

🐰 The setup was long, now install's the way,
Auto-detecting what needs to play,
Drizzle and Supabase in the light,
Config scaffolds true, migrations rewrite,
Skills and changelog to guide the sight! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.72% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title references a specific batch of Linear issues (CIP-2982…CIP-3001) and clearly indicates this is an onboarding fixes batch for launch, which aligns with the substantial refactoring of the db setup/install flow, scaffolding, detection, and wizard improvements described in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/blissful-wu-b957fc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/cli/src/bin/stash.ts (1)

76-92: ⚠️ Potential issue | 🟡 Minor

env --write is wired but undocumented in HELP.

Line 262 reads flags.write, but the HELP text for the env command (line 76) and the flags block (lines 82–92) don't mention --write. Even though the command is experimental and currently returns "not ready", users exploring --help won't discover the flag. Consider adding a one-liner under the env entry.

Suggested help tweak
-  env                  (experimental) Print production env vars for deployment
+  env                  (experimental) Print production env vars for deployment
+                       Flags: --write (persist output to a file)

Also applies to: 261-263

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/bin/stash.ts` around lines 76 - 92, The help text for the
"env" command is missing documentation for the wired-but-undocumented --write
flag referenced by flags.write; update the "env" entry in the printed help and
the flags block to include a one-line description for --write (e.g., "--write   
(experimental) Write production env vars to .env or stdout"), making clear it's
experimental and currently "not ready", and ensure the help string near the env
command and the flags listing match the implementation that reads flags.write.
packages/cli/src/commands/db/install.ts (1)

84-113: ⚠️ Potential issue | 🟡 Minor

Auto-fallback triggers on !isSuperuser regardless of whether permissions are already sufficient.

The new branch at Line 90-94 only inspects permissions.isSuperuser — it doesn't also require !permissions.ok. That means any non-superuser role (including one that has been explicitly granted CREATE SCHEMA/CREATE TYPE/CREATE EXTENSION and would have succeeded under the full install prior to this PR) is now silently downgraded to the no-operator-family variant, producing a different on-disk schema than before.

This is probably fine for the stated Supabase/Neon/RDS onboarding case, but it's a subtle behavior change for self-hosted Postgres setups with pre-granted roles, and they only get a single-line spinner message announcing the switch. Two options worth considering:

  • Gate the fallback on !permissions.isSuperuser && !permissions.ok so users who already have the right grants keep the full install.
  • Keep the current behavior but log a clearer note (e.g. "installing OPE variant; pass --no-exclude-operator-family to force the full install") so the choice is discoverable.

Either way, a follow-up on the downgrade decision in the output would help future debugging since the permissions diagnostic block at Line 101-108 is skipped entirely in this branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/db/install.ts` around lines 84 - 113, The
auto-fallback to the no-operator-family install currently triggers on
!permissions.isSuperuser alone; change the condition so it only falls back when
the role is not a superuser AND the permissions are insufficient (i.e., require
!permissions.isSuperuser && !permissions.ok in the if that sets
excludeOperatorFamily), and update the s.stop message to make the downgrade
explicit and actionable (e.g., mention how to force the full install or pass
--no-exclude-operator-family); reference the existing symbols
excludeOperatorFamily, permissions.isSuperuser, permissions.ok,
resolved.supabase, options.excludeOperatorFamily, s.stop, and p.log/p.note so
the fix is made in the same block.
🧹 Nitpick comments (9)
packages/cli/src/commands/init/steps/install-forge.ts (1)

60-63: Minor: err.message duplicates noise now that stderr streams to the terminal.

With stdio: 'inherit', the package manager's stderr is already shown to the user. execSync's thrown err.message for a non-zero exit is typically a generic Command failed: <cmd> string (stderr is not captured), so logging it via p.log.error(message) mostly restates the command the user just saw on line 54. Consider dropping the second p.log.error(message) (or gating it to only print when the message contains information beyond Command failed:), keeping the ${packageName} installation failed line and the manual-install note.

♻️ Proposed tweak
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err)
-    p.log.error(`${packageName} installation failed`)
-    p.log.error(message)
+  } catch (err) {
+    p.log.error(`${packageName} installation failed`)
+    if (err instanceof Error && !/^Command failed:/.test(err.message)) {
+      p.log.error(err.message)
+    }
     p.note(`You can install it manually:\n  ${cmd}`, 'Manual Installation')
     return false
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/init/steps/install-forge.ts` around lines 60 - 63,
In the catch block in install-forge.ts (the catch handling execSync with stdio:
'inherit'), remove the redundant p.log.error(message) that reprints execSync's
generic "Command failed:" text; instead keep the existing
p.log.error(`${packageName} installation failed`) and the manual-install
guidance, or if you prefer to preserve extra info only log message when it
contains more than the generic prefix (e.g., guard by checking that message does
not startWith or include "Command failed:"). Ensure you update the catch that
references packageName and p.log.error accordingly.
packages/cli/src/commands/env/index.ts (1)

33-39: process.exit(1) inside try-less flow is fine, but consider p.cancel + return for consistency.

Other cancellation paths in this file use p.cancel(...) ; return (line 51–52). Using process.exit(1) here skips any future cleanup (e.g., p.outro, file handles) and makes the function harder to test. Not a blocker.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/env/index.ts` around lines 33 - 39, The code exits
the process directly when fetchProdCredentials() returns falsy; replace the
process.exit(1) path with the same cancellation pattern used elsewhere by
calling p.cancel(...) with a clear error message (e.g., same string passed to
p.log.error) and then return to allow cleanup and keep behavior consistent with
other cancellation paths around fetchProdCredentials/creds and p.log.error.
packages/cli/src/commands/db/config-scaffold.ts (2)

52-65: Redundant defaultValue/initialValue pairing.

In @clack/prompts, initialValue prefills the editor (so the user just presses Enter to accept), while defaultValue is only applied when the input is empty on submit. Setting both to the same string is redundant — initialValue alone covers the ergonomic path. Not a bug; just clutter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/db/config-scaffold.ts` around lines 52 - 65, The
p.text prompt for clientPath redundantly passes both defaultValue and
initialValue; remove the unnecessary defaultValue and keep initialValue (or vice
versa) to avoid duplicate configuration. Edit the p.text call that creates
clientPath in config-scaffold.ts (the clientPath constant / p.text({...}) block)
and delete the defaultValue property, leaving initialValue: detected ??
'./src/encryption/index.ts' (and retain message, placeholder, validate) so the
prompt behavior remains identical but the config is not duplicated.

71-79: Generated config uses non-null assertion on DATABASE_URL.

process.env.DATABASE_URL! silences TypeScript but does nothing at runtime — if the user hasn't set DATABASE_URL, downstream code receives undefined and will fail with a less helpful error than an explicit throw. Consider emitting a runtime check in the generated config.

♻️ Proposed fix
-  return `import { defineConfig } from '@cipherstash/cli'
+  return `import { defineConfig } from '@cipherstash/cli'
+
+if (!process.env.DATABASE_URL) {
+  throw new Error('DATABASE_URL is not set. Add it to your .env file.')
+}
 
 export default defineConfig({
-  databaseUrl: process.env.DATABASE_URL!,
+  databaseUrl: process.env.DATABASE_URL,
   client: '${clientPath}',
 })
 `
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/db/config-scaffold.ts` around lines 71 - 79, The
generated config in generateConfig currently uses process.env.DATABASE_URL!
which only silences TypeScript but doesn't guard at runtime; change the template
to perform a runtime check (e.g., if (!process.env.DATABASE_URL) throw new
Error(...)) before passing the value to defineConfig so the generated file
throws a clear, descriptive error when DATABASE_URL is unset while preserving
the client: '${clientPath}' and export default defineConfig(...) structure.
packages/cli/src/commands/wizard/lib/wire-call-sites.ts (1)

60-72: Use path.join instead of manual string concatenation.

absPath = \${cwd.replace(//$/, '')}/${relPath}`hard-codes forward slashes and won't handle Windows paths cleanly. Sinceglobalready yields paths relative tocwd, you can just use path.join(cwd, relPath)(or skip the absolute round-trip entirely and read viajoin(cwd, relPath)then reportrelPath` directly).

♻️ Proposed fix
-import { readFile } from 'node:fs/promises'
-import { glob } from 'node:fs/promises'
-import { relative } from 'node:path'
+import { glob, readFile } from 'node:fs/promises'
+import { join } from 'node:path'
@@
   for (const relPath of files) {
-    const absPath = `${cwd.replace(/\/$/, '')}/${relPath}`
+    const absPath = join(cwd, relPath)
     let text: string
     try {
       text = await readFile(absPath, 'utf-8')
     } catch {
       continue
     }
 
     const matches = findMatches(text, tables, integration)
     for (const m of matches) {
-      results.push({ ...m, file: relative(cwd, absPath) })
+      results.push({ ...m, file: relPath })
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/wizard/lib/wire-call-sites.ts` around lines 60 -
72, The code builds absPath with string concatenation which breaks on Windows;
replace the manual path assembly for each relPath by using path.join(cwd,
relPath) (or skip making an absolute path entirely and call
readFile(path.join(cwd, relPath), 'utf-8') or readFile(relPath, 'utf-8') if glob
already yields correct relative paths) in the loop where absPath is defined;
update the usages in the try/catch that call readFile(absPath, ...) and the push
to results (you can keep reporting file: relPath or continue to use
relative(cwd, absPath) if you keep the absPath variable) and ensure path is
imported where wire-call-sites.ts defines findMatches, readFile, results, cwd,
files, and relative.
packages/cli/src/commands/db/detect.ts (1)

22-26: Nit: .pooler.supabase.com is already covered by .supabase.com.

Any hostname ending in .pooler.supabase.com also ends in .supabase.com, so the third branch is dead. Not a bug — just redundant. Keep it if you want the call site to read as explicit intent; otherwise drop it.

Optional simplification
-  return (
-    host.endsWith('.supabase.co') ||
-    host.endsWith('.supabase.com') ||
-    host.endsWith('.pooler.supabase.com')
-  )
+  return (
+    host.endsWith('.supabase.co') ||
+    host.endsWith('.supabase.com')
+  )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/db/detect.ts` around lines 22 - 26, The conditional
that returns whether a host is Supabase is redundant because
host.endsWith('.pooler.supabase.com') is already matched by
host.endsWith('.supabase.com'); remove the third branch (the
endsWith('.pooler.supabase.com') check) from the return expression so it becomes
just the two checks for '.supabase.co' and '.supabase.com', leaving the rest of
the function and the host variable usage unchanged.
packages/cli/src/bin/stash.ts (1)

3-10: Minor: "Next.js precedence" isn't exactly Next.js — .env.development is loaded unconditionally.

dotenv's non-overwrite default makes the .env.local.env winner ordering correct, so runtime behavior is fine. However, Next.js only loads .env.development[.local] when NODE_ENV === 'development' (and .env.production[.local] in production). Here those files load regardless of NODE_ENV, so a user with NODE_ENV=production will still pick up .env.development values. Either gate the dev-specific files on NODE_ENV or soften the comment to "Next.js-like precedence" to avoid surprising users running the CLI in prod contexts.

Suggested NODE_ENV-aware load
-// Load env files in Next.js precedence order. dotenv's default behavior is to
-// not overwrite vars that are already set, so loading .env.local first means
-// its values win over .env for the same keys. Users can still set anything in
-// the real environment to override both.
-config({ path: '.env.local' })
-config({ path: '.env.development.local' })
-config({ path: '.env.development' })
-config({ path: '.env' })
+// Load env files in Next.js precedence order. dotenv's default behavior is to
+// not overwrite vars that are already set, so loading .env.local first means
+// its values win over .env for the same keys. Users can still set anything in
+// the real environment to override both.
+const nodeEnv = process.env.NODE_ENV ?? 'development'
+config({ path: '.env.local' })
+config({ path: `.env.${nodeEnv}.local` })
+config({ path: `.env.${nodeEnv}` })
+config({ path: '.env' })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/bin/stash.ts` around lines 3 - 10, The comment and loading
are misleading because .env.development(.local) are always loaded; either gate
those two config calls on NODE_ENV === 'development' or change the comment to
"Next.js-like precedence" to avoid implying exact Next.js behavior. Locate the
four config(...) calls (config({ path: '.env.local' }), config({ path:
'.env.development.local' }), config({ path: '.env.development' }), config({
path: '.env' })) and implement one of two fixes: wrap the .env.development.local
and .env.development config(...) calls in a conditional check for
process.env.NODE_ENV === 'development', or update the preceding comment text to
"Next.js-like precedence" so it does not claim exact Next.js behavior.
packages/stack/src/drizzle/index.ts (1)

176-189: Back-compat detection looks correct — no current inconsistency with the parallel impl, but note a potential future maintenance issue.

This file now recognizes both the bare eql_v2_encrypted and the new fully-qualified "public"."eql_v2_encrypted" forms for back-compat. The parallel implementation in packages/drizzle/src/pg/index.ts currently emits only the bare form in its dataType() method and detects only the bare form in getEncryptedColumnConfig().

However, if packages/drizzle is updated to emit the schema-qualified form to match this file's new behavior, its detection path will silently miss encrypted columns without the same back-compat branch applied here. Consider synchronizing both implementations or document this dependency for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack/src/drizzle/index.ts` around lines 176 - 189, The parallel
implementation's detection must match this file's handling of both bare and
schema-qualified encrypted type names: update the other module's dataType() and
getEncryptedColumnConfig() logic to also accept the fully-qualified form (e.g.,
"\"public\".\"eql_v2_encrypted\"") in addition to the bare "eql_v2_encrypted",
or add a clear comment documenting the dependency; specifically, extend its
isEncryptedTypeString-like check (or the functions that currently only match
'eql_v2_encrypted') to also return true when given the quoted schema-qualified
string, ensuring the functions named dataType and getEncryptedColumnConfig (or
their equivalents) will detect encrypted columns consistently with
isEncryptedTypeString/isEncrypted here.
packages/cli/src/commands/db/install.ts (1)

172-178: detectDrizzle(process.cwd()) hard-codes the working directory.

resolveProviderOptions takes no cwd parameter and calls process.cwd() directly. Elsewhere in the CLI (e.g. the wizard) cwd is threaded through as an explicit option so invocations and tests can override it. Consider passing cwd in alongside databaseUrl for consistency and easier unit testing of the resolver.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/db/install.ts` around lines 172 - 178, The code
hard-codes process.cwd() into detectDrizzle; change the call site so it uses a
passed-in cwd (e.g. options.cwd or the same cwd used for databaseUrl) and thread
that cwd into resolveProviderOptions instead of letting those functions call
process.cwd() directly; update detectDrizzle usage in the db install flow (the
block using options.drizzle and detectDrizzle) to call detectDrizzle(cwd) and
adjust resolveProviderOptions signature/usage to accept a cwd parameter so tests
and alternative invocations can override the working directory.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/src/commands/db/install.ts`:
- Around line 22-32: Update the JSDoc for the excludeOperatorFamily option to
stop referencing detectSupabase and instead state that undefined triggers an
auto-fallback based on the superuser check in installCommand
(permissions.isSuperuser); mention that an explicit true/false from the user is
preserved and skips the superuser-driven detection so readers look at
installCommand and permissions.isSuperuser for the fallback logic.

In `@packages/cli/src/commands/env/index.ts`:
- Around line 43-60: The file write currently saves production secrets with
default permissions via writeFileSync(target, block, 'utf-8'); change this so
the created .env.production.local is restricted (owner read/write only). Update
the write path around the writeFileSync call for target (or immediately after)
to set file mode to 0o600 (either by passing an options object with mode: 0o600
to writeFileSync or calling fs.chmodSync(target, 0o600)); ensure the success log
(p.log.success) still runs only after the permission change succeeds.

In `@packages/cli/src/commands/wizard/lib/changelog.ts`:
- Around line 35-57: The flush() method currently appends the entire
this.entries every call, causing duplicate log content; modify the
implementation to track which entries have already been written (e.g., add a
flushedIndex or clear/advance this.entries) and change render to accept an
optional fromIndex parameter (render(fromIndex = 0)) so flush() only renders and
writes entries[fromIndex..end]; ensure the first write still emits the "## Run"
header but subsequent writes skip that header, update flush() to call
render(flushedIndex) and advance flushedIndex after successful write so flush()
becomes idempotent.

In `@packages/cli/src/commands/wizard/lib/install-skills.ts`:
- Around line 42-62: Detect existing skill directories before copying by
checking each destination path (dest = join(destRoot, name)) with
existsSync/statSync to build two lists: existing (would be overwritten) and new
(would be added); update the confirmation prompt (the p.confirm call that
references available) to mention counts/names of existing vs new (e.g.,
"Overwrite N existing skill(s) and install M new one(s)?") and if the user
declines, return []; then during the copy loop (where cpSync is called) either
skip copying for destinations in the existing list unless the user opted into
overwriting, or honor an explicit overwrite choice and keep cpSync({ recursive:
true, force: true }) only when overwriting; ensure p.log.warn is kept for
per-skill failures.

In `@packages/cli/src/commands/wizard/lib/post-agent.ts`:
- Around line 108-133: rewriteEncryptedMigrations currently returns as soon as
it finds the first existing DRIZZLE_OUT_DIRS candidate, which can skip the real
migrations dir; change the control flow in rewriteEncryptedMigrations so it only
returns (exits) when rewriteEncryptedAlterColumns(abs) actually rewrote files
(rewritten.length > 0), and otherwise continues the loop to try the next
candidate; likewise, on catch do not return immediately—log the error and
continue scanning other DRIZZLE_OUT_DIRS; keep references to DRIZZLE_OUT_DIRS,
rewriteEncryptedAlterColumns, existsSync and the p.log calls when making the
change.

In `@packages/cli/src/commands/wizard/lib/wire-call-sites.ts`:
- Around line 106-122: The loop over lines can push multiple entries when a
single line matches multiple regexes (e.g., drizzleInsert and drizzleSelect);
update the logic in the lines.forEach block to pick a single kind per line
(prefer insert, then update, then select) or dedupe pushes by tracking seen
keys. Concretely, replace the multiple if checks that call out.push with a
single decision per line (e.g., compute let kind = drizzleInsert.test(line) ?
'insert' : drizzleUpdate.test(line) ? 'update' : drizzleSelect.test(line) ?
'select' : undefined and push only if kind) or maintain a Set of `${i}-${kind}`
to avoid duplicate out.push calls for the same (line, kind); apply the same
approach for the supabase branch using
supabaseFromInsert/supabaseFromUpdate/supabaseFromSelect.

---

Outside diff comments:
In `@packages/cli/src/bin/stash.ts`:
- Around line 76-92: The help text for the "env" command is missing
documentation for the wired-but-undocumented --write flag referenced by
flags.write; update the "env" entry in the printed help and the flags block to
include a one-line description for --write (e.g., "--write     (experimental)
Write production env vars to .env or stdout"), making clear it's experimental
and currently "not ready", and ensure the help string near the env command and
the flags listing match the implementation that reads flags.write.

In `@packages/cli/src/commands/db/install.ts`:
- Around line 84-113: The auto-fallback to the no-operator-family install
currently triggers on !permissions.isSuperuser alone; change the condition so it
only falls back when the role is not a superuser AND the permissions are
insufficient (i.e., require !permissions.isSuperuser && !permissions.ok in the
if that sets excludeOperatorFamily), and update the s.stop message to make the
downgrade explicit and actionable (e.g., mention how to force the full install
or pass --no-exclude-operator-family); reference the existing symbols
excludeOperatorFamily, permissions.isSuperuser, permissions.ok,
resolved.supabase, options.excludeOperatorFamily, s.stop, and p.log/p.note so
the fix is made in the same block.

---

Nitpick comments:
In `@packages/cli/src/bin/stash.ts`:
- Around line 3-10: The comment and loading are misleading because
.env.development(.local) are always loaded; either gate those two config calls
on NODE_ENV === 'development' or change the comment to "Next.js-like precedence"
to avoid implying exact Next.js behavior. Locate the four config(...) calls
(config({ path: '.env.local' }), config({ path: '.env.development.local' }),
config({ path: '.env.development' }), config({ path: '.env' })) and implement
one of two fixes: wrap the .env.development.local and .env.development
config(...) calls in a conditional check for process.env.NODE_ENV ===
'development', or update the preceding comment text to "Next.js-like precedence"
so it does not claim exact Next.js behavior.

In `@packages/cli/src/commands/db/config-scaffold.ts`:
- Around line 52-65: The p.text prompt for clientPath redundantly passes both
defaultValue and initialValue; remove the unnecessary defaultValue and keep
initialValue (or vice versa) to avoid duplicate configuration. Edit the p.text
call that creates clientPath in config-scaffold.ts (the clientPath constant /
p.text({...}) block) and delete the defaultValue property, leaving initialValue:
detected ?? './src/encryption/index.ts' (and retain message, placeholder,
validate) so the prompt behavior remains identical but the config is not
duplicated.
- Around line 71-79: The generated config in generateConfig currently uses
process.env.DATABASE_URL! which only silences TypeScript but doesn't guard at
runtime; change the template to perform a runtime check (e.g., if
(!process.env.DATABASE_URL) throw new Error(...)) before passing the value to
defineConfig so the generated file throws a clear, descriptive error when
DATABASE_URL is unset while preserving the client: '${clientPath}' and export
default defineConfig(...) structure.

In `@packages/cli/src/commands/db/detect.ts`:
- Around line 22-26: The conditional that returns whether a host is Supabase is
redundant because host.endsWith('.pooler.supabase.com') is already matched by
host.endsWith('.supabase.com'); remove the third branch (the
endsWith('.pooler.supabase.com') check) from the return expression so it becomes
just the two checks for '.supabase.co' and '.supabase.com', leaving the rest of
the function and the host variable usage unchanged.

In `@packages/cli/src/commands/db/install.ts`:
- Around line 172-178: The code hard-codes process.cwd() into detectDrizzle;
change the call site so it uses a passed-in cwd (e.g. options.cwd or the same
cwd used for databaseUrl) and thread that cwd into resolveProviderOptions
instead of letting those functions call process.cwd() directly; update
detectDrizzle usage in the db install flow (the block using options.drizzle and
detectDrizzle) to call detectDrizzle(cwd) and adjust resolveProviderOptions
signature/usage to accept a cwd parameter so tests and alternative invocations
can override the working directory.

In `@packages/cli/src/commands/env/index.ts`:
- Around line 33-39: The code exits the process directly when
fetchProdCredentials() returns falsy; replace the process.exit(1) path with the
same cancellation pattern used elsewhere by calling p.cancel(...) with a clear
error message (e.g., same string passed to p.log.error) and then return to allow
cleanup and keep behavior consistent with other cancellation paths around
fetchProdCredentials/creds and p.log.error.

In `@packages/cli/src/commands/init/steps/install-forge.ts`:
- Around line 60-63: In the catch block in install-forge.ts (the catch handling
execSync with stdio: 'inherit'), remove the redundant p.log.error(message) that
reprints execSync's generic "Command failed:" text; instead keep the existing
p.log.error(`${packageName} installation failed`) and the manual-install
guidance, or if you prefer to preserve extra info only log message when it
contains more than the generic prefix (e.g., guard by checking that message does
not startWith or include "Command failed:"). Ensure you update the catch that
references packageName and p.log.error accordingly.

In `@packages/cli/src/commands/wizard/lib/wire-call-sites.ts`:
- Around line 60-72: The code builds absPath with string concatenation which
breaks on Windows; replace the manual path assembly for each relPath by using
path.join(cwd, relPath) (or skip making an absolute path entirely and call
readFile(path.join(cwd, relPath), 'utf-8') or readFile(relPath, 'utf-8') if glob
already yields correct relative paths) in the loop where absPath is defined;
update the usages in the try/catch that call readFile(absPath, ...) and the push
to results (you can keep reporting file: relPath or continue to use
relative(cwd, absPath) if you keep the absPath variable) and ensure path is
imported where wire-call-sites.ts defines findMatches, readFile, results, cwd,
files, and relative.

In `@packages/stack/src/drizzle/index.ts`:
- Around line 176-189: The parallel implementation's detection must match this
file's handling of both bare and schema-qualified encrypted type names: update
the other module's dataType() and getEncryptedColumnConfig() logic to also
accept the fully-qualified form (e.g., "\"public\".\"eql_v2_encrypted\"") in
addition to the bare "eql_v2_encrypted", or add a clear comment documenting the
dependency; specifically, extend its isEncryptedTypeString-like check (or the
functions that currently only match 'eql_v2_encrypted') to also return true when
given the quoted schema-qualified string, ensuring the functions named dataType
and getEncryptedColumnConfig (or their equivalents) will detect encrypted
columns consistently with isEncryptedTypeString/isEncrypted here.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b56241f4-ba82-4e59-a423-0c76e0004353

📥 Commits

Reviewing files that changed from the base of the PR and between 530625f and a47a63e.

📒 Files selected for processing (25)
  • packages/cli/README.md
  • packages/cli/src/__tests__/detect.test.ts
  • packages/cli/src/__tests__/rewrite-migrations.test.ts
  • packages/cli/src/bin/stash.ts
  • packages/cli/src/commands/auth/login.ts
  • packages/cli/src/commands/db/config-scaffold.ts
  • packages/cli/src/commands/db/detect.ts
  • packages/cli/src/commands/db/install.ts
  • packages/cli/src/commands/db/rewrite-migrations.ts
  • packages/cli/src/commands/db/setup.ts
  • packages/cli/src/commands/env/index.ts
  • packages/cli/src/commands/index.ts
  • packages/cli/src/commands/init/providers/base.ts
  • packages/cli/src/commands/init/providers/drizzle.ts
  • packages/cli/src/commands/init/providers/supabase.ts
  • packages/cli/src/commands/init/steps/install-forge.ts
  • packages/cli/src/commands/wizard/lib/changelog.ts
  • packages/cli/src/commands/wizard/lib/install-skills.ts
  • packages/cli/src/commands/wizard/lib/post-agent.ts
  • packages/cli/src/commands/wizard/lib/prerequisites.ts
  • packages/cli/src/commands/wizard/lib/wire-call-sites.ts
  • packages/cli/src/commands/wizard/run.ts
  • packages/cli/src/installer/index.ts
  • packages/cli/tsup.config.ts
  • packages/stack/src/drizzle/index.ts
💤 Files with no reviewable changes (1)
  • packages/cli/src/commands/db/setup.ts

Comment on lines +22 to +32
/**
* `undefined` means "auto-detect" (via {@link detectSupabase}). An explicit
* `true`/`false` from the user is preserved and skips detection.
*/
excludeOperatorFamily?: boolean
supabase?: boolean
drizzle?: boolean
latest?: boolean
name?: string
out?: string
}) {
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

JSdoc for excludeOperatorFamily is misleading.

The comment says undefined means "auto-detect (via {@link detectSupabase})", but detectSupabase only controls the supabase flag — excludeOperatorFamily's auto-fallback is driven by the permissions.isSuperuser check inside installCommand (Line 89-99), not by Supabase detection. Consider rewording to reference the superuser fallback so readers aren't sent looking for a Supabase→OPE link that doesn't exist.

✏️ Suggested tweak
-  /**
-   * `undefined` means "auto-detect" (via {`@link` detectSupabase}). An explicit
-   * `true`/`false` from the user is preserved and skips detection.
-   */
-  excludeOperatorFamily?: boolean
+  /**
+   * `undefined` means "auto-fall back to OPE when the connected role is not a
+   * superuser" (CIP-2989). An explicit `true`/`false` from the user is
+   * preserved and skips the auto-fallback.
+   */
+  excludeOperatorFamily?: boolean
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* `undefined` means "auto-detect" (via {@link detectSupabase}). An explicit
* `true`/`false` from the user is preserved and skips detection.
*/
excludeOperatorFamily?: boolean
supabase?: boolean
drizzle?: boolean
latest?: boolean
name?: string
out?: string
}) {
}
/**
* `undefined` means "auto-fall back to OPE when the connected role is not a
* superuser" (CIP-2989). An explicit `true`/`false` from the user is
* preserved and skips the auto-fallback.
*/
excludeOperatorFamily?: boolean
supabase?: boolean
drizzle?: boolean
latest?: boolean
name?: string
out?: string
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/db/install.ts` around lines 22 - 32, Update the
JSDoc for the excludeOperatorFamily option to stop referencing detectSupabase
and instead state that undefined triggers an auto-fallback based on the
superuser check in installCommand (permissions.isSuperuser); mention that an
explicit true/false from the user is preserved and skips the superuser-driven
detection so readers look at installCommand and permissions.isSuperuser for the
fallback logic.

Comment on lines +43 to +60
if (options.write) {
const target = resolve(process.cwd(), '.env.production.local')
if (existsSync(target)) {
const overwrite = await p.confirm({
message: `${target} already exists. Overwrite?`,
initialValue: false,
})
if (p.isCancel(overwrite) || !overwrite) {
p.cancel('Aborted.')
return
}
}

writeFileSync(target, block, 'utf-8')
p.log.success(`Wrote ${target}`)
p.outro('Done!')
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Restrict permissions on the written .env.production.local file.

writeFileSync(target, block, 'utf-8') writes production credentials (CS_CLIENT_KEY) with the default umask (typically 0o644 — world-readable). Once the CTS mint endpoint is wired up, this file will contain a live secret, and leaving it group/world-readable on shared dev machines or CI runners is a secrets leak waiting to happen.

🔒 Proposed fix
-    writeFileSync(target, block, 'utf-8')
+    writeFileSync(target, block, { encoding: 'utf-8', mode: 0o600 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (options.write) {
const target = resolve(process.cwd(), '.env.production.local')
if (existsSync(target)) {
const overwrite = await p.confirm({
message: `${target} already exists. Overwrite?`,
initialValue: false,
})
if (p.isCancel(overwrite) || !overwrite) {
p.cancel('Aborted.')
return
}
}
writeFileSync(target, block, 'utf-8')
p.log.success(`Wrote ${target}`)
p.outro('Done!')
return
}
if (options.write) {
const target = resolve(process.cwd(), '.env.production.local')
if (existsSync(target)) {
const overwrite = await p.confirm({
message: `${target} already exists. Overwrite?`,
initialValue: false,
})
if (p.isCancel(overwrite) || !overwrite) {
p.cancel('Aborted.')
return
}
}
writeFileSync(target, block, { encoding: 'utf-8', mode: 0o600 })
p.log.success(`Wrote ${target}`)
p.outro('Done!')
return
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/env/index.ts` around lines 43 - 60, The file write
currently saves production secrets with default permissions via
writeFileSync(target, block, 'utf-8'); change this so the created
.env.production.local is restricted (owner read/write only). Update the write
path around the writeFileSync call for target (or immediately after) to set file
mode to 0o600 (either by passing an options object with mode: 0o600 to
writeFileSync or calling fs.chmodSync(target, 0o600)); ensure the success log
(p.log.success) still runs only after the permission change succeeds.

Comment on lines +35 to +57
/**
* Serialize the collected entries and append to `<cwd>/.cipherstash/wizard-log.md`.
* Safe to call multiple times — only appends new content.
*/
async flush(): Promise<string | undefined> {
if (this.entries.length === 0) return undefined

const dir = resolve(this.cwd, '.cipherstash')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const path = join(dir, 'wizard-log.md')

const isNew = !existsSync(path)
const header = isNew ? '# CipherStash Wizard log\n\n' : ''
const body = this.render()

if (isNew) {
await writeFile(path, header + body, 'utf-8')
} else {
await appendFile(path, body, 'utf-8')
}

return path
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

flush() is not idempotent despite the JSDoc claim.

The docstring says "Safe to call multiple times — only appends new content", but render() iterates over the full this.entries array each time and entries are never cleared or marked as flushed. If flush() is invoked twice in a run (e.g., from both success and error paths, or once mid-run and again at end), the file will contain duplicate entries. Either clear/advance a cursor after writing, or document flush as "call exactly once".

♻️ Proposed fix
   async flush(): Promise<string | undefined> {
-    if (this.entries.length === 0) return undefined
+    if (this.flushedCount >= this.entries.length) return undefined
 
     const dir = resolve(this.cwd, '.cipherstash')
     if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
     const path = join(dir, 'wizard-log.md')
 
     const isNew = !existsSync(path)
     const header = isNew ? '# CipherStash Wizard log\n\n' : ''
-    const body = this.render()
+    const body = this.render(this.flushedCount)
+    this.flushedCount = this.entries.length

…and have render(fromIndex = 0) only emit entries from fromIndex onward (and skip the ## Run header on subsequent calls).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/wizard/lib/changelog.ts` around lines 35 - 57, The
flush() method currently appends the entire this.entries every call, causing
duplicate log content; modify the implementation to track which entries have
already been written (e.g., add a flushedIndex or clear/advance this.entries)
and change render to accept an optional fromIndex parameter (render(fromIndex =
0)) so flush() only renders and writes entries[fromIndex..end]; ensure the first
write still emits the "## Run" header but subsequent writes skip that header,
update flush() to call render(flushedIndex) and advance flushedIndex after
successful write so flush() becomes idempotent.

Comment on lines +42 to +62
const confirmed = await p.confirm({
message: `Install ${available.length} Claude skill(s) into ./.claude/skills/ (${available.join(', ')})?`,
initialValue: true,
})
if (p.isCancel(confirmed) || !confirmed) return []

const destRoot = resolve(cwd, '.claude', 'skills')
mkdirSync(destRoot, { recursive: true })

const copied: string[] = []
for (const name of available) {
const src = join(bundledRoot, name)
const dest = join(destRoot, name)
try {
cpSync(src, dest, { recursive: true, force: true })
copied.push(name)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
p.log.warn(`Failed to install skill ${name}: ${message}`)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

force: true silently overwrites user customizations in .claude/skills/<name>/.

If a user has previously installed a skill and edited it locally (e.g. customized stash-drizzle/SKILL.md), re-running the wizard will overwrite those edits without warning. The confirm prompt at Line 42-45 doesn't mention that existing files will be clobbered, so this is a silent data-loss path for a small subset of users.

Consider one of:

  • Detect pre-existing skill dirs and adjust the confirm copy (e.g. "Overwrite N existing skill(s) and install M new one(s)?").
  • Skip copying for destinations that already exist unless the user opts in to overwrite.

Not a launch blocker since .claude/skills/ should generally be treated as CLI-managed, but worth a note in the prompt.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/wizard/lib/install-skills.ts` around lines 42 - 62,
Detect existing skill directories before copying by checking each destination
path (dest = join(destRoot, name)) with existsSync/statSync to build two lists:
existing (would be overwritten) and new (would be added); update the
confirmation prompt (the p.confirm call that references available) to mention
counts/names of existing vs new (e.g., "Overwrite N existing skill(s) and
install M new one(s)?") and if the user declines, return []; then during the
copy loop (where cpSync is called) either skip copying for destinations in the
existing list unless the user opted into overwriting, or honor an explicit
overwrite choice and keep cpSync({ recursive: true, force: true }) only when
overwriting; ensure p.log.warn is kept for per-skill failures.

Comment on lines +108 to +133
async function rewriteEncryptedMigrations(cwd: string): Promise<void> {
for (const dir of DRIZZLE_OUT_DIRS) {
const abs = resolve(cwd, dir)
if (!existsSync(abs)) continue

try {
const rewritten = await rewriteEncryptedAlterColumns(abs)
if (rewritten.length > 0) {
p.log.info(
`Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`,
)
for (const file of rewritten) p.log.step(` - ${file}`)
p.log.warn(
'If any of these tables already have rows, backfill the new column via @cipherstash/stack before running the migration in production. See the comments in the rewritten SQL.',
)
}
// Only rewrite the first dir that matches — running again on a
// different candidate would double-transform already-rewritten SQL.
return
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
p.log.warn(`Could not rewrite migrations in ${dir}: ${message}`)
return
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Early return on the first existing candidate dir may skip the real migrations directory.

rewriteEncryptedMigrations walks DRIZZLE_OUT_DIRS in order and returns on the first candidate that existsSync succeeds on — regardless of whether that directory actually contains any migrations drizzle-kit just emitted. If a project has a leftover or unrelated drizzle/ directory but drizzle.config.* points out to migrations/ (or src/db/migrations/), the rewrite silently no-ops on the empty drizzle/ and never touches the real output directory.

Consider reading drizzle.config.ts/js for the authoritative out value and falling back to the candidate list only if that fails, or at minimum continuing the loop when rewritten.length === 0 (the double-transform concern the comment raises only applies when something was actually rewritten).

♻️ Sketch
   for (const dir of DRIZZLE_OUT_DIRS) {
     const abs = resolve(cwd, dir)
     if (!existsSync(abs)) continue

     try {
       const rewritten = await rewriteEncryptedAlterColumns(abs)
-      if (rewritten.length > 0) {
-        p.log.info(...)
-        for (const file of rewritten) p.log.step(`  - ${file}`)
-        p.log.warn(...)
-      }
-      // Only rewrite the first dir that matches — running again on a
-      // different candidate would double-transform already-rewritten SQL.
-      return
+      if (rewritten.length > 0) {
+        p.log.info(...)
+        for (const file of rewritten) p.log.step(`  - ${file}`)
+        p.log.warn(...)
+        // Only stop once we've actually rewritten something — otherwise keep
+        // looking; a stale empty candidate dir shouldn't mask the real one.
+        return
+      }
     } catch (err) {
       const message = err instanceof Error ? err.message : String(err)
       p.log.warn(`Could not rewrite migrations in ${dir}: ${message}`)
       return
     }
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function rewriteEncryptedMigrations(cwd: string): Promise<void> {
for (const dir of DRIZZLE_OUT_DIRS) {
const abs = resolve(cwd, dir)
if (!existsSync(abs)) continue
try {
const rewritten = await rewriteEncryptedAlterColumns(abs)
if (rewritten.length > 0) {
p.log.info(
`Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`,
)
for (const file of rewritten) p.log.step(` - ${file}`)
p.log.warn(
'If any of these tables already have rows, backfill the new column via @cipherstash/stack before running the migration in production. See the comments in the rewritten SQL.',
)
}
// Only rewrite the first dir that matches — running again on a
// different candidate would double-transform already-rewritten SQL.
return
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
p.log.warn(`Could not rewrite migrations in ${dir}: ${message}`)
return
}
}
}
async function rewriteEncryptedMigrations(cwd: string): Promise<void> {
for (const dir of DRIZZLE_OUT_DIRS) {
const abs = resolve(cwd, dir)
if (!existsSync(abs)) continue
try {
const rewritten = await rewriteEncryptedAlterColumns(abs)
if (rewritten.length > 0) {
p.log.info(
`Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`,
)
for (const file of rewritten) p.log.step(` - ${file}`)
p.log.warn(
'If any of these tables already have rows, backfill the new column via `@cipherstash/stack` before running the migration in production. See the comments in the rewritten SQL.',
)
// Only stop once we've actually rewritten something — otherwise keep
// looking; a stale empty candidate dir shouldn't mask the real one.
return
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
p.log.warn(`Could not rewrite migrations in ${dir}: ${message}`)
return
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/wizard/lib/post-agent.ts` around lines 108 - 133,
rewriteEncryptedMigrations currently returns as soon as it finds the first
existing DRIZZLE_OUT_DIRS candidate, which can skip the real migrations dir;
change the control flow in rewriteEncryptedMigrations so it only returns (exits)
when rewriteEncryptedAlterColumns(abs) actually rewrote files (rewritten.length
> 0), and otherwise continues the loop to try the next candidate; likewise, on
catch do not return immediately—log the error and continue scanning other
DRIZZLE_OUT_DIRS; keep references to DRIZZLE_OUT_DIRS,
rewriteEncryptedAlterColumns, existsSync and the p.log calls when making the
change.

Comment on lines +106 to +122
lines.forEach((line, i) => {
if (integration === 'drizzle') {
if (drizzleInsert.test(line))
out.push({ kind: 'insert', line: i + 1, snippet: line.trim() })
if (drizzleUpdate.test(line))
out.push({ kind: 'update', line: i + 1, snippet: line.trim() })
if (drizzleSelect.test(line))
out.push({ kind: 'select', line: i + 1, snippet: line.trim() })
} else if (integration === 'supabase') {
if (supabaseFromInsert.test(line))
out.push({ kind: 'insert', line: i + 1, snippet: line.trim() })
if (supabaseFromUpdate.test(line))
out.push({ kind: 'update', line: i + 1, snippet: line.trim() })
if (supabaseFromSelect.test(line))
out.push({ kind: 'select', line: i + 1, snippet: line.trim() })
}
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Duplicate matches when a single line triggers multiple kinds.

If one line happens to contain both (say) .from(users) and .update(users) — common in chained Drizzle query builders written on one line — you'll push two entries for the same source position. Consider picking the most specific match per line (insert/update before select) or deduping by (line, kind). Minor, only affects report noise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/wizard/lib/wire-call-sites.ts` around lines 106 -
122, The loop over lines can push multiple entries when a single line matches
multiple regexes (e.g., drizzleInsert and drizzleSelect); update the logic in
the lines.forEach block to pick a single kind per line (prefer insert, then
update, then select) or dedupe pushes by tracking seen keys. Concretely, replace
the multiple if checks that call out.push with a single decision per line (e.g.,
compute let kind = drizzleInsert.test(line) ? 'insert' :
drizzleUpdate.test(line) ? 'update' : drizzleSelect.test(line) ? 'select' :
undefined and push only if kind) or maintain a Set of `${i}-${kind}` to avoid
duplicate out.push calls for the same (line, kind); apply the same approach for
the supabase branch using
supabaseFromInsert/supabaseFromUpdate/supabaseFromSelect.

@calvinbrewer calvinbrewer merged commit c9f915b into main Apr 17, 2026
6 checks passed
@calvinbrewer calvinbrewer deleted the claude/blissful-wu-b957fc branch April 17, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant