Skip to content

feat: add stash forge package#314

Open
calvinbrewer wants to merge 5 commits intomainfrom
stash-forge
Open

feat: add stash forge package#314
calvinbrewer wants to merge 5 commits intomainfrom
stash-forge

Conversation

@calvinbrewer
Copy link
Contributor

@calvinbrewer calvinbrewer commented Mar 4, 2026

Intro stash-forge

@cipherstash/stack is the runtime encryption SDK. It should stay lean and free of heavy dependencies like pg. @cipherstash/stack-forge is a devDependency that handles database tooling: installing EQL extensions, checking permissions, and managing schema lifecycle.

Think of it like Prisma or Drizzle Kit: a companion CLI that sets up the database while the main SDK handles runtime operations.

Summary by CodeRabbit

  • New Features

    • Initial release of the stash-forge CLI and developer library (install, push, status/init placeholders) with Drizzle migration support and stash.config.ts-based configuration
    • New programmatic config and installer APIs surfaced for consumers
    • Examples updated to include PostgreSQL runtime dependency and dev tooling
  • Documentation

    • Added comprehensive README and skill docs covering setup, CLI usage, and programmatic API
  • Tests

    • Added unit tests for config loading and installer behaviors

@changeset-bot
Copy link

changeset-bot bot commented Mar 4, 2026

🦋 Changeset detected

Latest commit: bcfeeb8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@cipherstash/stack Minor
@cipherstash/stack-forge Minor
@cipherstash/basic-example Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai
Copy link

coderabbitai bot commented Mar 4, 2026

📝 Walkthrough

Walkthrough

Adds a new package @cipherstash/stack-forge (CLI + library) to load stash configs, check DB permissions, install CipherStash EQL into Postgres, and push encrypted configs. Includes CLI entrypoint, command implementations (install, push, placeholders), installer class, config loader, tests, docs, build tooling, example updates, and a changeset.

Changes

Cohort / File(s) Summary
Package manifest & docs
packages/stack-forge/package.json, packages/stack-forge/README.md, .changeset/soft-times-tease.md
Add new @cipherstash/stack-forge package, bin stash-forge, build/test scripts, dependencies, and initial README and release note.
Core config API & exports
packages/stack-forge/src/config/index.ts, packages/stack-forge/src/index.ts, examples/basic/stash.config.ts
Add typed StashConfig/ResolvedStashConfig, defineConfig, loadStashConfig, loadEncryptConfig, config discovery/validation via jiti + Zod, and barrel exports; example stash.config.ts added.
Installer & DB logic
packages/stack-forge/src/installer/index.ts, packages/stack-forge/src/__tests__/installer.test.ts
Add EQLInstaller with permission checks, isInstalled/getInstalledVersion, transactional install (download SQL, BEGIN/COMMIT/ROLLBACK), Supabase grants, download logic, and comprehensive installer tests.
CLI and commands
packages/stack-forge/src/bin/stash-forge.ts, packages/stack-forge/src/commands/index.ts, packages/stack-forge/src/commands/*.ts
Add CLI entrypoint with arg parser and routing; implement install (with drizzle migration generation), push (encrypt + persist config), and placeholder init/status commands; re-export commands.
Command internals (install/push)
packages/stack-forge/src/commands/install.ts, packages/stack-forge/src/commands/push.ts
Implement install flow with permission checks, drizzle migration generation, download/write migration support, and push flow that loads encrypt client, optionally dry-runs or writes encrypted config to DB.
Tests & tooling
packages/stack-forge/src/__tests__/config.test.ts, packages/stack-forge/vitest.config.ts, packages/stack-forge/tsup.config.ts, packages/stack-forge/tsconfig.json
Add config tests, vitest config with alias, tsup dual-build (lib + CLI), and TS config for strict compilation.
Examples & dependencies
examples/basic/package.json, examples/basic/encrypt.ts
Update example deps: dotenv bumped, add pg, add workspace devDep on @cipherstash/stack-forge; tiny formatting change in encrypt.ts.
Documentation / skill
skills/stash-forge/SKILL.md, packages/stack-forge/README.md
Add skill doc and README describing CLI usage, programmatic API, configuration, and Supabase considerations.
Workspace & changeset config
pnpm-workspace.yaml, .changeset/config.json, .changeset/curvy-bushes-mix.md
Small workspace YAML edits, add experimental changeset option, and add a changeset documenting a stack minor release (encryption API change noted).
Stack encryption getter
packages/stack/src/encryption/index.ts
Expose getEncryptConfig() getter on EncryptionClient (public accessor).

Sequence Diagrams

sequenceDiagram
    actor User
    participant CLI as stash-forge CLI
    participant Config as Config Loader
    participant Installer as EQL Installer
    participant GitHub as GitHub
    participant DB as PostgreSQL

    User->>CLI: run install command
    CLI->>Config: loadStashConfig()
    Config->>Config: find & validate stash.config.ts
    Config-->>CLI: return config

    CLI->>Installer: instantiate with databaseUrl
    CLI->>Installer: checkPermissions()
    Installer->>DB: query role & privileges
    DB-->>Installer: permission result
    Installer-->>CLI: PermissionCheckResult

    alt insufficient permissions
        CLI-->>User: abort with error
    else permissions ok
        CLI->>Installer: isInstalled()
        Installer->>DB: check eql_v2 schema
        DB-->>Installer: boolean

        alt not installed or --force
            CLI->>GitHub: downloadInstallScript()
            GitHub-->>CLI: SQL script
            CLI->>Installer: install(sql, options)
            Installer->>DB: BEGIN
            Installer->>DB: execute install SQL
            alt success
                Installer->>DB: COMMIT
                Installer-->>CLI: success
                alt supabase option
                    Installer->>DB: grant supabase privileges
                end
            else failure
                Installer->>DB: ROLLBACK
                Installer-->>CLI: error
            end
            CLI-->>User: report result
        else already installed
            CLI-->>User: prompt/use --force
        end
    end
Loading
sequenceDiagram
    actor User
    participant CLI as stash-forge CLI
    participant Config as Config Loader
    participant Encrypt as Encrypt Client Loader
    participant DB as PostgreSQL

    User->>CLI: run push command
    CLI->>Config: loadStashConfig()
    Config-->>CLI: stash config
    CLI->>Encrypt: loadEncryptConfig(clientPath)
    Encrypt->>Encrypt: load client & getEncryptConfig()
    Encrypt-->>CLI: EncryptConfig

    alt dry-run
        CLI->>User: print encryption schema (no DB)
    else normal
        CLI->>DB: connect(databaseUrl)
        CLI->>DB: mark existing configs inactive
        CLI->>DB: insert new active encrypted config
        DB-->>CLI: success or error
        CLI->>DB: close connection
    end
    CLI-->>User: report result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • coderdan

Poem

🐰 Hopping through TypeScript, I mend each string,
New forge and installer make schemas sing.
With configs found and SQL fetched on the run,
I nudge Postgres gently — work safely done.
A rabbit cheers: install complete, hop—fun! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% 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 title 'feat: add stash forge package' is clear and directly describes the primary change: introducing a new @cipherstash/stack-forge package to the repository.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch stash-forge

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

@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: 9

Caution

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

⚠️ Outside diff range comments (1)
examples/basic/package.json (1)

1-22: ⚠️ Potential issue | 🟠 Major

Add required Node/pnpm constraints for the example app.

This manifest is missing explicit runtime/tooling constraints for the example project.

🔧 Suggested patch
 {
   "name": "@cipherstash/basic-example",
   "private": true,
   "version": "1.1.20",
   "type": "module",
+  "packageManager": "pnpm@9",
+  "engines": {
+    "node": ">=22"
+  },
   "scripts": {
     "start": "tsx index.ts"
   },
As per coding guidelines, `examples/**/package.json`: Use Node.js >= 22 and pnpm 9.x for example apps.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/basic/package.json` around lines 1 - 22, The package.json is missing
runtime/tooling constraints; add an "engines" field with "node": ">=22" and set
the package manager by adding "packageManager": "pnpm@9.0.0" (or "pnpm@9"
allowed) to the top-level JSON so the example enforces Node >=22 and pnpm 9.x;
update the existing package.json object (the file's top-level keys such as
"name", "version", "scripts") to include these two keys without changing other
fields.
🧹 Nitpick comments (3)
packages/stack-forge/src/commands/init.ts (1)

3-5: Consider a shared “not implemented” command helper to reduce duplication.

init/status/push/migrate currently repeat the same placeholder pattern. Centralizing this keeps message format/exit behavior consistent.

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

In `@packages/stack-forge/src/commands/init.ts` around lines 3 - 5, Replace the
repeated placeholder logic in initCommand with a shared helper (eg.
notImplementedCommand or showNotImplemented) that accepts a command name or
message and handles the p.log.warn message and any exit behavior; update
initCommand to call that helper (and similarly replace the same pattern in
status/push/migrate) so message formatting and exit semantics are centralized
and consistent across commands.
packages/stack-forge/src/__tests__/installer.test.ts (1)

