Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Added

- Vite plugin `cedarPgDev()` (`@cedarjs/pg/vite-plus`): status panel on listen + shortcuts `d` (status) / `p` (Prisma or Drizzle Studio)
- CLI: `cedarpg status` (human + `--json`) and `cedarpg studio` (`--prisma` / `--drizzle`)
- Public `resolveDevStatus` / `formatDevStatus` for scripting

### Fixed

- TEMPLATE `cloneWorkerDatabase`: default clone name is unique per call (`<worker>_<pid>_<time>`) so Jest `setupFiles` (module reload per file) no longer hits `database already exists` on `_c_<workerId>`
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts
cedarpg run --mode=test -- vitest run
cedarpg dispose --mode=test
cedarpg print-url --mode=dev
cedarpg status --mode=dev # lease name, port, DATABASE_URL, env path
cedarpg studio --mode=dev # open Prisma or Drizzle Kit Studio (--prisma / --drizzle)
cedarpg gc # drop DBs whose worktree root is gone (uses ~/.cedarpg/registry)
```

Expand Down Expand Up @@ -170,9 +172,10 @@ Fallbacks when you cannot wrap with `run`: `loadDevEnv({ overwrite: true })` or
```ts
// vite.config.ts
import { defineConfig } from "vite-plus";
import { cedarPgTasks } from "@cedarjs/pg/vite-plus";
import { cedarPgTasks, cedarPgDev } from "@cedarjs/pg/vite-plus";

export default defineConfig({
plugins: [cedarPgDev()],
run: {
tasks: {
...cedarPgTasks(),
Expand All @@ -191,6 +194,18 @@ export default defineConfig({
});
```

`cedarPgDev()` does **not** acquire — keep `dependsOn: ['db:acquire']`. On listen it prints a
status panel (TTY, non-CI). Vite CLI shortcuts (`key` then Enter; also listed under `h`):

| Key | Action |
| --- | ---------------------------------------------------------------------- |
| `d` | Reprint cedar-pg status (name, port, `DATABASE_URL`, env file) |
| `p` | Open Prisma Studio or Drizzle Kit Studio with the lease `DATABASE_URL` |

Options: `cedarPgDev({ mode, root, studio: "prisma" \| "drizzle" \| false })`. Studio auto-detects
from the project (`prisma` preferred when both are present). Use `cedarpg status` / `cedarpg studio`
for Nx and other non-Vite hosts.

## Vitest / Jest adapters

```ts
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,17 @@
"@types/node": "^22.15.0",
"@types/pg": "^8.15.4",
"typescript": "^5.8.3",
"vite": "^8.2.0",
"vite-plus": "^0.2.4"
},
"peerDependencies": {
"vite": ">=5.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
},
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "4.1.9"
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 117 additions & 0 deletions src/adapters/studio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { createRequire } from "node:module";
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";

export type StudioKind = "prisma" | "drizzle";

export type DetectStudioOptions = {
root: string;
/** Force a kind; `false` disables. Omit to auto-detect (prisma wins if both). */
prefer?: StudioKind | false;
};

export type DetectedStudio = {
kind: StudioKind;
/** Absolute path to the CLI entry, or null when falling back to npx. */
bin: string | null;
command: string;
args: string[];
};

type PkgJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};

function readPackageJson(root: string): PkgJson | null {
const file = join(root, "package.json");
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, "utf8")) as PkgJson;
} catch {
return null;
}
}

function hasDep(pkg: PkgJson | null, name: string): boolean {
if (!pkg) return false;
return Boolean(
pkg.dependencies?.[name] || pkg.devDependencies?.[name] || pkg.optionalDependencies?.[name],
);
}

function tryResolveBin(root: string, packageName: string): string | null {
try {
const require = createRequire(pathToFileURL(join(root, "package.json")).href);
const pkgJsonPath = require.resolve(`${packageName}/package.json`);
const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as {
bin?: string | Record<string, string>;
};
const binField = pkg.bin;
let rel: string | undefined;
if (typeof binField === "string") rel = binField;
else if (binField && typeof binField === "object") {
rel = binField[packageName] ?? Object.values(binField)[0];
}
if (!rel) return null;
const abs = join(dirname(pkgJsonPath), rel);
return existsSync(abs) ? abs : null;
} catch {
return null;
}
}

function prismaCommand(root: string): DetectedStudio {
const bin = tryResolveBin(root, "prisma");
if (bin) return { kind: "prisma", bin, command: bin, args: ["studio"] };
return { kind: "prisma", bin: null, command: "npx", args: ["prisma", "studio"] };
}

function drizzleCommand(root: string): DetectedStudio {
const bin = tryResolveBin(root, "drizzle-kit");
if (bin) return { kind: "drizzle", bin, command: bin, args: ["studio"] };
return { kind: "drizzle", bin: null, command: "npx", args: ["drizzle-kit", "studio"] };
}

function packagePresent(root: string, name: string): boolean {
return hasDep(readPackageJson(root), name) || Boolean(tryResolveBin(root, name));
}

