opensaas dev brings up the Dev database, generates, reconciles and spawns the app - #1218
Conversation
…s and runs the app `opensaas dev` becomes the dev loop (ADR-0063): it starts the Dev database on a free loopback port under `.opensaas/dev-db`, loading `vector` when the config declares the pgvector pack; runs `generate`; runs `prisma db update`; and spawns the app — `next dev`, or the command after `--` — with no `DATABASE_URL` injected, so the child resolves the database through the state file and reports `'dev-database'` provenance. A URL already in the environment is the Database escape: no sidecar starts and the environment passes through untouched. The database dies with the process, and the config is still watched. Every Prisma CLI spawn is asynchronous now, behind one `runPrismaCli` helper: a `spawnSync` blocks the event loop the socket server is served on and deadlocks the first `db update`. `contract emit` closes stdin as before; the boot `db update` inherits the terminal, so a destructive plan stops at Prisma's own consent prompt and the app is never started. Implements #1157. Part of #1125. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 4d77d89 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
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 |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| ignoreInitial: true, | ||
| }) | ||
| let database: DevDatabase | undefined | ||
| if (findDatabaseConnection({ cwd })?.provenance === 'env') { |
There was a problem hiding this comment.
The Database escape only looks at process.env, but every other participant loads the project .env.
findDatabaseConnection({ cwd }) reads process.env as the CLI process has it. Nothing in bin/opensaas.js, src/index.ts or dev.ts loads a .env. But the generated prisma.config.ts does (if (existsSync(envFile)) process.loadEnvFile(envFile) — see __snapshots__/prisma-config.test.ts.snap), and next dev loads .env / .env.local / .env.development on its own.
Scenario — the mainstream setup, since every example ships a .env.example with DATABASE_URL: a project keeps DATABASE_URL=postgres://…/myapp_dev in .env and the user runs opensaas dev.
- This check sees no variable, so the loop takes the else branch: it starts a PGlite sidecar, prints
Dev database listening on …, and writes.opensaas/dev-db.json. reconcile()spawnsprisma db update, whoseprisma.config.tsloads.envfirst — so it reconciles the user's real Postgres, not the sidecar just started.next devloads.envtoo, so the app resolves provenance'env'and opens a pool against the real database — losing exactly the single-connection binding and marker suppression the whole no-injection design exists to preserve.
So the loop announces a Dev database nobody uses, and the state file it leaves behind will silently answer any later process that doesn't load .env (a plain node seed.mjs) with an empty PGlite instead of the intended error. The escape check needs the same .env load the generated prisma.config.ts performs before it decides which branch to take.
| console.log(chalk.yellow('\n\nStopping dev mode...')) | ||
| watcher.close() | ||
| process.exit(0) | ||
| await generateCommand() |
There was a problem hiding this comment.
A failing generateCommand() hard-exits the process, so the Dev database started above is never stopped.
generateCommand() does not throw on failure — every one of its error branches, and its outer catch, ends in process.exit(1) (generate.ts). By this line the sidecar is already running: PGlite has .opensaas/dev-db open and .opensaas/dev-db.json is written.
Scenario: a config with an unsatisfiable needs, a config refusal, a contract emit failure — any of them — and the process dies at process.exit(1). stop() never runs, so PGlite is never close()d (its data directory is left without a clean shutdown) and the state file is left on disk.
The line below has the same shape: reconcile() does not catch, and runPrismaCli rejects when resolvePrismaBinary throws (prisma not installed — a plausible first-run state). That rejection propagates out of devCommand past both stop() calls and terminates the process on the unhandled rejection.
Wrapping everything from the startDevDatabase call to the child's exit in a try/finally { await stop() } would cover the throwing paths; the process.exit paths additionally need a process.on('exit') hook, or generateCommand needs to throw rather than exit when it is called as a library.
| const watcher = chokidar.watch(configPath, { persistent: true, ignoreInitial: true }) | ||
| watcher.on('change', async () => { | ||
| console.log(chalk.yellow('\nConfig changed, regenerating...\n')) | ||
| await generateCommand() |
There was a problem hiding this comment.
A regeneration failure from the watcher kills the loop and orphans the running app.
This handler was harmless before the loop grew a database and a child process: the old devCommand only watched and regenerated, so generateCommand()'s process.exit(1) just ended a watcher.
Now, scenario: the user is running opensaas dev, next dev is up, and they save an opensaas.config.ts with a typo or a config refusal (a mistyped db.indexes entry, an unsatisfiable needs). generateCommand() prints and calls process.exit(1). That takes down the parent immediately — stop() does not run, the in-process PGlite dies with it — while the spawned next dev is a separate process that keeps running, still attached to the terminal, now talking to a database that no longer exists. The user sees connection errors from an app they cannot easily tell is orphaned.
At minimum the handler should catch and report rather than let the regeneration take the loop (and the child) down with it.
| const forward = (signal: 'SIGINT' | 'SIGTERM'): void => { | ||
| if (child.exitCode === null && child.signalCode === null) child.kill(signal) | ||
| } | ||
| process.once('SIGINT', () => forward('SIGINT')) |
There was a problem hiding this comment.
Signals are only handled once the app child exists — the window before it is exactly where the loop asks the user a question.
These process.once registrations happen after spawnApp. Everything before them — startDevDatabase, generateCommand, and the interactive prisma db update — runs with Node's default SIGINT/SIGTERM behaviour, which terminates the process outright.
Scenario: a destructive plan on boot puts Prisma's consent prompt on the terminal (the whole point of the 'interactive' stdio). The user reads the drop list, decides no, and presses Ctrl-C — the most natural response to a prompt they don't want to answer. The terminal delivers SIGINT to the group, the dev process has no handler, and it dies without stop(): PGlite is never closed, and .opensaas/dev-db.json is left behind naming a dead pid. (readDevDatabaseState's liveness check absorbs the stale file, but the unclean PGlite shutdown of the persistent .opensaas/dev-db data directory is not covered by anything.)
Registering the signal handlers as soon as database is assigned — running stop() when there is no child yet — would close the window.
| const dataDir = path.join(cwd, DEV_DATABASE_DIR) | ||
| fs.mkdirSync(path.dirname(dataDir), { recursive: true }) | ||
|
|
||
| database = await startDevDatabase({ |
There was a problem hiding this comment.
Nothing stops a second opensaas dev from opening the same data directory.
The branch above only diverts on provenance 'env'. A live state file — another opensaas dev already running in this project — reports 'dev-database', which falls through to here.
Scenario: the user leaves opensaas dev running in one terminal and starts it again in another (a very ordinary mistake, and the loop gives no hint it is already up). The second call hands PGlite the same .opensaas/dev-db path, which dev-database.ts's own Known limits call out as unsupported: "PGlite opens a data directory in one process at a time." There is no lock to refuse it, so two processes write the same directory. It then overwrites .opensaas/dev-db.json with its own URL, so the first loop's app child and any second-terminal prisma db update are silently redirected to the second sidecar; and when the second stops, clearDevDatabaseState matches its own URL and deletes the record, leaving the still-running first sidecar unreachable through the lookup.
readDevDatabaseState already returns the live record here — refusing to boot (or adopting it) when one exists is cheap.
| const [file, ...args] = command | ||
| if (file === undefined) throw new Error('No app command to run.') | ||
|
|
||
| return spawn(file, args, { |
There was a problem hiding this comment.
The shell-free spawn cannot run the default command on Windows.
pathWithProjectBinaries is documented as making next dev "resolve without a shell", and prisma-cli.ts goes out of its way to handle win32, so the platform is in scope here.
On Windows, node_modules/.bin/next is next.cmd. libuv finds it via PATHEXT, and Node then refuses to execute a .cmd/.bat without shell: true (the CVE-2024-27980 fix), so spawn('next', ['dev']) fails with EINVAL. The child.once('error') handler below turns that into "Could not run next dev", i.e. the documented default invocation of opensaas dev never starts an app on Windows.
Either add the .cmd handling (resolve the shim explicitly, or shell: true on win32) or state the POSIX-only limit the way resolvePrismaBinary does.
| // (ADR-0063) — a timer that never got to run is what that looks like. | ||
| expect(order).toEqual(['timer', 'prisma']) | ||
| expect(result.exitCode).toBe(0) | ||
| expect(result.output).toContain('8.0.0-rc') |
There was a problem hiding this comment.
This assertion pins the test to a prerelease version string. The test is about ordering and exit status; the moment the pinned prisma moves to 8.0.0 proper (or 8.1.x), --version no longer contains 8.0.0-rc and this fails for a reason that has nothing to do with what it verifies. expect(result.output).toContain('prisma') — or dropping the line — keeps the acceptance criterion without the dependency-bump tripwire.
borisno2
left a comment
There was a problem hiding this comment.
Code review — verdict: REQUEST CHANGES
(Posted as a Comment review: GitHub does not accept a formal review event from the PR's own author. The verdict above is the review's verdict.)
The shape of this is right — one async spawn implementation instead of two, the no-injection binding, provenance-driven escape, and an integration test that drives the real binary on a real project rather than the watcher's internals. Two things block it: a .env split-brain that defeats the no-injection design in the most common project layout, and process-lifecycle paths that exit without stop(). Seven line-level findings are posted inline; the summary and the additional points from a second pass are below.
Blocking
1. The Database escape misses .env, and the two halves of the loop then disagree — packages/cli/src/commands/dev.ts:134 (high, inline)
findDatabaseConnection({ cwd }) reads process.env only. The generated prisma.config.ts calls process.loadEnvFile('.env') before resolving (packages/cli/src/generator/prisma-config.ts:65-67), and next dev loads .env/.env.local itself. With DATABASE_URL in .env — which is exactly what every example's .env.example prescribes — opensaas dev takes the dev-database branch and starts a PGlite sidecar, while the prisma db update one line later reconciles the user's real Postgres, and the spawned app resolves 'env'. The sidecar is reconciled against nothing, the single-connection binding the no-injection design exists for is lost, and DDL lands on a database the user did not point the loop at. The escape check must resolve through the same env-file loading the rest of the loop does.
2. Failure paths exit the process without stop() — dev.ts:167, :156, :190 (medium, inline)
generateCommand()never throws; every failure ends inprocess.exit(1). Called at:167the Dev database is already up, so a config refusal kills the process with PGlite unclosed and the state file left behind.runPrismaClirejects whenprismais not installed (prisma-cli.ts:92throws synchronously inside anasyncfunction). That rejection escapesdevCommandpast bothstop()calls.- The watcher's
generateCommand()at:156exits mid-session: in-process PGlite dies, the spawnednext devis orphaned against a database that no longer exists. - SIGINT/SIGTERM are only installed at
:190, after the child spawns. Ctrl-C at Prisma's consent prompt — the natural way to decline a destructive plan — skipsstop()entirely and leaves the persistent data directory without a clean PGlite shutdown.
A single try { … } finally { await stop() } around everything from the database start, plus signal handlers registered as soon as the database is up, closes all four.
3. A second opensaas dev in the same project opens the same data directory — dev.ts:145 (medium, inline)
The first loop's state file gives provenance 'dev-database', not 'env', so the second run falls straight through the escape and opens .opensaas/dev-db — which dev-database.ts's own Known limits say only one process may open. It also overwrites the state file (redirecting the first loop's clients) and deletes it on stop.
Non-blocking
env: { ...process.env, PATH: … }(dev.ts:82) — the injection side is correct: nothing is added,DATABASE_URLis genuinely absent, and the escape branch leaves the environment untouched (dev-loop.test.ts:129/:179assert both). But on Windows the variable isPath; the spread reproduces that key literally and then adds a separatePATH, so the child can readPathand never see the project's.bin. Pair this with thenext.cmdspawn issue already flagged inline atdev.ts:79.- The
spawnAppdocblock says "spawned with the environment as it stands" (dev.ts:68-74) while the code below it modifiesPATH. The warning it carries is the right kind — the obvious "helpful" edit here is to injectDATABASE_URL— but the first sentence is already false. - Comment discipline.
DEFAULT_APP_COMMAND's docblock (dev.ts:16) restates['next', 'dev']; delete it. ThedevCommanddocblock (:104-115) is three paragraphs of rationale that ADR-0063 already owns — it is not a consumer-facing surface, so trim it to the line or two that orient a reader and let the ADR reference carry the rest.prisma-cli.ts's long header earns its place (pnpm hoisting, the Windowssh-shim constraint, an explicit Known limits block), as does themkdir-not-recursive note atdev.ts:140. findDatabaseConnectionis correctly typed —readonlyfields, a string-literal provenance union, noanyand no casts — andrequire(packageJson)inprisma-cli.ts:31is narrowed by annotation rather than assertion, which is the right call. Minor:findDatabaseConnectionis now a pure one-line alias of the privatelookupDatabaseUrl; with the internal export in place, one of the two can go.emitContractconversion is clean. Every call site is awaited —generate.ts:292,contract-emission.test.ts:41,emit-type-fixture.ts:74— andrunPrismaCliresolves on'close', not'exit', so captured output is complete. No dropped promises.- **
prisma-cli.test.ts:44** pinstoContain('8.0.0-rc')`; the next Prisma bump breaks it for a reason unrelated to the ordering criterion under test (inline). - Coverage gap.
dev-loop.test.tsis genuinely good, but nothing exercises the lifecycle risk this PR introduces: no test kills the loop with SIGINT and asserts the child dies and the state file is gone, and none covers a failure between database start and app spawn. Given findings 1–3 all live on those paths, at least the SIGINT case is worth having.
… child The Dev-database branch declined to inject a URL but never removed one the process had inherited, so an ambient DATABASE_URL leaked into the app child and put the generated runtime on the 'env' provenance branch, where it would deadlock on Prisma's first-use contract-marker read. The escape branch still passes the environment through untouched. The unit suite now pins DATABASE_URL in setup and restores it in teardown, so its outcome no longer depends on the machine's environment, and covers the escape branch as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
CI's Cause: the Dev-database branch built the app child's environment from Changed:
Verified: |
Coverage Report for Core Package Coverage (./packages/core)
File Coverage
|
||||||||||||||||||||||||||||||||||||||
Coverage Report for UI Package Coverage (./packages/ui)
File CoverageNo changed files found. |
Coverage Report for CLI Package Coverage (./packages/cli)
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Coverage Report for Auth Package Coverage (./packages/auth)
File CoverageNo changed files found. |
Coverage Report for Storage Package Coverage (./packages/storage)
File CoverageNo changed files found. |
Coverage Report for RAG Package Coverage (./packages/rag)
File CoverageNo changed files found. |
Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)
File CoverageNo changed files found. |
Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)
File CoverageNo changed files found. |
…ry exit path `findDatabaseConnection` read only `process.env`, while the generated `prisma.config.ts` and `next dev` both load the project's `.env`. A `DATABASE_URL` there therefore started a sidecar nobody used and pointed `prisma db update` at the developer's real database. The loop now loads `.env` before deciding the escape, so the check and what it predicts see the same connection; a shell variable still outranks the file. The boot sequence is wrapped in `try`/`finally` and the signal handlers are installed before the database starts, so a rejecting Prisma CLI, a failed reconcile and Ctrl-C at the consent prompt all reach `stop()`. A synchronous `exit` listener covers what `finally` cannot: `generateCommand` reports a refusal by exiting the process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — all three findings folded in on 1. HIGH — the Database escape missed Fixed in the loop rather than in Scope note: only Two tests: 2. MEDIUM — exits without One correction to the suggested remedy: Coverage: a test asserts 3. LOW — comments. Routed elsewhere, not touched here: two
|
borisno2
left a comment
There was a problem hiding this comment.
Re-review — verdict: APPROVE
(Posted as a Comment review: GitHub does not accept a formal Approve event from the PR's own author. The verdict above is this review's verdict.)
Checked ea494af7 against the three blocking/major findings, and re-ran the tests both as they stand and with each fix reverted. All three are resolved, the new tests genuinely discriminate, and the diff since 544fe1ef is confined to dev.ts, its two test files and the changeset — nothing routed elsewhere was smuggled in.
1. HIGH — the Database escape missed .env → RESOLVED
devCommand now calls loadProjectEnvFile(cwd) at packages/cli/src/commands/dev.ts:127, before the escape decision at :172, using process.loadEnvFile behind an existsSync guard (:59-62) — the same call and the same single file the generated prisma.config.ts makes, so the loop is exactly in step with its own prisma db update rather than ahead of it. No new dependency.
The two claims that mattered both hold on inspection and in practice:
- The escape sees what its children see.
lookupDatabaseUrlconsultsDIRECT_DATABASE_URL/DATABASE_URLbefore the state file (packages/core/src/db/url.ts:44-49), so a URL arriving from.envyields'env'and takes the escape branch. Nothing mutates the environment between:127and thespawnAppat:210. - Shell outranks
.env. Verified on Node 24.10: withDATABASE_URLin the shell and in.env,process.loadEnvFileleaves the shell value in place and still imports the file's other keys. This is now covered by a test rather than only asserted —dev.test.ts:158writes a.envwith nothing in the shell, anddev.test.ts:130/:142cover the shell-set cases.
The seam with the earlier delete env.DATABASE_URL change is sound. In the sidecar branch the .env cannot have contributed a non-empty DATABASE_URL/DIRECT_DATABASE_URL — that would have produced 'env' and taken the escape — so loadProjectEnvFile cannot smuggle a URL into the child, and the delete at :91 still catches an empty-string or ambient value. I confirmed this end to end: tests/dev-loop.test.ts:187 runs the real binary with both connection variables stripped from its environment and a .env pointing at a separate database, and asserts PROVENANCE env, real rows, and no .opensaas/dev-db on disk.
Discrimination checked by reverting. Deleting the loadProjectEnvFile(cwd) call and rebuilding: dev.test.ts:173 fails (startDevDatabase called), and the integration test fails with the app talking to the sidecar instead of the .env database — the exact split-brain the finding described.
2. MEDIUM — exits without stop() → RESOLVED
dev.ts:171-226: everything from the escape decision to the child's exit is inside one try, with stop() in the finally; SIGINT/SIGTERM/exit handlers are installed at :167-169, before the database starts, and removed at :221-223.
- Handlers are removed on the normal path.
process.offfor all three in thefinally;dev.test.ts:189pinslistenerCount('SIGINT')at baseline+1 insidestartDevDatabaseand back to baseline on return, so a repeated in-process run leaks nothing. - Double
stop()is safe.DevDatabase.stopis guarded by astoppedflag (packages/core/src/db/dev-database.ts:194-206) and is in any case only called from the singlefinally. - The
'exit'listener is synchronous-only.child.killandfs.rmSync(:159-162) — no promise is created and dropped there. - The
process.exitlimitation claim is correct and honestly scoped.process.exitdoes not unwind, so notry/finallyplacement can reach an asyncstop()ongenerateCommand's exit paths. The comment at:154-158says exactly that and confines itself to what the listener does cover, and #1223 carries the real fix. That mitigation also closes the orphaned-next devcase I raised at:156— the child is killed synchronously before the process goes.
Reverting the fix (signals registered after the spawn, no stop() in finally) fails four tests, including the runPrismaCli-rejection case at dev.test.ts:178 and the handler-timing case at :189.
3. LOW — comment discipline → RESOLVED
DEFAULT_APP_COMMAND is a bare const at dev.ts:16. devCommand's three paragraphs are one orientation line at :114 pointing at ADR-0063. The comments added in this round all carry something the code cannot: the loadProjectEnvFile docblock names the two other participants that load .env and the non-obvious no-overwrite semantics; the 'exit' block names the process.exit constraint and its own limits; the signal-registration note names the default-SIGINT behaviour it exists to displace. No restatement introduced.
Also confirmed
- No
any, no type assertions in the new code.config-load.tsactually removes one (jiti.import<…>in place of the oldas { default: … }); the onlyas neverindev.test.ts:98is the pre-existingprocess.exitstub. - Routed-elsewhere findings genuinely untouched: no single-writer guard or
dataDirchange (#1198), noPathcasing or.cmdhandling inspawnApp(#1219), andprisma-cli.test.ts:44still pins8.0.0-rc. - CLI unit suite passes with and without an ambient
DATABASE_URL(171 tests insrc/), plus the.envintegration case against a real database.
Non-blocking residuals
- A second Ctrl-C is still an unclean exit. The handlers are
process.once, so after the firstSIGINTis consumed a second one gets Node's default termination, which runs neither thefinallynor the'exit'listener. That is the realistic sequence when the app child does not die on the first interrupt. Re-arming (or usingprocess.onplus the explicitoffyou already have) would close it. onExit'sfs.rmSync(database.stateFile)is less careful thanstop()'sclearDevDatabaseState(stateFile, url), which only clears the record when the URL still matches this run. On theprocess.exitpath with two loops in one project it would delete another run's record — squarely inside #1198's territory, so noting rather than blocking.- The changeset says the loop "shuts the database down on every exit path" while #1223 exists precisely because one path is not covered. The sentence that follows enumerates the three that are; tightening the claim would keep the release note honest.
…t unwind Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e.ts The base moved under this branch while it was in review, so GitHub could compute no merge ref and CI never ran for 300b17d. The one content conflict was `packages/core/src/internal.ts`, where this branch's `where/like.ts` re-exports and the base's `findDatabaseConnection` (#1218) were added at the same point; both are kept. #1220 landed the Where vocabulary from its own branch rather than adopting this branch's engine-owned escaper, leaving a second implementation: `containsPattern()` in `secured/vocabulary.ts`. It performed the same three replacements in the same order with the same wrapping as `likeContainsPattern`, so it is deleted and the `contains` case now calls the shared builder. One escaper, as ADR-0060 requires. #1220's Where-vocabulary and filter tests pass unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements #1157. Part of #1125.
opensaas devgrows from a config watcher into the dev loop of ADR-0063.What it does now
startDevDatabase— persistentdataDirat.opensaas/dev-db, a free loopback port, andvectorloaded when the config declares the pgvector pack (mapped fromdb.extensions, so a plugin-added pack counts too).generateCommand()path).prisma db updateagainst that database.next dev, or the command afteropensaas dev -- <cmd>— with noDATABASE_URLinjected. The child takes the state-file branch of the lookup and reports'dev-database'provenance, which is what the single-connection binding and marker suppression key off.PATHgains the project'snode_modules/.binsonextresolves without a shell.opensaas.config.ts, regenerating on change as before. The database is a foreground sidecar:stop()runs on child exit and on SIGINT/SIGTERM, which are forwarded to the child.DATABASE_URL(orDIRECT_DATABASE_URL) already set is the Database escape: no Dev database starts, the environment passes through untouched. The branch is decided by the lookup's own provenance, via a newfindDatabaseConnectionexport on@opensaas/stack-core/internal— no second copy of the variable list.The two riders
contract-emit.ts'sspawnSyncwas a deadlock waiting to happen once generation runs while the socket server is up. Rather than adding a second async path,resolvePrismaBinaryand the spawn moved into onerunPrismaClihelper (src/generator/prisma-cli.ts) andemitContractbecameasync, awaited bygenerateCommand—generatestandalone is unchanged in behaviour, and there is one spawn implementation, not two. The four test call sites were updated to await it.db updateruns withstdio: 'inherit', so the prompt reaches the user's terminal and the loop does not proceed past it; a refusal (or, with no terminal to ask,CLI.CONSENT_REQUIRED) leaves the database untouched, never starts the app, and exits non-zero. Mid-session staging, promotion andopensaas db updateare Staged reconcile on config change, destructive handling, app restart, and opensaas db update #1158's and are not built here.Tests
packages/cli/tests/dev-loop.test.tsdrives the realopensaas devbinary on a real fixture project and observes the database and the spawned app, never the watcher:DATABASE_URL: the app resolves'dev-database', sees no injected URL, inserts and reads a row, and findsvectorinstalled; the data directory persists and the state file is cleaned upsrc/generator/prisma-cli.test.tsruns the acceptance criterion directly: a timer scheduled beside a Prisma run fires first (aspawnSynccould not), and the spawn's stdio is['ignore', 'pipe', 'pipe']captured /'inherit'interactive.Verification
pnpm build,pnpm lint,pnpm manypkg fix,pnpm formatclean;@opensaas/stack-cli335/335 and@opensaas/stack-core1527/1528 (1 pre-existing skip) pass.🤖 Generated with Claude Code