34-35: Prefer importing EQLInstaller through the public API in tests.

These tests currently reach into @/installer/index.ts; switching to @/index.ts keeps tests aligned with the supported surface and reduces coupling to internals.

As per coding guidelines, **/__tests__/**/*.test.ts: Prefer testing via the public API and avoid reaching into private internals.

Also applies to: 69-70, 86-87, 103-104, 125-126, 160-161

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

In `@packages/stack-forge/src/__tests__/installer.test.ts` around lines 34 - 35,
Tests import EQLInstaller directly from the internal module
'@/installer/index.ts'; update the imports to use the public package entry
instead (import EQLInstaller from '@/index.ts' or from the package root export)
so tests use the supported public API surface; update all occurrences that reach
into internals (the instances around the EQLInstaller imports at the other noted
spots) to import from the public API and keep constructor usage the same.
packages/stack-forge/src/__tests__/config.test.ts (1)

34-35: Prefer testing through the package public entrypoint.

These tests currently import loadStashConfig from an internal module path. Switching to the public barrel reduces coupling to file layout and keeps tests aligned with consumer behavior.

♻️ Proposed refactor
-    const { loadStashConfig } = await import('@/config/index.ts')
+    const { loadStashConfig } = await import('@/index.ts')
...
-    const { loadStashConfig } = await import('@/config/index.ts')
+    const { loadStashConfig } = await import('@/index.ts')
...
-    const { loadStashConfig } = await import('@/config/index.ts')
+    const { loadStashConfig } = await import('@/index.ts')
As per coding guidelines, "Prefer testing via the public API and avoid reaching into private internals".

Also applies to: 54-55, 75-75

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

In `@packages/stack-forge/src/__tests__/config.test.ts` around lines 34 - 35,
Replace the internal-module import of loadStashConfig in the tests with an
import from the package's public barrel (the package public entrypoint) so tests
exercise the public API instead of reaching into internals; update the dynamic
import that currently targets '@/config/index.ts' to import loadStashConfig via
the package's exported entrypoint and make the same change for the other
occurrences in this test file (the imports around the other noted lines),
ensuring you reference the exported symbol loadStashConfig from the public API
rather than the internal path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/basic/stash.config.ts`:
- Around line 3-5: The current export uses process.env.DATABASE_URL! which can
be undefined at runtime; update the defineConfig call so it explicitly checks
process.env.DATABASE_URL and throws a clear, actionable error if missing (e.g.,
before calling defineConfig or inline), ensuring the databaseUrl property is
always a non-empty string; reference the defineConfig export and the databaseUrl
property that currently reads process.env.DATABASE_URL to locate where to add
the guard and error message.

In `@packages/stack-forge/README.md`:
- Around line 81-100: The Markdown table under the `install` subsection is
missing the required blank line spacing that triggers MD058; update the README
by adding a blank line before the table header line starting with "| Option |
Description |" and a blank line after the final table row ("|
`--exclude-operator-family` | Skip operator family creation ... |") so the table
block is properly separated from surrounding code/paragraphs in the `install`
section.

In `@packages/stack-forge/src/bin/stash-forge.ts`:
- Line 8: The CLI hardcodes the version string "CipherStash Forge v0.1.0" (and
the duplicate at the other occurrence) which conflicts with package metadata;
update the startup/version output in stash-forge.ts to read the canonical
version from the package manifest (e.g., import or require the version from
package.json or use an existing exported VERSION constant) instead of the
hardcoded "v0.1.0", and replace both occurrences so the CLI always prints the
package.json version.
- Around line 78-81: The catch block in main() currently logs raw runtime error
messages via p.log.error which can leak sensitive plaintext; replace the
conditional p.log.error(error instanceof Error ? error.message : 'An unexpected
error occurred') with a generic, non-plaintext message (e.g.
p.log.error('Unhandled error in stash-forge main')) and forward the actual Error
to your error/telemetry pipeline instead (e.g. call
Sentry.captureException(error) or an internal reportError(error)) so the logger
never emits raw error.message while still preserving the error for diagnostics.

In `@packages/stack-forge/src/config/index.ts`:
- Around line 102-104: The catch block currently prints the raw error object via
console.error(error) which can leak sensitive plaintext; remove the raw error
logging and replace it with a non-sensitive, sanitized log (or no log) that
references CONFIG_FILENAME and configPath only (e.g., an explicit message like
"Failed to load <CONFIG_FILENAME> at <configPath>") and then call
process.exit(1); update the console.error call site (the two console.error(...)
calls around CONFIG_FILENAME/configPath) to avoid printing error.message/stack
or any raw object—if you must include error metadata, include only non-sensitive
identifiers such as error.name or a predefined error code rather than full
plaintext.
- Around line 76-118: The exported function loadStashConfig currently calls
process.exit and prints errors directly; change it to throw well-typed errors
instead so callers can handle failures. Replace the process.exit paths in
loadStashConfig (the missing configPath branch, the jiti.import catch, and the
stashConfigSchema validation branch) with thrown Error instances (e.g.,
ConfigNotFoundError / ConfigLoadError / ConfigValidationError) that include the
same diagnostic messages and original error/validation details (attach the
caught error and result.error.issues). Keep console.error only for non-fatal
logging if needed, but ensure the function returns the parsed config on success
and never terminates the process itself.

In `@packages/stack-forge/src/installer/index.ts`:
- Around line 190-220: The pg.Client created as client can leak because
client.end() is only in the inner finally; ensure client.end() is always called
even if client.connect() throws by wrapping the entire sequence (connect(),
BEGIN, query(sql), grantSupabasePermissions, COMMIT/ROLLBACK) in a single
try/catch/finally or adding an outer finally that always calls await
client.end(); locate the client usage around the
connect/BEGIN/COMMIT/ROLLBACK/grantSupabasePermissions calls and move the
cleanup (await client.end()) into a finally that runs regardless of where an
exception occurs, preserving existing error handling and rollback behavior.
- Around line 253-259: The current fetch(url) call can hang; wrap it with an
AbortController timeout: create an AbortController, start a setTimeout to call
controller.abort() after a chosen timeout (e.g., 10s), pass signal:
controller.signal into fetch(url), and clear the timer after fetch completes;
update the catch to detect abort (error.name === 'AbortError') and throw a
descriptive Error (or include cause) indicating the download timed out,
otherwise rethrow the original failure; apply this change around the existing
fetch(url) / response handling so the response variable and existing error
handling remain consistent.

In `@packages/stack-forge/vitest.config.ts`:
- Line 10: The vitest config uses __dirname directly (in the alias '@/':
`${resolve(__dirname, './src')}/`) which breaks in ESM; replace __dirname by
deriving the directory from import.meta.url (e.g., use
fileURLToPath(import.meta.url) and path.dirname) and then pass that resolved
dirname into resolve(...) so the alias becomes ESM-safe—update the top of the
file to import or require the URL-to-path helper and use that dirname when
calling resolve in the alias.

---

Outside diff comments:
In `@examples/basic/package.json`:
- Around line 1-22: The package.json is missing runtime/tooling constraints; add
an "engines" field with "node": ">=22" and set the package manager by adding
"packageManager": "pnpm@9.0.0" (or "pnpm@9" allowed) to the top-level JSON so
the example enforces Node >=22 and pnpm 9.x; update the existing package.json
object (the file's top-level keys such as "name", "version", "scripts") to
include these two keys without changing other fields.

---

Nitpick comments:
In `@packages/stack-forge/src/__tests__/config.test.ts`:
- Around line 34-35: Replace the internal-module import of loadStashConfig in
the tests with an import from the package's public barrel (the package public
entrypoint) so tests exercise the public API instead of reaching into internals;
update the dynamic import that currently targets '@/config/index.ts' to import
loadStashConfig via the package's exported entrypoint and make the same change
for the other occurrences in this test file (the imports around the other noted
lines), ensuring you reference the exported symbol loadStashConfig from the
public API rather than the internal path.

In `@packages/stack-forge/src/__tests__/installer.test.ts`:
- Around line 34-35: Tests import EQLInstaller directly from the internal module
'@/installer/index.ts'; update the imports to use the public package entry
instead (import EQLInstaller from '@/index.ts' or from the package root export)
so tests use the supported public API surface; update all occurrences that reach
into internals (the instances around the EQLInstaller imports at the other noted
spots) to import from the public API and keep constructor usage the same.

In `@packages/stack-forge/src/commands/init.ts`:
- Around line 3-5: Replace the repeated placeholder logic in initCommand with a
shared helper (eg. notImplementedCommand or showNotImplemented) that accepts a
command name or message and handles the p.log.warn message and any exit
behavior; update initCommand to call that helper (and similarly replace the same
pattern in status/push/migrate) so message formatting and exit semantics are
centralized and consistent across commands.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4b6a59e7-23dc-4d0f-ae5c-5d98d1296e2b

📥 Commits

Reviewing files that changed from the base of the PR and between d3bc40e and 60ce44a.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (21)
  • .changeset/soft-times-tease.md
  • examples/basic/package.json
  • examples/basic/stash.config.ts
  • packages/stack-forge/README.md
  • packages/stack-forge/package.json
  • packages/stack-forge/src/__tests__/config.test.ts
  • packages/stack-forge/src/__tests__/installer.test.ts
  • packages/stack-forge/src/bin/stash-forge.ts
  • packages/stack-forge/src/commands/index.ts
  • packages/stack-forge/src/commands/init.ts
  • packages/stack-forge/src/commands/install.ts
  • packages/stack-forge/src/commands/migrate.ts
  • packages/stack-forge/src/commands/push.ts
  • packages/stack-forge/src/commands/status.ts
  • packages/stack-forge/src/config/index.ts
  • packages/stack-forge/src/index.ts
  • packages/stack-forge/src/installer/index.ts
  • packages/stack-forge/tsconfig.json
  • packages/stack-forge/tsup.config.ts
  • packages/stack-forge/vitest.config.ts
  • skills/stash-forge/SKILL.md

Comment on lines +3 to +5
export default defineConfig({
databaseUrl: process.env.DATABASE_URL!,
})
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fail fast when DATABASE_URL is missing instead of relying on !.

Line 4 can pass undefined at runtime and fail later with a less clear error. Add an explicit guard so the example fails with an actionable message.

Suggested patch
 import { defineConfig } from '@cipherstash/stack-forge'
 
+const databaseUrl = process.env.DATABASE_URL
+if (!databaseUrl) {
+  throw new Error('DATABASE_URL is required')
+}
+
 export default defineConfig({
-  databaseUrl: process.env.DATABASE_URL!,
+  databaseUrl,
 })

As per coding guidelines, "Example apps should be small, idiomatic, and runnable as-is with documented env vars; never leak secrets in code or logs".

📝 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
export default defineConfig({
databaseUrl: process.env.DATABASE_URL!,
})
import { defineConfig } from '@cipherstash/stack-forge'
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error('DATABASE_URL is required')
}
export default defineConfig({
databaseUrl,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/basic/stash.config.ts` around lines 3 - 5, The current export uses
process.env.DATABASE_URL! which can be undefined at runtime; update the
defineConfig call so it explicitly checks process.env.DATABASE_URL and throws a
clear, actionable error if missing (e.g., before calling defineConfig or
inline), ensuring the databaseUrl property is always a non-empty string;
reference the defineConfig export and the databaseUrl property that currently
reads process.env.DATABASE_URL to locate where to add the guard and error
message.