/** Detect Prisma Studio or Drizzle Kit Studio from the project root. */
export function detectStudio(options: DetectStudioOptions): DetectedStudio | null {
if (options.prefer === false) return null;

if (options.prefer === "prisma") return prismaCommand(options.root);
if (options.prefer === "drizzle") return drizzleCommand(options.root);

// Auto: prefer prisma when both present.
if (packagePresent(options.root, "prisma")) return prismaCommand(options.root);
if (packagePresent(options.root, "drizzle-kit")) return drizzleCommand(options.root);
return null;
}

export type OpenStudioOptions = {
root: string;
databaseUrl: string;
studio: DetectedStudio;
};

/**
* Spawn Studio detached with DATABASE_URL from the cedar-pg lease.
* Returns the child process; caller may ignore it.
*/
export function openStudio(options: OpenStudioOptions): ChildProcess {
const { root, databaseUrl, studio } = options;
const child = spawn(studio.command, studio.args, {
cwd: root,
env: { ...process.env, DATABASE_URL: databaseUrl },
detached: true,
stdio: "ignore",
shell: studio.bin === null,
});
child.unref();
return child;
}
99 changes: 99 additions & 0 deletions src/adapters/vite-dev-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Plugin, ViteDevServer } from "vite";
import { CLI_NAME } from "../core/constants.ts";
import { formatDevStatus, resolveDevStatus } from "../core/status.ts";
import type { DbMode } from "../core/naming.ts";
import { detectStudio, openStudio, type StudioKind } from "./studio.ts";

export type CedarPgDevOptions = {
mode?: DbMode;
root?: string;
/** Force Prisma/Drizzle Studio; `false` disables the `p` shortcut. Omit to auto-detect. */
studio?: StudioKind | false;
};

function shouldShowPanel(): boolean {
return Boolean(process.stdin.isTTY) && !process.env.CI;
}

function logStatus(server: ViteDevServer, options: CedarPgDevOptions): void {
const status = resolveDevStatus({ root: options.root, mode: options.mode ?? "dev" });
for (const line of formatDevStatus(status)) {
server.config.logger.info(line);
}
}

function tryOpenStudio(server: ViteDevServer, options: CedarPgDevOptions): void {
if (options.studio === false) {
server.config.logger.warn(`${CLI_NAME}: studio shortcut disabled`);
return;
}

const status = resolveDevStatus({ root: options.root, mode: options.mode ?? "dev" });
if (!status.ok) {
for (const line of formatDevStatus(status)) {
server.config.logger.warn(line);
}
return;
}

const studio = detectStudio({ root: status.root, prefer: options.studio });
if (!studio) {
server.config.logger.warn(
`${CLI_NAME}: no Prisma or Drizzle Studio found (install prisma or drizzle-kit)`,
);
return;
}

openStudio({ root: status.root, databaseUrl: status.databaseUrl, studio });
server.config.logger.info(
`${CLI_NAME}: opening ${studio.kind} studio (${studio.command} ${studio.args.join(" ")})`,
);
}

/**
* Vite / Vite+ plugin: print a cedar-pg status panel on listen and register
* CLI shortcuts (`d` status, `p` studio). Does **not** acquire — pair with
* `cedarPgTasks()` / `dependsOn: ['db:acquire']`.
*/
export function cedarPgDev(options: CedarPgDevOptions = {}): Plugin {
return {
name: "cedar-pg-dev",
configureServer(server) {
if (shouldShowPanel()) {
const print = (): void => {
logStatus(server, options);
};
if (server.httpServer) {
server.httpServer.once("listening", print);
} else {
// Middleware mode / late bind: still allow shortcuts; panel on demand via `d`.
}
}

const shortcuts = [
{
key: "d",
description: "show cedar-pg database status",
action(s: ViteDevServer) {
logStatus(s, options);
},
},
];

if (options.studio !== false) {
shortcuts.push({
key: "p",
description: "open Prisma/Drizzle studio (cedar-pg DATABASE_URL)",
action(s: ViteDevServer) {
tryOpenStudio(s, options);
},
});
}

server.bindCLIShortcuts({
print: false,
customShortcuts: shortcuts,
});
},
};
}
11 changes: 8 additions & 3 deletions src/adapters/vite-plus.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
/**
* Consumer adapter for external Vite+ projects.
*
* Merge into `vite.config.ts` `run.tasks` so `vp run test` / `vp run dev`
* depend on cedarpg acquire.
* - `cedarPgTasks()` — merge into `run.tasks` so `vp run test` / `vp run dev`
* depend on cedarpg acquire.
* - `cedarPgDev()` — Vite plugin: status panel + `d` / `p` shortcuts (no acquire).
*
* @example
* ```ts
* import { defineConfig } from 'vite-plus'
* import { cedarPgTasks } from '@cedarjs/pg/vite-plus'
* import { cedarPgTasks, cedarPgDev } from '@cedarjs/pg/vite-plus'
*
* export default defineConfig({
* plugins: [cedarPgDev()],
* run: {
* tasks: {
* ...cedarPgTasks(),
Expand Down Expand Up @@ -47,3 +49,6 @@ export {

export type CedarPgTaskDef = CedarPgLifecycleTarget;
export type CedarPgTasksOptions = CedarPgLifecycleTargetsOptions;

export { cedarPgDev } from "./vite-dev-plugin.ts";
export type { CedarPgDevOptions } from "./vite-dev-plugin.ts";
Loading
Loading