diff --git a/.claude/commands/create-local-dev.md b/.claude/commands/create-local-dev.md deleted file mode 100644 index 9720fcbc0..000000000 --- a/.claude/commands/create-local-dev.md +++ /dev/null @@ -1,140 +0,0 @@ -Spin up a complete local development environment for KeeperHub. Follow these steps in order, checking each prerequisite before proceeding. - -## 1. Prerequisites Check - -Verify Docker is running: -```bash -docker info > /dev/null 2>&1 -``` -If Docker is not running, tell the user to start Docker Desktop and try again. - -Check if port 5432 is already in use: -```bash -lsof -i :5432 -``` -If something is already on 5432, check if it's a keeperhub-postgres container. If it is, reuse it. If it's something else, warn the user. - -## 2. Start Postgres - -Check for an existing keeperhub-postgres container: -```bash -docker ps -a --filter "name=keeperhub-postgres" --format "{{.Names}} {{.Status}}" -``` - -- If running: skip, reuse it -- If stopped: `docker start keeperhub-postgres` -- If not found: create it: -```bash -docker run -d --name keeperhub-postgres \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=workflow \ - -p 5432:5432 \ - postgres:16-alpine -``` - -Wait for Postgres to be ready: -```bash -sleep 3 && docker exec keeperhub-postgres pg_isready -``` - -## 3. Configure Environment - -Read the current `.env` file. Check if `DATABASE_URL` and `BETTER_AUTH_SECRET` are already set. - -If NOT set, add them at the top of `.env`: -``` -DATABASE_URL="postgres://postgres:postgres@localhost:5432/workflow" -BETTER_AUTH_SECRET="local-dev-secret-change-me" -BETTER_AUTH_URL="http://localhost:3000" -``` - -Do NOT overwrite existing values. Do NOT remove any existing env vars. - -## 4. Install Dependencies & Push Schema - -```bash -pnpm install -pnpm db:push -pnpm db:seed-chains -pnpm discover-plugins -``` - -## 5. Create Test User - -Use the Better Auth sign-up API to create a user with a proper password hash: -```bash -curl -s -X POST http://localhost:3000/api/auth/sign-up/email \ - -H "Content-Type: application/json" \ - -d '{"email":"dev@keeperhub.local","password":"testpass123","name":"Dev User"}' -``` - -NOTE: The dev server must be running for this step. If it's not running yet, start it first in the background with `pnpm dev`, wait for it to be ready, then create the user. - -If the user already exists, that's fine -- skip this step. - -After creating the user, mark email as verified and get the user ID: -```bash -docker exec keeperhub-postgres psql -U postgres -d workflow -c " - UPDATE users SET email_verified = true WHERE email = 'dev@keeperhub.local'; - SELECT id FROM users WHERE email = 'dev@keeperhub.local'; -" -``` - -## 6. Create Test Organization - -Using the user ID from step 5: -```bash -docker exec keeperhub-postgres psql -U postgres -d workflow -c " - INSERT INTO organization (id, name, slug, created_at) - VALUES ('test-org-001', 'Test Org', 'test-org', NOW()) - ON CONFLICT DO NOTHING; - - INSERT INTO member (id, organization_id, user_id, role, created_at) - VALUES ('mem-001', 'test-org-001', '', 'owner', NOW()) - ON CONFLICT DO NOTHING; - - UPDATE sessions SET active_organization_id = 'test-org-001' - WHERE user_id = ''; -" -``` - -Replace `` with the actual user ID from step 5. - -## 7. Start Dev Server (if not already running) - -```bash -pnpm dev -``` - -Run in background and wait for "Ready" message. - -## 8. Present Summary - -After everything is set up, present the user with this information: - -``` -Local Dev Environment Ready - - Postgres: localhost:5432 (Docker: keeperhub-postgres) - App: http://localhost:3000 - - Sign in: - Email: dev@keeperhub.local - Password: testpass123 - - Organization: Test Org (test-org-001) - - Useful commands: - pnpm dev - Start dev server - pnpm db:studio - Open Drizzle Studio (DB browser) - pnpm db:push - Push schema changes - pnpm discover-plugins - Re-register plugins after changes -``` - -## Important Notes - -- Do NOT commit `.env` changes -- the file is gitignored -- Do NOT create documentation files -- If any step fails, stop and tell the user what went wrong with the specific error -- The seed script at `scripts/seed-test-workflow.ts` can be used separately to create test workflows diff --git a/.claude/commands/dev-login.md b/.claude/commands/dev-login.md new file mode 100644 index 000000000..718ff13ce --- /dev/null +++ b/.claude/commands/dev-login.md @@ -0,0 +1,33 @@ +--- +description: Bootstrap the local DB, mint a session cookie, and open a signed-in Chromium window at localhost:3000 -- one command, no manual paste. +--- + +Run one command. It does the bootstrap, the mint, ensures the dev server +is serving `localhost:3000` (starting `pnpm dev` detached if nothing is +listening yet), the cookie injection, and opens a detached Chromium window +already signed in as `$1` (default `dev@keeperhub.local`): + +```bash +pnpm dev:login "${1:-dev@keeperhub.local}" +``` + +The window is a separate Chromium instance (its own user-data-dir under +`.claude/.dev-chrome-profile/`), so it does not touch the user's normal +Chrome. The terminal returns as soon as the browser launches. + +Hard refusals to respect: +- The script refuses to run if `DATABASE_URL` host is not local. Do not + edit that guard, and do not run this against staging or prod. +- Do not commit `.claude/.dev-session-cookie-LOCAL` or + `.claude/.dev-chrome-profile/` (gitignored). + +If the user wants only the cookie file (no browser) for a manual paste +into their normal Chrome, run instead: +```bash +KEEPERHUB_DEV_MINT=1 pnpm dev:mint-cookie "${1:-dev@keeperhub.local}" +``` + +If they only need the DB set up (no cookie, kh CLI only), run: +```bash +pnpm dev:bootstrap +``` diff --git a/.claude/skills/create-local-dev/SKILL.md b/.claude/skills/create-local-dev/SKILL.md new file mode 100644 index 000000000..741290b3d --- /dev/null +++ b/.claude/skills/create-local-dev/SKILL.md @@ -0,0 +1,80 @@ +--- +name: create-local-dev +description: Bring a fresh KeeperHub worktree to a signed-in Chromium window against the shared local Docker Postgres in one command, without going through the signup UI. Use when starting work in a new worktree, when "I'm not signed in to localhost:3000", or any time you need an authenticated browser session for local manual testing. Avoids the ~30 tool-call signup -> OTP -> MFA -> TOTP loop by reusing in-codebase session helpers, then auto-opens a Chromium window with the cookie already loaded. +--- + +# Local-dev one-command sign-in + +This skill is the fast path from a fresh worktree to a signed-in browser. + +It does not touch any production auth code: it composes Drizzle inserts +and the existing `signSessionCookieValue` / `hashSessionToken` helpers from +`lib/auth-session-token-hash.ts`, then loads the cookie into a Playwright +Chromium profile and launches it detached. + +Hostname guard refuses to run unless `DATABASE_URL` resolves to a local +Postgres host. The standalone `dev:mint-cookie` additionally requires +`KEEPERHUB_DEV_MINT=1`; `dev:login` sets that env var for its mint child +because invoking `dev:login` is itself the explicit acknowledgement. + +## When to invoke + +- You opened a new worktree and the app at `localhost:3000` is anonymous. +- The user asks to "log in locally" / "sign in for testing". +- A manual-test task needs a real authenticated session (workflow list, + org-scoped endpoints, anything that goes through `getDualAuthContext`). + +Do NOT invoke this skill against staging or prod. The script will refuse +on hostname grounds. Do not try to defeat that guard. + +## The one command + +```bash +pnpm dev:login +``` + +Optional override: `pnpm dev:login some-other@email`. Default is +`dev@keeperhub.local`. + +What it does, in order: +1. Asserts the local-DB hostname guard. +2. Runs `pnpm dev:bootstrap` (idempotent): backfills the drizzle journal + only if the schema was bootstrapped via `db:push`, runs `pnpm db:migrate`, + seeds the persistent e2e users plus a dev user/org/membership, pre-trusts + `127.0.0.1` + `::1` in `user_trusted_ips`, marks the dev user + `twoFactorEnabled=true` so the proxy MFA gate lets them through, + binds the local `kh` CLI token from `~/.config/kh/hosts.yml`, and + upserts 8 workflow fixtures (Manual / Schedule / Webhook / Event, + on+off, plus a soft-deleted row). +3. Mints a Better Auth session row (matches the shape of + `app/api/auth/oauth-mfa-finalize/route.ts`) and writes the signed cookie + to `.claude/.dev-session-cookie-LOCAL`. +4. Seeds that cookie into a Playwright-managed Chromium persistent profile + under `.claude/.dev-chrome-profile/` (gitignored). +5. Spawns Chromium detached against `http://localhost:3000` with that + profile. The terminal returns. The Chromium window is independent from + the user's normal Chrome (separate user-data-dir). + +Re-running `pnpm dev:login` just refreshes the cookie inside the same +profile, so the user can keep the window open across sessions. + +## Lower-level commands (rarely needed) + +- `pnpm dev:bootstrap` -- DB setup only, no cookie or browser. Use for CI + or when scripting against the seeded fixtures via the kh CLI. +- `KEEPERHUB_DEV_MINT=1 pnpm dev:mint-cookie ` -- mint a cookie file + only, no browser. Use when you want the signed value for manual paste + into another browser, or for headless cookie-driven scripts. + +## Boundaries + +- Do NOT modify `lib/auth.ts`, `lib/auth-session-token-hash.ts`, or any + `app/api/**` route to weaken the auth flow for local convenience. This + whole skill exists precisely so we never need to. +- Do NOT commit `.claude/.dev-session-cookie-LOCAL` or + `.claude/.dev-chrome-profile/` (gitignored already). +- Do NOT add an env override that lets the mint script run against a + non-local DB. There is no legitimate reason to forge a session against + staging or prod. +- Chromium is launched detached. The script exits as soon as the browser + is up; closing the script does not close the browser. diff --git a/.dockerignore b/.dockerignore index e420c9ca8..57c7a8362 100644 --- a/.dockerignore +++ b/.dockerignore @@ -37,6 +37,14 @@ analysis/ .cursor/ .probes/ +# Local-dev sign-in tooling (pnpm dev:login). Never run in production and +# dev-bootstrap.ts imports from tests/, which is excluded above. +scripts/seed/dev-bootstrap.ts +scripts/seed/fixtures/dev-workflows.ts +scripts/dev-mint-session.ts +scripts/dev-login.ts +scripts/dev-login-browser.ts + # Dev-only config files playwright.config.ts vitest.config.mts diff --git a/.gitignore b/.gitignore index b243c2301..0950757c6 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,12 @@ packages/*/dist/ .claude/test-*-output.txt .claude/worktrees/ +# Local dev session cookie minted by scripts/dev-mint-session.ts +.claude/.dev-session-cookie-LOCAL + +# Chromium profile seeded by scripts/dev-login.ts (cookies + cache only) +.claude/.dev-chrome-profile/ + # Playwright MCP screenshots .playwright/ .playwright-cli/ @@ -152,6 +158,8 @@ reports/ .mcp.json +.claude/.dev-server-LOCAL.log + # Understand-Anything: commit knowledge-graph.json + config.json + .understandignore. # Ignore meta (timestamp churn) and fingerprints (per-machine incremental cache). .understand-anything/intermediate/ diff --git a/CLAUDE.md b/CLAUDE.md index 138db3ace..45867c0c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,6 +202,59 @@ Note: a shell-set `DATABASE_URL` overrides the value in `.env` (drizzle.config.t --- +## Local Dev Sign-in + +To get a fresh worktree to a signed-in browser without going through the +signup -> OTP -> MFA -> TOTP UI loop, run one command: + +```bash +pnpm dev:login # default dev@keeperhub.local +pnpm dev:login some-other@example.com # any seeded email +``` + +This bootstraps the DB (idempotent), mints a Better Auth session via the +same helpers (`signSessionCookieValue`, `hashSessionToken`) the production +OAuth-MFA finalize path uses, ensures a dev server is serving +`http://localhost:3000` (reuses one if it is already up, otherwise spawns +`pnpm dev` detached -- logs to `.claude/.dev-server-LOCAL.log` -- and waits +for it to respond), seeds the signed cookie into a Playwright-managed +Chromium profile, and launches Chromium detached at the now-serving URL. +When a server is already running the terminal returns as soon as the +browser launches; on a cold start it blocks until the server is ready. The +Chromium instance has its own user-data-dir under +`.claude/.dev-chrome-profile/`, so it does not touch the user's normal +Chrome. + +Lower-level commands for headless / scripted use: + +- `pnpm dev:bootstrap` -- DB setup only. Backfills the drizzle journal + only if the schema was bootstrapped via `db:push`, runs `pnpm db:migrate`, + seeds the persistent e2e users plus a dev user/org, pre-trusts + `127.0.0.1` + `::1`, marks the dev user `twoFactorEnabled=true`, binds + the local `kh` CLI token from `~/.config/kh/hosts.yml`, and upserts 8 + workflow fixtures (Manual/Schedule/Webhook/Event triggers, on+off, plus + a soft-deleted row). +- `KEEPERHUB_DEV_MINT=1 pnpm dev:mint-cookie ` -- mints a cookie + file at `.claude/.dev-session-cookie-LOCAL` without opening a browser. + +**Hard boundaries -- do not relax these:** + +- All three scripts refuse to run unless `DATABASE_URL` host is + `localhost`, `127.0.0.1`, `::1`, `db`, or `postgres`. The standalone + `dev:mint-cookie` additionally requires `KEEPERHUB_DEV_MINT=1`; + `dev:login` sets that env var for its mint child because invoking + `dev:login` is itself the explicit acknowledgement. Do not add other + bypass envs. +- None of these scripts edit `lib/auth.ts`, + `lib/auth-session-token-hash.ts` (imported only), or any `app/api/**` + route. The whole point is to avoid any production runtime change for + local convenience. If a future task needs to weaken production auth, + do it in production auth and review it there -- not here. +- `.claude/.dev-session-cookie-LOCAL`, `.claude/.dev-chrome-profile/`, and + `.claude/.dev-server-LOCAL.log` are gitignored. Do not commit them. + +--- + ## Plugin Development **Context**: Building Web3 integrations for the workflow system. Plugins go in `plugins/`. diff --git a/package.json b/package.json index d22ac1895..3531a0d37 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "db:seed-test-wallet": "tsx scripts/seed/seed-test-wallet.ts", "db:seed-workflows": "tsx scripts/seed/seed-protocol-workflows.ts", "db:seed-all": "pnpm db:seed && pnpm db:seed-test-wallet && pnpm db:seed-workflows", + "dev:bootstrap": "tsx scripts/seed/dev-bootstrap.ts", + "dev:mint-cookie": "tsx scripts/dev-mint-session.ts", + "dev:login": "tsx scripts/dev-login.ts", "db:setup": "pnpm db:migrate && pnpm db:setup-workflow && pnpm db:seed", "db:setup-workflow": "workflow-postgres-setup", "discover-plugins": "tsx scripts/discover-plugins.ts", diff --git a/scripts/dev-login-browser.ts b/scripts/dev-login-browser.ts new file mode 100644 index 000000000..94717700c --- /dev/null +++ b/scripts/dev-login-browser.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env tsx + +/** + * dev-login-browser.ts + * + * Sub-process spawned (detached, background) by scripts/dev-login.ts. + * Owns the Playwright Chromium window: receives the signed cookie value + * via the KEEPERHUB_DEV_COOKIE env var (owner-only, unlike world-readable + * argv), the URL via argv[2], and the profile dir via argv[3], launches a + * persistent context, sets the cookie, opens the URL, and waits for the + * browser to close before exiting. + * + * Lives in its own process so the parent (dev:login) can return the + * terminal to the user immediately while the browser stays alive. + * Detaching via `spawn(..., { detached: true })` is not enough on its + * own: a Playwright BrowserContext closes when the Node process that + * owns it exits, so we need a separate Node process that stays alive + * until the user closes the window. + */ + +import "dotenv/config"; + +import { chromium } from "@playwright/test"; + +async function main(): Promise { + // The signed cookie arrives via env (owner-only) rather than argv + // (world-readable); see launchBrowserDetached in scripts/dev-login.ts. + const rawSignedValue = process.env.KEEPERHUB_DEV_COOKIE; + const url = process.argv[2]; + const profileDir = process.argv[3]; + if (!(rawSignedValue && url && profileDir)) { + throw new Error( + "dev-login-browser: expected KEEPERHUB_DEV_COOKIE env and argv: " + ); + } + + // Mirror the wire encoding used by + // app/api/auth/oauth-mfa-finalize/route.ts:buildSessionSetCookie. + const value = encodeURIComponent(rawSignedValue); + + const ctx = await chromium.launchPersistentContext(profileDir, { + headless: false, + }); + + await ctx.addCookies([ + { + name: "better-auth.session_token", + value, + domain: "localhost", + path: "/", + httpOnly: true, + secure: false, + sameSite: "Lax", + }, + ]); + + // Reuse the auto-created blank page if there is one; otherwise open one. + const page = ctx.pages()[0] ?? (await ctx.newPage()); + await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => { + // Ignore navigation errors: even if the dev server is slow or down, + // the window itself stays open and the user can refresh manually. + }); + + // Block until Chromium exits. launchPersistentContext returns a + // contextless browser (ctx.browser() === null), so we wait on the + // context's own close lifecycle via waitForEvent. The parent + // dev:login has long since detached; this daemon exits when the + // user closes the browser window. + await ctx.waitForEvent("close", { timeout: 0 }); +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + // biome-ignore lint/suspicious/noConsole: detached daemon, stderr is the only signal + console.error(msg); + process.exit(1); +}); diff --git a/scripts/dev-login.ts b/scripts/dev-login.ts new file mode 100644 index 000000000..b1cded5ae --- /dev/null +++ b/scripts/dev-login.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env tsx + +/** + * dev-login.ts + * + * One-shot local-dev sign-in. Composes four steps so the caller only + * needs `pnpm dev:login`: + * 1. pnpm dev:bootstrap (idempotent, ~2s when warm) + * 2. KEEPERHUB_DEV_MINT=1 dev-mint-cookie (writes the signed cookie file) + * 3. ensure a dev server is serving http://localhost:3000 -- reuse one + * if it is already up, otherwise spawn `pnpm dev` detached (logging to + * .claude/.dev-server-LOCAL.log) and wait until it responds. + * 4. seed the cookie into a Chromium persistent profile under + * .claude/.dev-chrome-profile/ and launch Chromium detached against + * the now-serving URL. + * + * Step 3 is what makes this a true single command: without it the browser + * opens onto a connection-refused page whenever the dev server is not + * already running. + * + * The Chromium instance is separate from the user's normal Chrome (its + * own user-data-dir), so it doesn't disturb their working tabs. The + * profile dir and the server log are gitignored. Re-running dev:login just + * refreshes the cookie and reuses the running server, so the user can keep + * the window open across sessions if they want. + * + * Hostname guard: refuses to run unless DATABASE_URL resolves to a local + * Postgres. KEEPERHUB_DEV_MINT is set for the mint child process here, + * because invoking dev:login is itself the explicit acknowledgment of + * "yes, mint a forged session" -- the hostname guard is the bulletproof + * safety check, the env var is belt-and-suspenders for direct invocation + * of dev:mint-cookie. + */ + +import "dotenv/config"; + +import { spawn, spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as http from "node:http"; +import * as path from "node:path"; + +import { getDatabaseUrl } from "../lib/db/connection-utils"; + +const ALLOWED_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "::1", + "db", + "postgres", +]); + +const REPO_ROOT = process.cwd(); +const COOKIE_FILE = path.join( + REPO_ROOT, + ".claude", + ".dev-session-cookie-LOCAL" +); +const CHROME_PROFILE_DIR = path.join( + REPO_ROOT, + ".claude", + ".dev-chrome-profile" +); +const DEV_SERVER_LOG = path.join( + REPO_ROOT, + ".claude", + ".dev-server-LOCAL.log" +); +const DEV_URL = process.env.DEV_LOGIN_URL ?? "http://localhost:3000"; +const SERVER_READY_TIMEOUT_MS = 180_000; +const SERVER_POLL_INTERVAL_MS = 1000; +const SERVER_PROBE_TIMEOUT_MS = 2000; + +function assertLocalDb(): void { + const url = getDatabaseUrl(); + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + throw new Error("dev-login: DATABASE_URL is not a parseable URL"); + } + if (!ALLOWED_HOSTS.has(hostname)) { + throw new Error( + `dev-login: refusing to run against host "${hostname}". Only ${[ + ...ALLOWED_HOSTS, + ].join(", ")} are allowed.` + ); + } +} + +function parseEmail(): string { + return process.argv[2] ?? "dev@keeperhub.local"; +} + +function runStep( + label: string, + script: string, + args: string[], + extraEnv: Record = {} +): void { + console.log(`> ${label}`); + const env = { ...process.env, ...extraEnv } as NodeJS.ProcessEnv; + const result = spawnSync("pnpm", ["tsx", script, ...args], { + stdio: "inherit", + env, + }); + if (result.status !== 0) { + throw new Error(`${label} exited with status ${result.status ?? "null"}`); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function probeServer(url: string): Promise { + return new Promise((resolve) => { + const req = http.get(url, (res) => { + // Any HTTP response means something is listening and serving. + res.resume(); + resolve(true); + }); + req.on("error", () => resolve(false)); + req.setTimeout(SERVER_PROBE_TIMEOUT_MS, () => { + req.destroy(); + resolve(false); + }); + }); +} + +function startDevServerDetached(): void { + const logFd = fs.openSync(DEV_SERVER_LOG, "a"); + try { + const child = spawn("pnpm", ["dev"], { + detached: true, + stdio: ["ignore", logFd, logFd], + cwd: REPO_ROOT, + env: process.env, + }); + child.unref(); + } finally { + // The child has its own dup'd descriptor for stdout/stderr; close the + // parent's copy so we don't leak it for the parent's remaining lifetime. + fs.closeSync(logFd); + } +} + +async function waitForServer(url: string): Promise { + const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await probeServer(url)) { + return; + } + await sleep(SERVER_POLL_INTERVAL_MS); + } + throw new Error( + `dev-login: dev server did not become ready within ${ + SERVER_READY_TIMEOUT_MS / 1000 + }s. Check ${DEV_SERVER_LOG}.` + ); +} + +async function ensureDevServer(): Promise { + if (await probeServer(DEV_URL)) { + console.log(`> dev server already running at ${DEV_URL}`); + return; + } + console.log(`> starting dev server (logs: ${DEV_SERVER_LOG})`); + startDevServerDetached(); + console.log("> waiting for dev server to be ready..."); + await waitForServer(DEV_URL); + console.log("> dev server ready"); +} + +function readCookieValue(): string { + if (!fs.existsSync(COOKIE_FILE)) { + throw new Error( + `dev-login: cookie file not found at ${COOKIE_FILE}. The mint step did not produce it.` + ); + } + const text = fs.readFileSync(COOKIE_FILE, "utf8"); + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + continue; + } + return trimmed; + } + throw new Error(`dev-login: ${COOKIE_FILE} has no cookie line`); +} + +function launchBrowserDetached(rawSignedValue: string): void { + fs.mkdirSync(CHROME_PROFILE_DIR, { recursive: true }); + // Spawn the browser owner as a detached child. We can't seed cookies in + // this parent process and then point a separate Chromium at the same + // profile -- Playwright's addCookies() in headless mode doesn't reliably + // flush HttpOnly cookies to disk before the context closes, so the + // newly-spawned Chromium would see an empty cookie store. Keeping the + // browser inside a Playwright context (in a separate Node process) + // sidesteps the persistence race entirely. + // Pass the signed cookie (a live bearer credential) via env, not argv: + // process argv is world-readable via ps / /proc//cmdline, whereas + // /proc//environ is owner-only. URL and profile dir are not secret, + // so they stay as positional args. + const child = spawn( + "pnpm", + [ + "tsx", + path.join("scripts", "dev-login-browser.ts"), + DEV_URL, + CHROME_PROFILE_DIR, + ], + { + detached: true, + stdio: "ignore", + cwd: REPO_ROOT, + env: { ...process.env, KEEPERHUB_DEV_COOKIE: rawSignedValue }, + } + ); + child.unref(); +} + +async function main(): Promise { + assertLocalDb(); + const email = parseEmail(); + + runStep("pnpm dev:bootstrap", "scripts/seed/dev-bootstrap.ts", []); + runStep( + `pnpm dev:mint-cookie ${email}`, + "scripts/dev-mint-session.ts", + [email], + { KEEPERHUB_DEV_MINT: "1" } + ); + + await ensureDevServer(); + + const cookie = readCookieValue(); + console.log(`> launching detached Chromium at ${DEV_URL}`); + launchBrowserDetached(cookie); + + console.log("\ndev-login: signed-in Chromium window opening."); + console.log(` Profile: ${CHROME_PROFILE_DIR}`); + console.log(" Re-run pnpm dev:login any time to refresh the cookie."); +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + process.exit(1); +}); diff --git a/scripts/dev-mint-session.ts b/scripts/dev-mint-session.ts new file mode 100644 index 000000000..668a7e044 --- /dev/null +++ b/scripts/dev-mint-session.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env tsx + +/** + * dev-mint-session.ts + * + * Mint a Better Auth session for a local-dev user without going through the + * UI (signup -> OTP -> MFA -> TOTP). The signed cookie value is written to + * .claude/.dev-session-cookie-LOCAL so it can be pasted into Chrome devtools + * as the `better-auth.session_token` cookie. + * + * Mirrors app/api/auth/oauth-mfa-finalize/route.ts:117-167 -- same row shape + * (requiresMfa=false, mfaVerifiedAt=now), same HMAC signing scheme, same + * cookie name. The only difference is we skip the dual-factor challenge and + * accept any email the caller names. That is dangerous against a real + * deployment, which is why the script refuses to run unless BOTH: + * - DATABASE_URL points at a local Postgres host, AND + * - KEEPERHUB_DEV_MINT=1 is set in the environment. + * + * Usage: + * KEEPERHUB_DEV_MINT=1 pnpm dev:mint-cookie dev@keeperhub.local + */ + +import "dotenv/config"; + +import { randomBytes } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { and, asc, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { + hashSessionToken, + signSessionCookieValue, +} from "../lib/auth-session-token-hash"; +import { getDatabaseUrl } from "../lib/db/connection-utils"; +import { member, organization, sessions, users } from "../lib/db/schema"; + +// --------------------------------------------------------------------------- +// Guards: hostname + opt-in env var +// --------------------------------------------------------------------------- + +const ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "db", "postgres"]); + +function assertLocalDb(url: string): void { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + throw new Error(`dev-mint-session: DATABASE_URL is not a parseable URL`); + } + if (!ALLOWED_HOSTS.has(hostname)) { + throw new Error( + `dev-mint-session: refusing to run against host "${hostname}". ` + + `Only ${[...ALLOWED_HOSTS].join(", ")} are allowed. ` + + "Set DATABASE_URL to a local Postgres before re-running." + ); + } +} + +function assertOptIn(): void { + if (process.env.KEEPERHUB_DEV_MINT !== "1") { + throw new Error( + "dev-mint-session: refusing to run without KEEPERHUB_DEV_MINT=1. " + + "This is a session-forge utility; set the env var to acknowledge." + ); + } +} + +// --------------------------------------------------------------------------- +// Mint +// --------------------------------------------------------------------------- + +const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const COOKIE_OUTPUT_PATH = path.join( + process.cwd(), + ".claude", + ".dev-session-cookie-LOCAL" +); + +type MintResult = { + email: string; + userId: string; + cookieName: string; + signedCookieValue: string; + expiresAt: Date; +}; + +async function mintSession(email: string): Promise { + const secret = process.env.BETTER_AUTH_SECRET; + if (!secret) { + throw new Error( + "dev-mint-session: BETTER_AUTH_SECRET is not set. The cookie cannot be signed." + ); + } + + const url = getDatabaseUrl(); + const client = postgres(url, { max: 1 }); + const db = drizzle(client); + + try { + const row = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!row[0]) { + throw new Error( + `dev-mint-session: no user found with email "${email}". ` + + "Run pnpm dev:bootstrap first, or pass a seeded email." + ); + } + const userId = row[0].id; + + // Surface a stable "active org" so the UI lands on the seeded workflows. + // Prefer the membership in the bootstrap's dev-org slug; fall back to the + // user's first membership so a custom SEED_DEV_ORG_SLUG still works. + const devOrgSlug = process.env.SEED_DEV_ORG_SLUG ?? "dev-org"; + const devOrgMember = await db + .select({ organizationId: member.organizationId }) + .from(member) + .innerJoin(organization, eq(organization.id, member.organizationId)) + .where(and(eq(member.userId, userId), eq(organization.slug, devOrgSlug))) + .limit(1); + let activeOrganizationId: string | null = + devOrgMember[0]?.organizationId ?? null; + if (!activeOrganizationId) { + const fallback = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + .orderBy(asc(member.createdAt)) + .limit(1); + activeOrganizationId = fallback[0]?.organizationId ?? null; + } + + const rawToken = randomBytes(32).toString("base64url"); + const tokenHash = hashSessionToken(rawToken); + const expiresAt = new Date(Date.now() + DEFAULT_SESSION_TTL_MS); + const sessionId = `sess_${randomBytes(16).toString("base64url")}`; + const now = new Date(); + + await db.insert(sessions).values({ + id: sessionId, + userId, + token: tokenHash, + expiresAt, + createdAt: now, + updatedAt: now, + ipAddress: "127.0.0.1", + userAgent: "dev-mint-session", + requiresMfa: false, + mfaVerifiedAt: now, + activeOrganizationId, + }); + + const signedCookieValue = signSessionCookieValue(rawToken, secret); + // The route uses __Secure- in production, plain in dev. dev-mint always + // targets dev, so emit the dev cookie name. + const cookieName = "better-auth.session_token"; + + return { email, userId, cookieName, signedCookieValue, expiresAt }; + } finally { + await client.end(); + } +} + +function writeCookieFile(result: MintResult): void { + const dir = path.dirname(COOKIE_OUTPUT_PATH); + fs.mkdirSync(dir, { recursive: true }); + const body = [ + `# Generated by scripts/dev-mint-session.ts at ${new Date().toISOString()}`, + `# User: ${result.email} (${result.userId})`, + `# Cookie: ${result.cookieName}`, + `# Expires: ${result.expiresAt.toISOString()}`, + "#", + "# Paste the line below into Chrome devtools (Application > Cookies > localhost:3000)", + "# as the value for the cookie named above, then refresh.", + "", + result.signedCookieValue, + "", + ].join("\n"); + fs.writeFileSync(COOKIE_OUTPUT_PATH, body, { mode: 0o600 }); +} + +// --------------------------------------------------------------------------- +// Entry +// --------------------------------------------------------------------------- + +function parseEmail(): string { + const email = process.argv[2]; + if (!email) { + throw new Error( + "dev-mint-session: missing email argument. " + + "Usage: KEEPERHUB_DEV_MINT=1 pnpm dev:mint-cookie " + ); + } + return email; +} + +async function main(): Promise { + const url = getDatabaseUrl(); + assertLocalDb(url); + assertOptIn(); + const email = parseEmail(); + const result = await mintSession(email); + writeCookieFile(result); + + console.log(`dev-mint-session: minted session for ${result.email}`); + console.log(` user id: ${result.userId}`); + console.log(` cookie: ${result.cookieName}`); + console.log(` expires: ${result.expiresAt.toISOString()}`); + console.log(` written to: ${COOKIE_OUTPUT_PATH}`); +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + process.exit(1); +}); diff --git a/scripts/seed/dev-bootstrap.ts b/scripts/seed/dev-bootstrap.ts new file mode 100644 index 000000000..7a874f98a --- /dev/null +++ b/scripts/seed/dev-bootstrap.ts @@ -0,0 +1,553 @@ +#!/usr/bin/env tsx + +/** + * dev-bootstrap.ts + * + * One-shot bootstrap for a usable local-dev DB. Idempotent. Refuses to run + * against anything that doesn't look like a local Postgres host. + * + * Sequence: + * 1. assertLocalDb() + * 2. If users table exists AND drizzle.__drizzle_migrations is empty, + * run scripts/backfill-drizzle-migrations.ts so db:migrate skips the + * already-applied SQL. (db:push-bootstrapped DBs land here.) + * 3. pnpm db:migrate (applies anything new). + * 4. seedPersistentTestUsers() for the e2e user/org set. + * 5. Upsert the dev user (default dev@keeperhub.local) + org + membership. + * 6. Read the local kh CLI token from ~/.config/kh/hosts.yml and upsert + * an organization_api_keys row that hashes to it. The file itself is + * never read in or committed. + * 7. Upsert the 8 workflow fixtures from ./fixtures/dev-workflows.ts + * into the dev user's org. + * + * Re-run anytime: it's an upsert at every step. + */ + +import "dotenv/config"; + +import { createHash, randomBytes, scrypt } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { and, eq, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { getDatabaseUrl } from "../../lib/db/connection-utils"; +import { + accounts, + member, + organization, + users, + userTrustedIps, + workflows, +} from "../../lib/db/schema"; +import { organizationApiKeys } from "../../lib/db/schema-extensions"; +import { generateId } from "../../lib/utils/id"; +import { seedPersistentTestUsers } from "../../tests/e2e/playwright/utils/seed"; +import { + buildTriggerNodes, + DEV_WORKFLOW_FIXTURES, +} from "./fixtures/dev-workflows"; + +// --------------------------------------------------------------------------- +// Hostname guard +// --------------------------------------------------------------------------- + +const ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "db", "postgres"]); + +function assertLocalDb(url: string): string { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + throw new Error( + `dev-bootstrap: DATABASE_URL is not a parseable URL: ${url}` + ); + } + if (!ALLOWED_HOSTS.has(hostname)) { + throw new Error( + `dev-bootstrap: refusing to run against host "${hostname}". ` + + `Only ${[...ALLOWED_HOSTS].join(", ")} are allowed. ` + + "Set DATABASE_URL to a local Postgres before re-running." + ); + } + return hostname; +} + +// --------------------------------------------------------------------------- +// Password hashing (matches tests/e2e/playwright/utils/seed.ts so the +// dev user's password is verifiable through Better Auth's credential flow) +// --------------------------------------------------------------------------- + +const SCRYPT_CONFIG = { N: 16_384, r: 16, p: 1, dkLen: 64 } as const; +const DEFAULT_DEV_PASSWORD = "Test1234!"; + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function scryptAsync( + password: string, + salt: string, + keylen: number +): Promise { + return new Promise((resolve, reject) => { + scrypt( + password, + salt, + keylen, + { + N: SCRYPT_CONFIG.N, + r: SCRYPT_CONFIG.r, + p: SCRYPT_CONFIG.p, + maxmem: 128 * SCRYPT_CONFIG.N * SCRYPT_CONFIG.r * 2, + }, + (err, derivedKey) => { + if (err) { + reject(err); + } else { + resolve(derivedKey); + } + } + ); + }); +} + +async function hashPassword(password: string): Promise { + const saltBytes = randomBytes(16); + const salt = bytesToHex(saltBytes); + const key = await scryptAsync( + password.normalize("NFKC"), + salt, + SCRYPT_CONFIG.dkLen + ); + return `${salt}:${bytesToHex(key)}`; +} + +// --------------------------------------------------------------------------- +// Step 2 + 3: migration journal + db:migrate +// --------------------------------------------------------------------------- + +async function maybeBackfillJournal( + client: ReturnType +): Promise { + const usersExists = await client<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'users' + ) AS exists + `; + if (!usersExists[0]?.exists) { + return false; + } + + const journalCount = await client<{ c: number }[]>` + SELECT COALESCE( + (SELECT COUNT(*)::int FROM drizzle.__drizzle_migrations), + 0 + ) AS c + `.catch(() => [{ c: 0 }]); + + if ((journalCount[0]?.c ?? 0) > 0) { + return false; + } + + return true; +} + +function runChildScript(script: string, label: string): void { + console.log(`> ${label}`); + const result = spawnSync("pnpm", ["tsx", script], { + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + throw new Error(`${label} exited with status ${result.status ?? "null"}`); + } +} + +function runMigrate(): void { + console.log("> pnpm db:migrate"); + const result = spawnSync("pnpm", ["db:migrate"], { + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + throw new Error(`pnpm db:migrate exited with status ${result.status ?? "null"}`); + } +} + +// --------------------------------------------------------------------------- +// Step 5: dev user upsert +// --------------------------------------------------------------------------- + +type DevIdentity = { + userId: string; + orgId: string; + email: string; +}; + +async function ensureDevIdentity( + db: ReturnType +): Promise { + const email = process.env.SEED_DEV_EMAIL ?? "dev@keeperhub.local"; + const name = process.env.SEED_DEV_NAME ?? "Dev User"; + const password = process.env.SEED_DEV_PASSWORD ?? DEFAULT_DEV_PASSWORD; + const orgSlug = process.env.SEED_DEV_ORG_SLUG ?? "dev-org"; + const orgName = process.env.SEED_DEV_ORG_NAME ?? "Dev Org"; + const now = new Date(); + + const existingUser = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email)) + .limit(1); + + let userId: string; + if (existingUser[0]) { + userId = existingUser[0].id; + // twoFactorEnabled=true so the dev user passes proxy.ts:mfaBlock with a + // minted (rather than enrolled) session. The session row carries + // requiresMfa=false and mfaVerifiedAt set, so the gate sees an "enrolled" + // user with a satisfied step-up. No two_factor row is seeded; the dev + // mint flow never asks the user for a TOTP, so no secret is needed. + await db + .update(users) + .set({ + emailVerified: true, + twoFactorEnabled: true, + updatedAt: now, + }) + .where(eq(users.id, userId)); + } else { + userId = generateId(); + await db.insert(users).values({ + id: userId, + name, + email, + emailVerified: true, + twoFactorEnabled: true, + createdAt: now, + updatedAt: now, + isAnonymous: false, + }); + } + + const existingAccount = await db + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.userId, userId)) + .limit(1); + + if (existingAccount.length === 0) { + const hash = await hashPassword(password); + await db.insert(accounts).values({ + id: generateId(), + accountId: userId, + providerId: "credential", + userId, + password: hash, + createdAt: now, + updatedAt: now, + }); + } + + const existingOrg = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, orgSlug)) + .limit(1); + + let orgId: string; + if (existingOrg[0]) { + orgId = existingOrg[0].id; + } else { + orgId = generateId(); + await db.insert(organization).values({ + id: orgId, + name: orgName, + slug: orgSlug, + createdAt: now, + }); + } + + // Check membership in THIS specific org; a user may already belong to other + // orgs from earlier ad-hoc seeds. + const existingMember = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, orgId))) + .limit(1); + + if (existingMember.length === 0) { + await db.insert(member).values({ + id: generateId(), + organizationId: orgId, + userId, + role: "owner", + createdAt: now, + }); + } + + await ensureTrustedLocalIps(db, userId); + + return { userId, orgId, email }; +} + +// Pre-trust the IPs Chrome will use when hitting localhost:3000 so the +// proxy IP gate (login-risk.ts:gateRequestIp) passes for the minted +// session. Without these the user gets redirected to /verify-ip on every +// request, which defeats the whole point of skipping the UI auth flow. +// +// IPv4 stored as-is; IPv6 ::1 normalizes to its /64 (all-zero) prefix per +// normalizeIpForTrust, matching what gateRequestIp would look up. +const TRUSTED_LOCAL_IPS = [ + "127.0.0.1", + "0000:0000:0000:0000:0000:0000:0000:0000", +] as const; + +async function ensureTrustedLocalIps( + db: ReturnType, + userId: string +): Promise { + const now = new Date(); + for (const ip of TRUSTED_LOCAL_IPS) { + const existing = await db + .select({ id: userTrustedIps.id }) + .from(userTrustedIps) + .where(and(eq(userTrustedIps.userId, userId), eq(userTrustedIps.ip, ip))) + .limit(1); + if (existing.length === 0) { + await db.insert(userTrustedIps).values({ + userId, + ip, + country: "LOCAL", + firstSeenAt: now, + lastSeenAt: now, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Step 6: kh CLI key binding +// --------------------------------------------------------------------------- + +const KH_HOSTS_FILE = path.join(os.homedir(), ".config/kh/hosts.yml"); +const KH_HOST = process.env.SEED_DEV_KH_HOST ?? "localhost:3000"; + +function readKhToken(host: string): string | null { + if (!fs.existsSync(KH_HOSTS_FILE)) { + return null; + } + const text = fs.readFileSync(KH_HOSTS_FILE, "utf8"); + const lines = text.split(/\r?\n/); + const hostPattern = new RegExp(`^(\\s+)${escapeRegex(host)}:\\s*$`); + + for (const [i, line] of lines.entries()) { + const match = line.match(hostPattern); + if (!match) { + continue; + } + const hostIndent = match[1].length; + // Scan downward from this host header for the first `token:` line whose + // indent is deeper than the host header's. Stop once indent returns to + // the host level or above (sibling key) or we hit EOF. + for (const inner of lines.slice(i + 1)) { + if (inner.trim() === "") { + continue; + } + const indent = inner.length - inner.trimStart().length; + if (indent <= hostIndent) { + break; + } + const tokenMatch = inner.match(/^\s+token:\s*(\S+)\s*$/); + if (tokenMatch) { + return tokenMatch[1]; + } + } + return null; + } + return null; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\:]/g, "\\$&"); +} + +async function bindKhKey( + db: ReturnType, + identity: DevIdentity +): Promise<{ bound: boolean; prefix?: string; reason?: string }> { + const token = readKhToken(KH_HOST); + if (!token) { + return { + bound: false, + reason: `no token for "${KH_HOST}" in ${KH_HOSTS_FILE}`, + }; + } + if (!token.startsWith("kh_")) { + return { bound: false, reason: `token does not have kh_ prefix` }; + } + + const keyHash = createHash("sha256").update(token).digest("hex"); + const keyPrefix = token.slice(0, 8); + + const existing = await db + .select({ id: organizationApiKeys.id }) + .from(organizationApiKeys) + .where(eq(organizationApiKeys.keyHash, keyHash)) + .limit(1); + + if (existing[0]) { + await db + .update(organizationApiKeys) + .set({ + organizationId: identity.orgId, + createdBy: identity.userId, + revokedAt: null, + expiresAt: null, + }) + .where(eq(organizationApiKeys.id, existing[0].id)); + return { bound: true, prefix: keyPrefix }; + } + + await db.insert(organizationApiKeys).values({ + id: generateId(), + organizationId: identity.orgId, + name: `local kh CLI (${KH_HOST})`, + keyHash, + keyPrefix, + createdBy: identity.userId, + }); + return { bound: true, prefix: keyPrefix }; +} + +// --------------------------------------------------------------------------- +// Step 7: workflow fixtures +// --------------------------------------------------------------------------- + +async function seedWorkflowFixtures( + db: ReturnType, + identity: DevIdentity +): Promise<{ created: number; updated: number }> { + let created = 0; + let updated = 0; + const now = new Date(); + + for (const fixture of DEV_WORKFLOW_FIXTURES) { + const { nodes, edges } = buildTriggerNodes(fixture); + const existing = await db + .select({ id: workflows.id }) + .from(workflows) + .where(eq(workflows.id, fixture.id)) + .limit(1); + + if (existing[0]) { + await db + .update(workflows) + .set({ + name: fixture.name, + description: fixture.description, + userId: identity.userId, + organizationId: identity.orgId, + nodes, + edges, + enabled: fixture.enabled, + deletedAt: fixture.softDeleted ? now : null, + updatedAt: now, + }) + .where(eq(workflows.id, fixture.id)); + updated++; + } else { + await db.insert(workflows).values({ + id: fixture.id, + name: fixture.name, + description: fixture.description, + userId: identity.userId, + organizationId: identity.orgId, + nodes, + edges, + visibility: "private", + enabled: fixture.enabled, + deletedAt: fixture.softDeleted ? now : null, + createdAt: now, + updatedAt: now, + }); + created++; + } + } + return { created, updated }; +} + +// --------------------------------------------------------------------------- +// Driver +// --------------------------------------------------------------------------- + +async function main(): Promise { + const url = getDatabaseUrl(); + const host = assertLocalDb(url); + console.log(`dev-bootstrap: connected to ${host}`); + + const client = postgres(url, { max: 1 }); + try { + const needsBackfill = await maybeBackfillJournal(client); + if (needsBackfill) { + runChildScript( + path.join(__dirname, "..", "backfill-drizzle-migrations.ts"), + "backfill drizzle journal (db:push -> db:migrate handoff)" + ); + } else { + console.log("> journal already populated or fresh DB; skipping backfill"); + } + } finally { + await client.end(); + } + + runMigrate(); + + console.log("> seedPersistentTestUsers()"); + await seedPersistentTestUsers(); + + const seedClient = postgres(url, { max: 1 }); + const db = drizzle(seedClient); + try { + const identity = await ensureDevIdentity(db); + console.log(` + dev user: ${identity.email} (${identity.userId})`); + console.log(` + dev org: ${identity.orgId}`); + + const keyBinding = await bindKhKey(db, identity); + if (keyBinding.bound) { + console.log(` + kh key: bound to dev org (prefix ${keyBinding.prefix})`); + } else { + console.log(` ! kh key: skipped (${keyBinding.reason})`); + } + + const fixtures = await seedWorkflowFixtures(db, identity); + console.log( + ` + workflows: ${fixtures.created} created, ${fixtures.updated} updated ` + + `(${DEV_WORKFLOW_FIXTURES.length} total fixtures)` + ); + + // Use sql to keep schema imports honest: this is just a smoke-test count. + const total = await db.execute( + sql`SELECT COUNT(*)::int AS c FROM workflows WHERE organization_id = ${identity.orgId}` + ); + const totalRow = total as unknown as Array<{ c: number }>; + console.log(` = workflows in dev org: ${totalRow[0]?.c ?? "?"}`); + } finally { + await seedClient.end(); + } + + console.log("\ndev-bootstrap: done."); +} + +main().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/seed/fixtures/dev-workflows.ts b/scripts/seed/fixtures/dev-workflows.ts new file mode 100644 index 000000000..211996f69 --- /dev/null +++ b/scripts/seed/fixtures/dev-workflows.ts @@ -0,0 +1,137 @@ +/** + * Stable workflow fixtures for the local-dev bootstrap. + * + * Each entry has a fixed `id` (so re-running dev-bootstrap.ts is an upsert, + * not a fan-out) and a single trigger node — enough to cover the four trigger + * types and the on/off + soft-deleted matrix without hauling in real plugin + * actions. The shapes mirror the minimal trigger graphs produced by the + * workflow builder UI. + */ + +export type DevWorkflowFixture = { + id: string; + name: string; + description: string; + triggerType: "Manual" | "Schedule" | "Webhook" | "Event"; + enabled: boolean; + softDeleted: boolean; + triggerConfig: Record; +}; + +const NODE_X = 100; +const NODE_Y = 200; + +function manualConfig(): Record { + return { triggerType: "Manual" }; +} + +function scheduleConfig(): Record { + return { triggerType: "Schedule", scheduleCron: "0 * * * *" }; +} + +function webhookConfig(): Record { + return { triggerType: "Webhook" }; +} + +function eventConfig(): Record { + return { + triggerType: "Event", + network: "1", + contractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + eventName: "Transfer", + }; +} + +export const DEV_WORKFLOW_FIXTURES: DevWorkflowFixture[] = [ + { + id: "dev_wf_manual_on", + name: "[Dev] Manual Trigger (enabled)", + description: "Manual trigger workflow, enabled. Local dev fixture.", + triggerType: "Manual", + enabled: true, + softDeleted: false, + triggerConfig: manualConfig(), + }, + { + id: "dev_wf_manual_off", + name: "[Dev] Manual Trigger (disabled)", + description: "Manual trigger workflow, disabled. Local dev fixture.", + triggerType: "Manual", + enabled: false, + softDeleted: false, + triggerConfig: manualConfig(), + }, + { + id: "dev_wf_schedule_on", + name: "[Dev] Schedule Trigger (enabled)", + description: "Hourly schedule, enabled. Local dev fixture.", + triggerType: "Schedule", + enabled: true, + softDeleted: false, + triggerConfig: scheduleConfig(), + }, + { + id: "dev_wf_schedule_off", + name: "[Dev] Schedule Trigger (disabled)", + description: "Hourly schedule, disabled. Local dev fixture.", + triggerType: "Schedule", + enabled: false, + softDeleted: false, + triggerConfig: scheduleConfig(), + }, + { + id: "dev_wf_webhook_on", + name: "[Dev] Webhook Trigger (enabled)", + description: "Webhook trigger, enabled. Local dev fixture.", + triggerType: "Webhook", + enabled: true, + softDeleted: false, + triggerConfig: webhookConfig(), + }, + { + id: "dev_wf_webhook_off", + name: "[Dev] Webhook Trigger (disabled)", + description: "Webhook trigger, disabled. Local dev fixture.", + triggerType: "Webhook", + enabled: false, + softDeleted: false, + triggerConfig: webhookConfig(), + }, + { + id: "dev_wf_event_on", + name: "[Dev] Event Trigger (enabled)", + description: "Event trigger on USDT Transfer, enabled. Local dev fixture.", + triggerType: "Event", + enabled: true, + softDeleted: false, + triggerConfig: eventConfig(), + }, + { + id: "dev_wf_soft_deleted", + name: "[Dev] Soft-deleted Workflow", + description: "Manual trigger workflow with deletedAt set. Local dev fixture.", + triggerType: "Manual", + enabled: false, + softDeleted: true, + triggerConfig: manualConfig(), + }, +]; + +export function buildTriggerNodes( + fixture: DevWorkflowFixture +): { nodes: unknown[]; edges: unknown[] } { + const nodes = [ + { + id: "trigger-1", + type: "trigger", + position: { x: NODE_X, y: NODE_Y }, + data: { + label: `${fixture.triggerType} Trigger`, + type: "trigger", + config: fixture.triggerConfig, + status: "idle", + }, + }, + ]; + return { nodes, edges: [] }; +} diff --git a/tsconfig.json b/tsconfig.json index 2ab390dd2..08191ac7a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,11 @@ "docs-site/**/*", "keeperhub-events/**/*", "keeperhub-scheduler/**/*", - "sandbox/**/*" + "sandbox/**/*", + "scripts/seed/dev-bootstrap.ts", + "scripts/seed/fixtures/dev-workflows.ts", + "scripts/dev-mint-session.ts", + "scripts/dev-login.ts", + "scripts/dev-login-browser.ts" ] }