import { installCommand } from '../commands/index.js'

const HELP = `
CipherStash Forge v0.1.0
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Version output is inconsistent with package metadata.

CLI help/version currently reports 0.1.0 while the package manifest is 0.0.0, which will mislead users.

🔧 Suggested patch
-const HELP = `
-CipherStash Forge v0.1.0
+const VERSION = '0.0.0'
+const HELP = `
+CipherStash Forge v${VERSION}
 ...
 `.trim()
@@
-    console.log('0.1.0')
+    console.log(VERSION)

Also applies to: 51-51

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

In `@packages/stack-forge/src/bin/stash-forge.ts` at line 8, The CLI hardcodes the
version string "CipherStash Forge v0.1.0" (and the duplicate at the other
occurrence) which conflicts with package metadata; update the startup/version
output in stash-forge.ts to read the canonical version from the package manifest
(e.g., import or require the version from package.json or use an existing
exported VERSION constant) instead of the hardcoded "v0.1.0", and replace both
occurrences so the CLI always prints the package.json version.

Comment on lines +78 to +81
main().catch((error) => {
p.log.error(
error instanceof Error ? error.message : 'An unexpected error occurred',
)
Copy link

@coderabbitai coderabbitai bot Mar 4, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid emitting raw runtime error messages.

Printing arbitrary error.message can leak sensitive plaintext from downstream errors.

As per coding guidelines, **/*.{ts,tsx,js,jsx}: Do NOT log plaintext; the library never logs plaintext by design and logs should never leak sensitive data.

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

In `@packages/stack-forge/src/bin/stash-forge.ts` around lines 78 - 81, The catch
block in main() currently logs raw runtime error messages via p.log.error which
can leak sensitive plaintext; replace the conditional p.log.error(error
instanceof Error ? error.message : 'An unexpected error occurred') with a
generic, non-plaintext message (e.g. p.log.error('Unhandled error in stash-forge
main')) and forward the actual Error to your error/telemetry pipeline instead
(e.g. call Sentry.captureException(error) or an internal reportError(error)) so
the logger never emits raw error.message while still preserving the error for
diagnostics.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is by design to show to underlying Postgres errors and creates a much better UX

Copy link

Choose a reason for hiding this comment

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

@calvinbrewer, understood — surfacing raw Postgres error messages directly in the terminal is a valid UX choice for a developer CLI, where the operator running the command benefits from seeing the exact error rather than a generic message. I'll note this for future reviews.

Comment on lines +76 to +118
export async function loadStashConfig(): Promise<StashConfig> {
const configPath = findConfigFile(process.cwd())

if (!configPath) {
console.error(`Error: Could not find ${CONFIG_FILENAME}

Create a ${CONFIG_FILENAME} file in your project root:

import { defineConfig } from '@cipherstash/stack-forge'

export default defineConfig({
databaseUrl: process.env.DATABASE_URL!,
})
`)
process.exit(1)
}

const { createJiti } = await import('jiti')
const jiti = createJiti(configPath, {
interopDefault: true,
})

let rawConfig: unknown
try {
rawConfig = await jiti.import(configPath)
} catch (error) {
console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`)
console.error(error)
process.exit(1)
}

const result = stashConfigSchema.safeParse(rawConfig)

if (!result.success) {
console.error(`Error: Invalid ${CONFIG_FILENAME}\n`)

for (const issue of result.error.issues) {
console.error(` - ${issue.path.join('.')}: ${issue.message}`)
}

console.error()
process.exit(1)
}
Copy link

@coderabbitai coderabbitai bot Mar 4, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid process.exit inside exported library API.

loadStashConfig() is publicly exported; forcefully exiting the process makes it unsafe to consume programmatically and bypasses upstream error handling.

🔧 Suggested direction
 export async function loadStashConfig(): Promise<StashConfig> {
   const configPath = findConfigFile(process.cwd())

   if (!configPath) {
-    console.error(`Error: Could not find ${CONFIG_FILENAME}
-...
-`)
-    process.exit(1)
+    throw new Error(
+      `Could not find ${CONFIG_FILENAME}. Create it in your project root.`,
+    )
   }

   let rawConfig: unknown
   try {
     rawConfig = await jiti.import(configPath)
   } catch {
-    console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`)
-    console.error(error)
-    process.exit(1)
+    throw new Error(`Failed to load ${CONFIG_FILENAME} at ${configPath}`)
   }

   const result = stashConfigSchema.safeParse(rawConfig)
   if (!result.success) {
-    console.error(`Error: Invalid ${CONFIG_FILENAME}\n`)
-    ...
-    process.exit(1)
+    const details = result.error.issues
+      .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
+      .join('; ')
+    throw new Error(`Invalid ${CONFIG_FILENAME}: ${details}`)
   }

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

In `@packages/stack-forge/src/config/index.ts` around lines 76 - 118, The exported
function loadStashConfig currently calls process.exit and prints errors
directly; change it to throw well-typed errors instead so callers can handle
failures. Replace the process.exit paths in loadStashConfig (the missing
configPath branch, the jiti.import catch, and the stashConfigSchema validation
branch) with thrown Error instances (e.g., ConfigNotFoundError / ConfigLoadError
/ ConfigValidationError) that include the same diagnostic messages and original
error/validation details (attach the caught error and result.error.issues). Keep
console.error only for non-fatal logging if needed, but ensure the function
returns the parsed config on success and never terminates the process itself.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just outright wrong

Copy link

Choose a reason for hiding this comment

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

@calvinbrewer, fair enough — loadStashConfig() is primarily consumed by the stash-forge CLI commands, and using process.exit() with user-friendly error messages is the standard pattern for CLI config loading (Vite, webpack, and others do the same). My concern was misplaced for a CLI-first tool. Thanks for the correction.

Comment on lines +102 to +104
console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`)
console.error(error)
process.exit(1)
Copy link

@coderabbitai coderabbitai bot Mar 4, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not log raw error objects from config loading.

Logging the full caught error can expose sensitive plaintext from config/runtime failures.

As per coding guidelines, **/*.{ts,tsx,js,jsx}: Do NOT log plaintext; the library never logs plaintext by design and logs should never leak sensitive data.

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

In `@packages/stack-forge/src/config/index.ts` around lines 102 - 104, The catch
block currently prints the raw error object via console.error(error) which can
leak sensitive plaintext; remove the raw error logging and replace it with a
non-sensitive, sanitized log (or no log) that references CONFIG_FILENAME and
configPath only (e.g., an explicit message like "Failed to load
<CONFIG_FILENAME> at <configPath>") and then call process.exit(1); update the
console.error call site (the two console.error(...) calls around
CONFIG_FILENAME/configPath) to avoid printing error.message/stack or any raw
object—if you must include error metadata, include only non-sensitive
identifiers such as error.name or a predefined error code rather than full
plaintext.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wrong

Copy link

Choose a reason for hiding this comment

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

@calvinbrewer, you're right — I apologize for the incorrect comment. The "no plaintext logging" guideline applies to the runtime encryption library where sensitive data is processed, not to this dev-time CLI tool. Logging the full error object here is appropriate and expected behavior, as it helps developers diagnose why their stash.config.ts failed to load. I'll disregard this finding.

Comment on lines +190 to +220
try {
await client.connect()
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to connect to database: ${detail}`,
{ cause: error },
)
}

try {
await client.query('BEGIN')
await client.query(sql)

if (supabase) {
await this.grantSupabasePermissions(client)
}

await client.query('COMMIT')
} catch (error) {
await client.query('ROLLBACK').catch(() => {
// Swallow rollback errors — the original error is more important.
})

const detail = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to install EQL: ${detail}`, {
cause: error,
})
} finally {
await client.end()
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -nP --type=ts -C4 'try\s*\{\s*await client\.connect\(\)' packages/stack-forge/src/installer/index.ts
rg -nP --type=ts -C4 'finally\s*\{\s*await client\.end\(\)' packages/stack-forge/src/installer/index.ts

Repository: cipherstash/stack

Length of output: 43


🏁 Script executed:

cat -n packages/stack-forge/src/installer/index.ts | sed -n '180,230p'

Repository: cipherstash/stack

Length of output: 1959


Ensure pg.Client is always closed, including connect failures.

client.end() is currently in the second try/finally block (lines 218–220), so a connect() error on line 191 exits before guaranteed cleanup occurs. The client instance created on line 188 will leak if the first try/catch throws.

Merge both try blocks into a single try/catch/finally structure, or wrap the entire operation in an outer finally block to guarantee cleanup.

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

In `@packages/stack-forge/src/installer/index.ts` around lines 190 - 220, The
pg.Client created as client can leak because client.end() is only in the inner
finally; ensure client.end() is always called even if client.connect() throws by
wrapping the entire sequence (connect(), BEGIN, query(sql),
grantSupabasePermissions, COMMIT/ROLLBACK) in a single try/catch/finally or
adding an outer finally that always calls await client.end(); locate the client
usage around the connect/BEGIN/COMMIT/ROLLBACK/grantSupabasePermissions calls
and move the cleanup (await client.end()) into a finally that runs regardless of
where an exception occurs, preserving existing error handling and rollback
behavior.

Comment on lines +253 to +259
try {
response = await fetch(url)
} catch (error) {
throw new Error('Failed to download EQL install script from GitHub.', {
cause: error,
})
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -nP --type=ts -C2 '\bfetch\(' packages/stack-forge/src/installer/index.ts

Repository: cipherstash/stack

Length of output: 225


🏁 Script executed:

# Check for other fetch calls in the codebase
rg -nP --type=ts -B2 -A2 '\bfetch\(' packages/stack-forge/src/

# Check Node.js version/environment to ensure AbortController is available
fd -e json -e tsconfig -e package.json | head -20

Repository: cipherstash/stack

Length of output: 1020


🏁 Script executed:

# Check stack-forge package.json for Node.js version and environment
cat packages/stack-forge/package.json

# Check if there are other fetch calls elsewhere in the codebase
rg -nP --type=ts '\bfetch\(' --max-count=20

Repository: cipherstash/stack

Length of output: 1808


🏁 Script executed:

# Check how other fetch calls handle abort/timeout
rg -nP --type=ts -B3 -A3 'fetch\(' packages/stack/src/secrets/index.ts
rg -nP --type=ts -B3 -A3 'fetch\(' packages/protect/src/stash/index.ts

Repository: cipherstash/stack

Length of output: 501


🏁 Script executed:

# Check full context for the other fetch calls
rg -nP --type=ts -B5 -A10 'fetch\(url,' packages/stack/src/secrets/index.ts | head -30
rg -nP --type=ts -B5 -A10 'fetch\(url,' packages/protect/src/stash/index.ts | head -30

Repository: cipherstash/stack

Length of output: 1211


🏁 Script executed:

# Get exact line numbers and context for the problematic fetch call
sed -n '250,260p' packages/stack-forge/src/installer/index.ts | cat -n

Repository: cipherstash/stack

Length of output: 348


Add a timeout/abort path to remote script download.

fetch(url) without an abort signal can hang indefinitely on network stalls, blocking CLI completion. Since this downloads from an external service (GitHub), a timeout is critical for reliable CLI UX.

⏱️ Proposed fix
     let response: Response
+    const controller = new AbortController()
+    const timeout = setTimeout(() => controller.abort(), 15000)
 
     try {
-      response = await fetch(url)
+      response = await fetch(url, { signal: controller.signal })
     } catch (error) {
       throw new Error('Failed to download EQL install script from GitHub.', {
         cause: error,
       })
+    } finally {
+      clearTimeout(timeout)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/installer/index.ts` around lines 253 - 259, The
current fetch(url) call can hang; wrap it with an AbortController timeout:
create an AbortController, start a setTimeout to call controller.abort() after a
chosen timeout (e.g., 10s), pass signal: controller.signal into fetch(url), and
clear the timer after fetch completes; update the catch to detect abort
(error.name === 'AbortError') and throw a descriptive Error (or include cause)
indicating the download timed out, otherwise rethrow the original failure; apply
this change around the existing fetch(url) / response handling so the response
variable and existing error handling remain consistent.

},
resolve: {
alias: {
'@/': `${resolve(__dirname, './src')}/`,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check package.json for module type in stack-forge =="
if [ -f "packages/stack-forge/package.json" ]; then
  cat "packages/stack-forge/package.json" | python3 -c "import json, sys; data=json.load(sys.stdin); print(f\"type: {data.get('type', 'commonjs (default)')}\"); print(f\"Full package.json:\"); print(json.dumps(data, indent=2))" | head -50
else
  echo "stack-forge package.json not found"
fi

echo
echo "== Check vitest.config.ts content =="
if [ -f "packages/stack-forge/vitest.config.ts" ]; then
  cat "packages/stack-forge/vitest.config.ts"
else
  echo "vitest.config.ts not found"
fi

echo
echo "== Search for __dirname usage in config files =="
rg -n '\b__dirname\b' --type=ts,js

echo
echo "== Search for import.meta.url usage in config files =="
rg -n 'import\.meta\.url' --type=ts,js

echo
echo "== Check root package.json module type =="
if [ -f "package.json" ]; then
  cat "package.json" | python3 -c "import json, sys; data=json.load(sys.stdin); print(f\"Root type: {data.get('type', 'commonjs (default)')}\")"
fi

Repository: cipherstash/stack

Length of output: 1600


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for __dirname in ts/tsx/js/jsx files =="
rg -n '__dirname' -t ts -t tsx -t js -t jsx 2>/dev/null | head -20 || echo "No __dirname found or search failed"

echo
echo "== Search for import.meta.url in ts/tsx/js/jsx files =="
rg -n 'import\.meta\.url' -t ts -t tsx -t js -t jsx 2>/dev/null | head -20 || echo "No import.meta.url found"

echo
echo "== Full content of vitest.config.ts with line numbers =="
cat -n "packages/stack-forge/vitest.config.ts"

echo
echo "== Check if there are any other config files in stack-forge =="
fd -e config.ts -e config.js -e tsconfig.json "packages/stack-forge" --type f

Repository: cipherstash/stack

Length of output: 1078


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check how Vite/Vitest handles config files =="
echo "Looking at vitest.config.ts more carefully and checking for any global definitions..."
grep -rn "global" "packages/stack-forge/" --include="*.ts" --include="*.js" --include="*.json" 2>/dev/null | head -10 || echo "No globals found"

echo
echo "== List all config files in stack-forge =="
find "packages/stack-forge" -maxdepth 2 -type f \( -name "*.config.*" -o -name "tsconfig*" -o -name "vite*" \)

echo
echo "== Check if __dirname is used anywhere else in the codebase =="
rg '__dirname' --type-list 2>/dev/null | grep -E 'ts|js' || true
rg -F '__dirname' 2>/dev/null | head -5 || echo "Search completed"

echo
echo "== Check vitest/vite documentation expectations =="
echo "Vitest config files are typically treated as ESM when package.json has type=module"
echo "Running a test to see if vitest can be invoked..."
if [ -f "packages/stack-forge/package.json" ]; then
  cd packages/stack-forge
  echo "Testing if vitest works with current config..."
  npm test 2>&1 | head -30 || true
fi

Repository: cipherstash/stack

Length of output: 1742


Avoid direct __dirname usage in config to stay ESM-safe.

Line 10 uses __dirname without defining it, which will cause a ReferenceError in this ESM package ("type": "module"). Derive the path from import.meta.url instead, following the pattern already used elsewhere in the codebase (e.g., packages/stack/src/bin/stash.ts).

Suggested patch
-import { resolve } from 'node:path'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import { defineConfig } from 'vitest/config'
 
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
 export default defineConfig({
   test: {
     globals: true,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/vitest.config.ts` at line 10, The vitest config uses
__dirname directly (in the alias '@/': `${resolve(__dirname, './src')}/`) which
breaks in ESM; replace __dirname by deriving the directory from import.meta.url
(e.g., use fileURLToPath(import.meta.url) and path.dirname) and then pass that
resolved dirname into resolve(...) so the alias becomes ESM-safe—update the top
of the file to import or require the URL-to-path helper and use that dirname
when calling resolve in the alias.

Copy link

@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: 10

♻️ Duplicate comments (3)
packages/stack-forge/src/bin/stash-forge.ts (1)

68-70: ⚠️ Potential issue | 🟡 Minor

Version output is hardcoded and can drift from package metadata.

Line 69 prints 0.1.0, which can diverge from packages/stack-forge/package.json. Read the version from package metadata (or a shared constant) to keep CLI output accurate.

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

In `@packages/stack-forge/src/bin/stash-forge.ts` around lines 68 - 70, The CLI
prints a hardcoded version string in the conditional that checks
flags.version/command; replace the literal '0.1.0' with the package's actual
version metadata by importing/reading the package.json version (or a shared
constant) and logging that value instead so the CLI output stays in sync with
packages/stack-forge/package.json; update the code around the flags.version ||
command === '--version' || command === '-v' branch to read and use
packageJson.version (or equivalent exported VERSION) instead of the hardcoded
string.
examples/basic/stash.config.ts (1)

3-5: ⚠️ Potential issue | 🟡 Minor

Fail fast when DATABASE_URL is missing.

Line 4 uses a non-null assertion, so missing env can fail later with a less actionable error. Add an explicit guard before defineConfig.

🛠️ Suggested patch
 import { defineConfig } from '@cipherstash/stack-forge'
 
+const databaseUrl = process.env.DATABASE_URL
+if (!databaseUrl) {
+  throw new Error('DATABASE_URL is required')
+}
+
 export default defineConfig({
-  databaseUrl: process.env.DATABASE_URL!,
+  databaseUrl,
   client: './encrypt.ts',
 })

As per coding guidelines, "Example apps should be small, idiomatic, and runnable as-is with documented env vars; never leak secrets in code or logs".

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

In `@examples/basic/stash.config.ts` around lines 3 - 5, Add a guard before the
call to defineConfig that checks process.env.DATABASE_URL and throws a clear,
early error if it's missing; update the configuration passed to defineConfig
(the databaseUrl property) to use the validated value so the code fails fast and
provides an actionable message rather than relying on the non-null assertion on
process.env.DATABASE_URL.
packages/stack-forge/README.md (1)

88-95: ⚠️ Potential issue | 🟡 Minor

Fix MD058 by surrounding both tables with blank lines.

Line 95 follows the first table immediately, and the table at Line 115 starts directly after the fenced block at Line 114. Add blank lines so both table blocks are isolated.

🧰 Suggested patch
@@
 | `workspaceId` | No | CipherStash workspace ID |
 | `clientAccessKey` | No | CipherStash client access key |
+
 The CLI automatically loads `.env` files before evaluating the config, so `process.env` references work out of the box.
@@
 npx stash-forge install [options]
Option Description
--dry-run Show what would happen without making changes
</details>

 


Also applies to: 115-124

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @packages/stack-forge/README.md around lines 88 - 95, MD058 violations: the
two markdown tables need to be isolated by blank lines. Add an empty line before
and after the table whose header is "| Option | Required | Description |" and
likewise add an empty line before and after the table whose header is "| Option
| Description |" so each table is separated from adjacent paragraphs/fenced
blocks; ensure there is a blank line between the preceding/following fenced
blocks or text and each table.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>packages/stack/src/encryption/index.ts (1)</summary><blockquote>

`599-600`: **Return a defensive copy instead of the internal config reference.**

Line 600 returns the internal `encryptConfig` object directly, so callers can mutate client state from outside. Consider returning an immutable snapshot (or a deep copy) to keep `EncryptionClient` state encapsulated.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @packages/stack/src/encryption/index.ts around lines 599 - 600,
getEncryptConfig currently returns the internal this.encryptConfig reference
which allows external mutation of EncryptionClient state; change
getEncryptConfig() to return a defensive copy (shallow clone or deep clone
depending on EncryptConfig shape) or an immutable snapshot instead of
this.encryptConfig so callers cannot mutate internal state — locate
getEncryptConfig() in EncryptionClient and replace the direct return with a
cloned/serialized copy of EncryptConfig (e.g., using object spread,
structuredClone, or a utility deepClone) ensuring the returned value preserves
type EncryptConfig but is not the original reference.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.changeset/config.json:

  • Around line 11-13: The changeset config currently sets the experimental option
    onlyUpdatePeerDependentsWhenOutOfRange: true which prevents automatic
    peer-dependent bumps when internal peers update within declared ranges (e.g.,
    workspace:* in protect-dynamodb or >= ranges in drizzle), causing silent
    coordination drift; revert this by removing
    onlyUpdatePeerDependentsWhenOutOfRange or set the option to an empty object
    (disable the experimental behavior) in the changeset configuration so peer
    dependents are bumped as before.

In @.changeset/curvy-bushes-mix.md:

  • Line 5: The changeset note currently uses a vague description for the public
    API added; update the text to reference the exact API name
    EncryptionClient.getEncryptConfig() so consumers see the precise public surface
    added. Edit the line mentioning the exposed method and replace it with "Exposed
    EncryptionClient.getEncryptConfig() to build the encryption schema" (or similar)
    ensuring the symbol EncryptionClient.getEncryptConfig() appears verbatim.

In @packages/stack-forge/package.json:

  • Around line 28-29: Update the package.json export metadata so the "types"
    entry points to the actual declaration file emitted by tsup: replace the
    incorrect "./dist/index.d.cts" value for the "types" key with
    "./dist/index.d.ts" (the file produced by tsup's dts output); keep the
    "default": "./dist/index.cjs" entry as-is so both ESM and CJS consumers share
    the same bundled declaration file.

In @packages/stack-forge/src/commands/install.ts:

  • Around line 107-126: Replace the unsafe execSync call that interpolates
    migrationName with a call to execFileSync (from child_process) using an argument
    array so no shell interpolation occurs; pass migrationName as a separate arg and
    include the computed outDir via the --out= flag (e.g. args including
    "generate", "--custom", "--name", migrationName, --out=${outDir}) and preserve
    stdio handling; update the symbol execSync to execFileSync and ensure
    migrationName and outDir variables are used as discrete args rather than
    concatenated into a single shell string.

In @packages/stack-forge/src/commands/push.ts:

  • Around line 33-42: Wrap the two queries that flip the active config in a
    single database transaction using the same client (e.g., BEGIN/COMMIT/ROLLBACK
    around the queries) so the update+insert are atomic; change the UPDATE to only
    affect currently active rows (add WHERE state = 'active') instead of updating
    all rows, and ensure you ROLLBACK the transaction on any error before
    rethrowing; locate this logic around client.query calls in push.ts where client
    and encryptConfig are used.

In @packages/stack-forge/src/config/index.ts:

  • Line 149: The error message that references the missing encrypt client file
    uses the wrong config key name; update the error guidance string (the template
    that currently mentions "encryptClient") to instruct users to check the "client"
    field in the CONFIG_FILENAME instead. Locate the message construction that
    includes resolvedPath and CONFIG_FILENAME (the error string around resolvedPath)
    and replace "encryptClient" with "client" so the guidance matches the actual
    config key.
  • Around line 170-179: The code assumes the first export is an initialized
    EncryptionClient by doing const encryptClient = Object.values(moduleExports)[0]
    and then calling encryptClient.getEncryptConfig(); instead, validate and find an
    actual EncryptionClient instance: iterate Object.values(moduleExports) to locate
    an object that passes a type/shape check (has a getEncryptConfig method) or
    instanceof EncryptionClient, log a clear error including encryptClientPath if
    none found, and only call getEncryptConfig() on the validated instance
    (encryptClient, moduleExports, getEncryptConfig are the symbols to update).

In @skills/stash-forge/SKILL.md:

  • Line 173: Update the permission notes so both the checkPermissions description
    and the "Common issues" section consistently mention the pgcrypto exception:
    when pgcrypto is not installed the required role must have SUPERUSER or
    CREATEDB. Edit the text near the checkPermissions mention and the "Common
    issues" paragraph (the line currently reading "If pgcrypto is not installed:
    also needs SUPERUSER or CREATEDB") so both sections contain the same wording
    and explanation about the pgcrypto branch.
  • Around line 101-104: Update the documented flow for stash-forge --drizzle to
    verify integrity of the downloaded EQL install SQL before writing it into the
    migration: after step "Downloads the EQL install SQL from GitHub releases" add a
    step that fetches and verifies a checksum/signature (e.g., .sha256/.sha256.sig
    or a PGP signature) for the release asset, validate the SQL content against that
    checksum/signature, and abort with an explicit error if verification fails;
    reference the existing drizzle-kit generate --custom --name= step and the
    code path that writes the SQL into the generated migration file so the
    verification occurs between download and the write operation.
  • Around line 145-146: Update the SKILL.md contract to remove the hard
    process.exit(1) behavior and state that the config loader will throw typed
    errors instead; specifically, replace the statement "Exits with code 1 if config
    is missing or invalid" with a note that the loader (the function that
    finds/loads stash.config.ts and validates with Zod, and which applies defaults
    such as client -> './src/encryption/index.ts') will throw well-named exceptions
    (e.g., ConfigNotFoundError or InvalidConfigError with Zod validation details) so
    callers/CLIs can decide whether to exit the process.

Duplicate comments:
In @examples/basic/stash.config.ts:

  • Around line 3-5: Add a guard before the call to defineConfig that checks
    process.env.DATABASE_URL and throws a clear, early error if it's missing; update
    the configuration passed to defineConfig (the databaseUrl property) to use the
    validated value so the code fails fast and provides an actionable message rather
    than relying on the non-null assertion on process.env.DATABASE_URL.

In @packages/stack-forge/README.md:

  • Around line 88-95: MD058 violations: the two markdown tables need to be
    isolated by blank lines. Add an empty line before and after the table whose
    header is "| Option | Required | Description |" and likewise add an empty line
    before and after the table whose header is "| Option | Description |" so each
    table is separated from adjacent paragraphs/fenced blocks; ensure there is a
    blank line between the preceding/following fenced blocks or text and each table.

In @packages/stack-forge/src/bin/stash-forge.ts:

  • Around line 68-70: The CLI prints a hardcoded version string in the
    conditional that checks flags.version/command; replace the literal '0.1.0' with
    the package's actual version metadata by importing/reading the package.json
    version (or a shared constant) and logging that value instead so the CLI output
    stays in sync with packages/stack-forge/package.json; update the code around the
    flags.version || command === '--version' || command === '-v' branch to read and
    use packageJson.version (or equivalent exported VERSION) instead of the
    hardcoded string.

Nitpick comments:
In @packages/stack/src/encryption/index.ts:

  • Around line 599-600: getEncryptConfig currently returns the internal
    this.encryptConfig reference which allows external mutation of EncryptionClient
    state; change getEncryptConfig() to return a defensive copy (shallow clone or
    deep clone depending on EncryptConfig shape) or an immutable snapshot instead of
    this.encryptConfig so callers cannot mutate internal state — locate
    getEncryptConfig() in EncryptionClient and replace the direct return with a
    cloned/serialized copy of EncryptConfig (e.g., using object spread,
    structuredClone, or a utility deepClone) ensuring the returned value preserves
    type EncryptConfig but is not the original reference.

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `c028aebe-6bb4-4032-b123-3de818fc310b`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 60ce44a7f99ac4855051bc5d541fa43a060626fd and 1be8f813465a47edd178eca58d5d35bbf2da5545.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `pnpm-lock.yaml` is excluded by `!**/pnpm-lock.yaml`

</details>

<details>
<summary>📒 Files selected for processing (15)</summary>

* `.changeset/config.json`
* `.changeset/curvy-bushes-mix.md`
* `examples/basic/encrypt.ts`
* `examples/basic/package.json`
* `examples/basic/stash.config.ts`
* `packages/stack-forge/README.md`
* `packages/stack-forge/package.json`
* `packages/stack-forge/src/bin/stash-forge.ts`
* `packages/stack-forge/src/commands/index.ts`
* `packages/stack-forge/src/commands/install.ts`
* `packages/stack-forge/src/commands/push.ts`
* `packages/stack-forge/src/config/index.ts`
* `packages/stack/src/encryption/index.ts`
* `pnpm-workspace.yaml`
* `skills/stash-forge/SKILL.md`

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* examples/basic/encrypt.ts

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* examples/basic/package.json

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment on lines +11 to +13
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the actual .changeset/config.json file
cat .changeset/config.json

Repository: cipherstash/stack

Length of output: 448


🏁 Script executed:

# Run the verification script from the review comment to enumerate peer dependency ranges
#!/bin/bash
set -euo pipefail

echo "=== All internal `@cipherstash` peer dependency ranges ==="
fd package.json | while read -r file; do
  jq -r --arg f "$file" '
    if .peerDependencies then
      .peerDependencies
      | to_entries[]
      | select(.key | startswith("@cipherstash/"))
      | [$f, .key, .value] | `@tsv`
    else empty end
  ' "$file" 2>/dev/null
done | sort

echo
echo "=== Potentially non-propagating ranges with onlyUpdatePeerDependentsWhenOutOfRange=true ==="
fd package.json | while read -r file; do
  jq -r --arg f "$file" '
    if .peerDependencies then
      .peerDependencies
      | to_entries[]
      | select(.key | startswith("@cipherstash/"))
      | select(.value | test("^(workspace:\\*|\\*|>=|\\^|~)"))
      | [$f, .key, .value] | `@tsv`
    else empty end
  ' "$file" 2>/dev/null
done | sort

Repository: cipherstash/stack

Length of output: 590


🌐 Web query:

Changesets onlyUpdatePeerDependentsWhenOutOfRange option behavior documentation

💡 Result:

onlyUpdatePeerDependentsWhenOutOfRange is an experimental Changesets config flag (boolean, default false) that you set under ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH in .changeset/config.json. When set to true, Changesets will only bump packages that peer-depend on another package when the updated peer’s new version would no longer satisfy the dependent’s peerDependencies version range (“leaving the range”). In other words: if the peer’s new version is still within the existing peer range, the peer-dependent won’t be bumped just because the peer changed. [1][2]

Example:

{
  "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
    "onlyUpdatePeerDependentsWhenOutOfRange": true
  }
}

Sources: Changesets “Experimental Options” docs [1] and the release note entry that introduced the flag [2].

[1] (changesets-docs.vercel.app)
[2] (newreleases.io)

Citations:


Global peer-dependent bump behavior changed; this can silently skip coordinated releases.

Lines 11–13 enable onlyUpdatePeerDependentsWhenOutOfRange: true, which suppresses automatic bumping of peer dependents when internal peers update but remain within the dependent's version range. With existing peer ranges like workspace:* (packages/protect-dynamodb) and >=... (packages/drizzle), peer dependents will not be bumped unless the peer version goes out of range—causing silent version coordination drift. This is particularly problematic for workspace:* dependencies, where updates rarely exceed range bounds.

Consider removing this flag or setting it to an empty object:

Suggested config change
   "ignore": [],
-  "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
-    "onlyUpdatePeerDependentsWhenOutOfRange": true
-  }
+  "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {}
📝 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
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/config.json around lines 11 - 13, The changeset config currently
sets the experimental option onlyUpdatePeerDependentsWhenOutOfRange: true which
prevents automatic peer-dependent bumps when internal peers update within
declared ranges (e.g., workspace:* in protect-dynamodb or >= ranges in drizzle),
causing silent coordination drift; revert this by removing
onlyUpdatePeerDependentsWhenOutOfRange or set the option to an empty object
(disable the experimental behavior) in the changeset configuration so peer
dependents are bumped as before.

"@cipherstash/stack": minor
---

Exposed a public method on the Encryption client to expose the build Encryption schema.
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use the exact API name in the changeset note.

Line 5 doesn’t match the actual public surface added in this PR. The note should reference EncryptionClient.getEncryptConfig() explicitly to avoid confusing consumers.

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

In @.changeset/curvy-bushes-mix.md at line 5, The changeset note currently uses
a vague description for the public API added; update the text to reference the
exact API name EncryptionClient.getEncryptConfig() so consumers see the precise
public surface added. Edit the line mentioning the exposed method and replace it
with "Exposed EncryptionClient.getEncryptConfig() to build the encryption
schema" (or similar) ensuring the symbol EncryptionClient.getEncryptConfig()
appears verbatim.

Comment on lines +28 to +29
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the tsup config in packages/stack-forge
find packages/stack-forge -type f -name "tsup*" -o -name "turbo*" -o -name "build*" | head -20

Repository: cipherstash/stack

Length of output: 96


🏁 Script executed:

# Check the package.json content
cat -n packages/stack-forge/package.json

Repository: cipherstash/stack

Length of output: 1753


🏁 Script executed:

# Look for any build/dist output or config files
ls -la packages/stack-forge/ | head -20

Repository: cipherstash/stack

Length of output: 547


🏁 Script executed:

# Check if there's a tsup config at root
find . -maxdepth 2 -type f \( -name "tsup.config.*" -o -name "*.config.ts" \) | grep -E "(tsup|build)" | head -10

Repository: cipherstash/stack

Length of output: 43


🌐 Web query:

tsup dts emit .d.cts vs .d.ts CJS ESM declaration files

💡 Result:

What .d.ts vs .d.cts means (and why it matters for CJS/ESM)

  • .d.ts is “ambiguous”: TypeScript decides whether it describes ESM or CJS using the same rules it uses for .ts/.js (notably the nearest package.json "type" field, plus NodeNext-style resolution rules). (docsarchive.dev)
  • .d.cts is explicitly CommonJS (the declaration-side counterpart of .cts → runtime .cjs). .d.mts is explicitly ESM (counterpart of .mts → runtime .mjs). (devblogs.microsoft.com)

So in a dual-published package (both import/ESM and require/CJS), relying on only .d.ts can be problematic because it’s interpreted according to package context—whereas .d.cts / .d.mts are unambiguous.


How this interacts with tsup

  • tsup’s dts output is typically a single bundled .d.ts, not per-format .d.mts/.d.cts. This is a known/recurring limitation people run into when using tsup’s dts bundling. (github.com)

Practical implication: if you emit both dist/index.js (ESM) and dist/index.cjs (CJS), one shared index.d.ts can’t reliably represent both module systems in all setups—especially when "type": "module" is involved.


What to do (common patterns)

  1. If you ship dual ESM+CJS: prefer emitting two type entrypoints:
  2. If you stick with tsup for JS bundling: generate types with tsc (often twice, or with separate entry files/extensions) so you can produce .d.mts/.d.cts where needed. (tsup’s bundled .d.ts is convenient, but it won’t model “two runtimes” by itself.) (devblogs.microsoft.com)

If you share your package.json exports and your tsup config (formats + "type"), I can tell you exactly which declaration filenames/exports mappings TypeScript will pick up for import vs require.

Citations:


🏁 Script executed:

cat -n packages/stack-forge/tsup.config.ts

Repository: cipherstash/stack

Length of output: 935


Fix require.types path to match tsup's actual output.

Line 28 points require.types to ./dist/index.d.cts, but tsup's dts: true with default settings emits only ./dist/index.d.ts. This mismatch breaks type resolution for CJS consumers. Change line 28 to:

"types": "./dist/index.d.ts",

Both ESM and CJS can share the same declaration file when using a single bundled output.

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

In `@packages/stack-forge/package.json` around lines 28 - 29, Update the
package.json export metadata so the "types" entry points to the actual
declaration file emitted by tsup: replace the incorrect "./dist/index.d.cts"
value for the "types" key with "./dist/index.d.ts" (the file produced by tsup's
dts output); keep the "default": "./dist/index.cjs" entry as-is so both ESM and
CJS consumers share the same bundled declaration file.

Comment on lines +107 to +126
const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT)

if (options.dryRun) {
p.log.info('Dry run — no changes will be made.')
p.note(
`Would run: npx drizzle-kit generate --custom --name=${migrationName}\nWould download EQL install SQL from GitHub\nWould write SQL to migration file in ${outDir}`,
'Dry Run',
)
p.outro('Dry run complete.')
return
}

let generatedMigrationPath: string | undefined

// Step 1: Generate a custom Drizzle migration
s.start('Generating custom Drizzle migration...')

try {
execSync(`npx drizzle-kit generate --custom --name=${migrationName}`, {
stdio: 'pipe',
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file structure and confirm the file exists
ls -la packages/stack-forge/src/commands/install.ts
wc -l packages/stack-forge/src/commands/install.ts

Repository: cipherstash/stack

Length of output: 203


🏁 Script executed:

# Read the relevant section to see the actual code
cat -n packages/stack-forge/src/commands/install.ts | sed -n '100,135p'

Repository: cipherstash/stack

Length of output: 1497


🏁 Script executed:

# Check how migrationName is derived - look for options.name
cat -n packages/stack-forge/src/commands/install.ts | sed -n '1,110p' | grep -n "migrationName\|options.name"

Repository: cipherstash/stack

Length of output: 170


🏁 Script executed:

# Look for all execSync calls in the file to understand context
rg -n 'execSync' packages/stack-forge/src/commands/install.ts -B2 -A4

Repository: cipherstash/stack

Length of output: 536


🏁 Script executed:

# Check imports to see what's currently imported from child_process
rg -n "import.*from.*child_process\|from 'node:child_process'" packages/stack-forge/src/commands/install.ts -A2 -B2

Repository: cipherstash/stack

Length of output: 43


🏁 Script executed:

# Look for other shell execution patterns that might have similar issues
rg -n 'execSync|exec\(|spawn\(' packages/stack-forge/src/commands/install.ts -B2 -A3

Repository: cipherstash/stack

Length of output: 428


🏁 Script executed:

# Also check if there are other string-interpolated command constructs
rg -n '\$\{.*\}' packages/stack-forge/src/commands/install.ts | head -20

Repository: cipherstash/stack

Length of output: 901


Use execFileSync with array args and pass --out=${outDir} explicitly to avoid command injection.

Line 125 interpolates the user-supplied migrationName directly into a shell string passed to execSync, creating a command injection vulnerability. Additionally, outDir is computed on line 107 but never passed to the drizzle-kit command, so the --out flag is currently ignored.

Proposed fix
-import { execSync } from 'node:child_process'
+import { execFileSync } from 'node:child_process'
@@
-    execSync(`npx drizzle-kit generate --custom --name=${migrationName}`, {
-      stdio: 'pipe',
-      encoding: 'utf-8',
-    })
+    execFileSync(
+      'npx',
+      [
+        'drizzle-kit',
+        'generate',
+        '--custom',
+        `--name=${migrationName}`,
+        `--out=${outDir}`,
+      ],
+      {
+        stdio: 'pipe',
+        encoding: 'utf-8',
+        shell: false,
+      },
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/commands/install.ts` around lines 107 - 126, Replace
the unsafe execSync call that interpolates migrationName with a call to
execFileSync (from child_process) using an argument array so no shell
interpolation occurs; pass migrationName as a separate arg and include the
computed outDir via the --out=<outDir> flag (e.g. args including "generate",
"--custom", "--name", migrationName, `--out=${outDir}`) and preserve stdio
handling; update the symbol execSync to execFileSync and ensure migrationName
and outDir variables are used as discrete args rather than concatenated into a
single shell string.

Comment on lines +33 to +42
await client.query(`
UPDATE eql_v2_configuration SET state = 'inactive'
`)

await client.query(
`
INSERT INTO eql_v2_configuration (state, data) VALUES ('active', $1)
`,
[encryptConfig],
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make config state transition atomic and scoped to active rows.

Lines 33-42 perform a global state rewrite plus insert without a transaction. If insert fails after update, you can end up with no active config. Also, updating all rows (not just state='active') can corrupt lifecycle history.

Proposed fix
-    s.start('Updating eql_v2_configuration...')
-    await client.query(`
-      UPDATE eql_v2_configuration SET state = 'inactive'
-    `)
-
-    await client.query(
-      `
-        INSERT INTO eql_v2_configuration (state, data) VALUES ('active', $1)
-      `,
-      [encryptConfig],
-    )
+    s.start('Updating eql_v2_configuration...')
+    await client.query('BEGIN')
+    await client.query(`
+      UPDATE eql_v2_configuration
+      SET state = 'inactive'
+      WHERE state = 'active'
+    `)
+    await client.query(
+      `INSERT INTO eql_v2_configuration (state, data) VALUES ('active', $1)`,
+      [encryptConfig],
+    )
+    await client.query('COMMIT')
     s.stop('Updated eql_v2_configuration.')
@@
   } catch (error) {
+    await client.query('ROLLBACK').catch(() => {})
     s.stop('Failed.')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/commands/push.ts` around lines 33 - 42, Wrap the two
queries that flip the active config in a single database transaction using the
same client (e.g., BEGIN/COMMIT/ROLLBACK around the queries) so the
update+insert are atomic; change the UPDATE to only affect currently active rows
(add WHERE state = 'active') instead of updating all rows, and ensure you
ROLLBACK the transaction on any error before rethrowing; locate this logic
around client.query calls in push.ts where client and encryptConfig are used.


if (!fs.existsSync(resolvedPath)) {
console.error(
`Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "encryptClient" path in your ${CONFIG_FILENAME}.`,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix the config key name in the error guidance.

Line 149 says to check "encryptClient" in stash.config.ts, but the actual config field is client.

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

In `@packages/stack-forge/src/config/index.ts` at line 149, The error message that
references the missing encrypt client file uses the wrong config key name;
update the error guidance string (the template that currently mentions
"encryptClient") to instruct users to check the "client" field in the
CONFIG_FILENAME instead. Locate the message construction that includes
resolvedPath and CONFIG_FILENAME (the error string around resolvedPath) and
replace "encryptClient" with "client" so the guidance matches the actual config
key.

Copy link

@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: 2

♻️ Duplicate comments (2)
packages/stack-forge/src/config/index.ts (2)

149-149: ⚠️ Potential issue | 🟡 Minor

Fix the config key name in the error message (Line 149).

The guidance says "encryptClient", but the config field is client.

Suggested fix
-      `Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "encryptClient" path in your ${CONFIG_FILENAME}.`,
+      `Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "client" path in your ${CONFIG_FILENAME}.`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/config/index.ts` at line 149, The error message
built using CONFIG_FILENAME and resolvedPath incorrectly references the
"encryptClient" config key; update the message string to reference the actual
config field "client" (i.e., change "encryptClient" to "client") where the error
is constructed so the message reads something like Error: Encrypt client file
not found at ${resolvedPath}\n\nCheck the "client" path in your
${CONFIG_FILENAME}. Ensure you modify the string near the code that constructs
the error (using resolvedPath and CONFIG_FILENAME) so logs point to the correct
config key.

112-114: ⚠️ Potential issue | 🟠 Major

Avoid logging raw error objects; use sanitized diagnostics only.

console.error(error) can print plaintext from thrown messages/stack traces. Prefer sanitized output (e.g., error class/code only).

Suggested fix
   } catch (error) {
     console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`)
-    console.error(error)
+    const errorName = error instanceof Error ? error.name : 'UnknownError'
+    console.error(`Cause: ${errorName}`)
     process.exit(1)
   }
...
   } catch (error) {
     console.error(
       `Error: Failed to load encrypt client file at ${resolvedPath}\n`,
     )
-    console.error(error)
+    const errorName = error instanceof Error ? error.name : 'UnknownError'
+    console.error(`Cause: ${errorName}`)
     process.exit(1)
   }

As per coding guidelines, **/*.{ts,tsx,js,jsx}: Do NOT log plaintext; the library never logs plaintext by design and logs should never leak sensitive data.

Also applies to: 163-167

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

In `@packages/stack-forge/src/config/index.ts` around lines 112 - 114, The current
console.error calls (the one that logs `Error: Failed to load ${CONFIG_FILENAME}
at ${configPath}` followed by `console.error(error)`) must not print the raw
error object; replace the raw dump with a sanitized diagnostic containing only
non-sensitive fields (e.g., error.name and/or error.code and a short,
non-sensitive context message) and omit stack/message/plaintext that could leak
secrets. Update the logging around CONFIG_FILENAME/configPath to emit a safe
string/structured object (e.g., `{ context: "load-config", file:
CONFIG_FILENAME, path: configPath, error: { name: error.name, code: error.code }
}`) and apply the same change to the other identical console.error usage at the
later block (lines ~163-167). Ensure you do not log error.message or
error.stack.
🤖 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/stack-forge/src/config/index.ts`:
- Around line 44-49: stashConfigSchema currently only defines databaseUrl and
client so optional StashConfig properties workspaceId and clientAccessKey are
dropped when parsed; update stashConfigSchema to include workspaceId and
clientAccessKey (e.g., z.string().optional() or appropriate types) so parsed
output retains those values and matches the StashConfig shape used elsewhere
(ensure the parser/usage still validates defaults for client and preserves
optional fields).
- Around line 145-152: The code resolves encryptClientPath relative to
process.cwd(), causing wrong resolution when loadStashConfig() found a parent
config; update loadEncryptConfig() to resolve the client path relative to the
located config file directory instead of process.cwd(): use the directory of the
discovered stash config (e.g., path.dirname(stashConfigPath) or the config file
path returned by loadStashConfig()) when computing resolvedPath for
encryptClientPath (refer to symbols loadEncryptConfig, loadStashConfig,
encryptClientPath, resolvedPath, CONFIG_FILENAME).

---

Duplicate comments:
In `@packages/stack-forge/src/config/index.ts`:
- Line 149: The error message built using CONFIG_FILENAME and resolvedPath
incorrectly references the "encryptClient" config key; update the message string
to reference the actual config field "client" (i.e., change "encryptClient" to
"client") where the error is constructed so the message reads something like
Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "client"
path in your ${CONFIG_FILENAME}. Ensure you modify the string near the code that
constructs the error (using resolvedPath and CONFIG_FILENAME) so logs point to
the correct config key.
- Around line 112-114: The current console.error calls (the one that logs
`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}` followed by
`console.error(error)`) must not print the raw error object; replace the raw
dump with a sanitized diagnostic containing only non-sensitive fields (e.g.,
error.name and/or error.code and a short, non-sensitive context message) and
omit stack/message/plaintext that could leak secrets. Update the logging around
CONFIG_FILENAME/configPath to emit a safe string/structured object (e.g., `{
context: "load-config", file: CONFIG_FILENAME, path: configPath, error: { name:
error.name, code: error.code } }`) and apply the same change to the other
identical console.error usage at the later block (lines ~163-167). Ensure you do
not log error.message or error.stack.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 681943b8-4688-48a0-8703-f9cd92380364

📥 Commits

Reviewing files that changed from the base of the PR and between 3a7d190 and bcfeeb8.

📒 Files selected for processing (1)
  • packages/stack-forge/src/config/index.ts

Comment on lines +44 to +49
const stashConfigSchema = z.object({
databaseUrl: z
.string({ required_error: 'databaseUrl is required' })
.min(1, 'databaseUrl must not be empty'),
client: z.string().default(DEFAULT_ENCRYPT_CLIENT_PATH),
})
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

stashConfigSchema omits declared config fields and drops them from resolved output.

StashConfig declares workspaceId and clientAccessKey, but validation only keeps databaseUrl and client. Any provided values for those optional fields are lost after parsing.

Suggested fix
 const stashConfigSchema = z.object({
   databaseUrl: z
     .string({ required_error: 'databaseUrl is required' })
     .min(1, 'databaseUrl must not be empty'),
+  workspaceId: z.string().optional(),
+  clientAccessKey: z.string().optional(),
   client: z.string().default(DEFAULT_ENCRYPT_CLIENT_PATH),
 })
📝 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
const stashConfigSchema = z.object({
databaseUrl: z
.string({ required_error: 'databaseUrl is required' })
.min(1, 'databaseUrl must not be empty'),
client: z.string().default(DEFAULT_ENCRYPT_CLIENT_PATH),
})
const stashConfigSchema = z.object({
databaseUrl: z
.string({ required_error: 'databaseUrl is required' })
.min(1, 'databaseUrl must not be empty'),
workspaceId: z.string().optional(),
clientAccessKey: z.string().optional(),
client: z.string().default(DEFAULT_ENCRYPT_CLIENT_PATH),
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/config/index.ts` around lines 44 - 49,
stashConfigSchema currently only defines databaseUrl and client so optional
StashConfig properties workspaceId and clientAccessKey are dropped when parsed;
update stashConfigSchema to include workspaceId and clientAccessKey (e.g.,
z.string().optional() or appropriate types) so parsed output retains those
values and matches the StashConfig shape used elsewhere (ensure the parser/usage
still validates defaults for client and preserves optional fields).

Comment on lines +145 to +152
const resolvedPath = path.resolve(process.cwd(), encryptClientPath)

if (!fs.existsSync(resolvedPath)) {
console.error(
`Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "encryptClient" path in your ${CONFIG_FILENAME}.`,
)
process.exit(1)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resolve client path relative to the located config file, not only process.cwd().

If the command runs from a subdirectory, loadStashConfig() can find a parent stash.config.ts, but loadEncryptConfig() resolves client from current working directory and can point to the wrong file.

Suggested fix
 export async function loadEncryptConfig(
   encryptClientPath: string,
 ): Promise<EncryptConfig | undefined> {
-  const resolvedPath = path.resolve(process.cwd(), encryptClientPath)
+  const configPath = findConfigFile(process.cwd())
+  const configDir = configPath ? path.dirname(configPath) : process.cwd()
+  const resolvedPath = path.resolve(configDir, encryptClientPath)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-forge/src/config/index.ts` around lines 145 - 152, The code
resolves encryptClientPath relative to process.cwd(), causing wrong resolution
when loadStashConfig() found a parent config; update loadEncryptConfig() to
resolve the client path relative to the located config file directory instead of
process.cwd(): use the directory of the discovered stash config (e.g.,
path.dirname(stashConfigPath) or the config file path returned by
loadStashConfig()) when computing resolvedPath for encryptClientPath (refer to
symbols loadEncryptConfig, loadStashConfig, encryptClientPath, resolvedPath,
CONFIG_FILENAME).

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