From 2638b41c60dad6ae0b3ad40be83d6a3f40abd4ba Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Thu, 23 Jul 2026 22:10:05 +0530 Subject: [PATCH 1/3] feat: add scoped init command for db, auth, and stack modules - Updated `postkit init` command to accept an optional module argument for scoped initialization. - Implemented separate scaffolding for `db`, `auth`, and `stack` modules. - Created `scaffoldStorageMigration` to bootstrap storage migrations during full initialization. - Enhanced `.gitignore` management to include module-specific entries. - Updated documentation to reflect new scoped initialization options and their effects. - Added tests for new functionality and ensured idempotency of init commands. --- CLAUDE.md | 15 +- agent/skills/postkit-setup/SKILL.md | 17 +- cli/docs/architecture.md | 4 + cli/docs/db.md | 3 + cli/docs/e2e-testing.md | 5 + cli/docs/stack.md | 4 +- cli/package-lock.json | 4 +- cli/src/commands/init.ts | 355 ++++++++++++------ cli/src/index.ts | 8 +- cli/src/modules/db/services/scaffold.ts | 60 +++ cli/test/e2e/smoke/basic-commands.test.ts | 118 +++++- cli/test/modules/db/services/scaffold.test.ts | 179 +++++++++ docs/docs/getting-started/configuration.md | 2 +- docs/docs/getting-started/quick-start.md | 2 + docs/docs/reference/init.md | 92 +++++ docs/docs/reference/project-structure.md | 35 +- docs/sidebars.ts | 1 + 17 files changed, 761 insertions(+), 143 deletions(-) create mode 100644 cli/test/modules/db/services/scaffold.test.ts create mode 100644 docs/docs/reference/init.md diff --git a/CLAUDE.md b/CLAUDE.md index 8d01b8b..2f19bab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,7 @@ PostKit files are split between committed (shared with team) and gitignored (use - `assertLocalConnection(session, spinner)` (`utils/session.ts`) - Tests local DB connection from session; throws if unreachable - `resolveApplyTarget(target?)` (`utils/apply-target.ts`) - Resolves `"local"` or `"remote"` apply target; used by infra and seed commands - `readJsonFile(path)` / `writeJsonFile(path, data)` (`utils/json-file.ts`) - Typed JSON helpers used by remotes and committed migration tracking +- `scaffoldDbInfra()` (`services/scaffold.ts`) - Scaffolds `db/infra/001_roles.sql`+`002_schemas.sql`; runs on both full `postkit init` and `init db`. `scaffoldStorageMigration()` scaffolds the `storage.migrations` bootstrap migration; full `postkit init` only. Both are idempotent (safe to call multiple times, never overwrite) ### Stack Module Architecture @@ -197,10 +198,13 @@ The `stack` module manages a local backend service stack (Postgres, Keycloak, Po - `importRealmTemplate(config, spinner?)` (`services/realm-init.ts`) — Cleans realm JSON and imports via `keycloak-config-cli` container - `cleanRealmTemplate(raw, realmName)` (`services/realm-init.ts`) — Strips builtins, injects JWT Role Mapper -**`postkit init` scaffold additions:** +**`postkit init [module]` scaffold additions:** +- No `module` argument scaffolds everything (db + auth + stack), unchanged from before scoping existed. Passing `db`, `auth`, or `stack` scaffolds only that module — see `initCommand()`/`initModuleCommand()` in `cli/src/commands/init.ts` +- Scoped runs never re-prompt for or overwrite an existing `postkit.config.json`/`postkit.secrets.json` — they create those files (always with the full db+auth+stack shape, since the db/auth config loaders throw on a missing section) only if missing, and otherwise reuse the existing project name - Prompts for project name → generates `_<8hexchars>`, stored as `name` in `postkit.config.json` - Creates `db/infra/001_roles.sql` (anon, authenticated, service_role, app_user, authenticator roles) - Creates `db/infra/002_schemas.sql` (public, auth, storage schemas) +- Full init only (not `init db`): scaffolds a `storage.migrations` bootstrap migration (`.postkit/db/migrations/00000000000001_create_storage_migrations_table.sql`, tracked in `committed.json`) — a migration-tracking table expected by a self-hosted storage service (e.g. Supabase storage-api) run against the `storage` schema; delete the file + its `committed.json` entry if you don't run one - Copies vendor provider JARs to `.postkit/auth/providers/` - Scaffolds realm template at `.postkit/auth/realm/postkit.json` @@ -274,6 +278,15 @@ Remotes are managed via utilities in `modules/db/utils/remotes.ts`: - **tsx** for development mode (direct TS execution without building) - Output goes to `dist/` with a shebang banner for CLI execution +## Project Init Commands Reference + +| Command | Purpose | +|---------|---------| +| `postkit init` | Scaffold the entire project: db + auth + stack | +| `postkit init db` | Scaffold only the db module (`.postkit/db/`, `db/infra/*.sql`) — does not scaffold the `storage.migrations` bootstrap migration, which is full-init only | +| `postkit init auth` | Scaffold only the auth module (`.postkit/auth/`, Keycloak provider sync, realm template) | +| `postkit init stack` | Scaffold only the stack module (`.postkit/stack/`) | + ## Database Module Commands Reference | Command | Purpose | diff --git a/agent/skills/postkit-setup/SKILL.md b/agent/skills/postkit-setup/SKILL.md index 9a10db4..bcda8ef 100644 --- a/agent/skills/postkit-setup/SKILL.md +++ b/agent/skills/postkit-setup/SKILL.md @@ -20,7 +20,8 @@ This creates: - `postkit.config.json` — Committed project configuration (schema paths, flags) - `postkit.secrets.json` — Gitignored secrets (DB URLs, remote credentials) - `db/schema/public/` — Default schema directory structure -- `db/infra/` — Infrastructure SQL directory +- `db/infra/` — Infrastructure SQL directory (roles, schemas) +- `.postkit/db/migrations/` — includes a `storage.migrations` bootstrap migration for a self-hosted storage service (e.g. Supabase storage-api) — delete it (and its `committed.json` entry) if you don't run one - `.postkit/` — Runtime directory (gitignored) - `.gitignore` entries for secrets and ephemeral files @@ -30,6 +31,20 @@ Add `-f` to skip confirmation prompts: postkit init -f ``` +### Scaffold a single module + +`postkit init` with no argument scaffolds everything (db + auth + stack). To scaffold only one module — e.g. when adding a module to a project that was only ever partially initialized — pass its name: + +```bash +postkit init db # .postkit/db/, db/infra/*.sql +postkit init auth # .postkit/auth/, Keycloak provider sync, realm template +postkit init stack # .postkit/stack/ +``` + +Scoped runs never re-prompt for or overwrite an existing `postkit.config.json`/`postkit.secrets.json` — they only create those files if missing, and reuse the existing project name otherwise. Each run is idempotent and only updates its own slice of `.gitignore`. + +Note: the `storage.migrations` bootstrap migration is only scaffolded by the full `postkit init` — `postkit init db` does not create it. + ## Configuration Files PostKit splits config across two files: diff --git a/cli/docs/architecture.md b/cli/docs/architecture.md index 5f9f0db..44df94f 100644 --- a/cli/docs/architecture.md +++ b/cli/docs/architecture.md @@ -21,6 +21,8 @@ PostKit is a modular CLI toolkit built with **TypeScript** and **Node.js** that └───────────────────────────────────────────────────────────────────────┘ ``` +**`postkit init [module]`** scaffolds the project. With no argument it scaffolds everything (db + auth + stack) in one pass — the original, unchanged behavior. Passing `db`, `auth`, or `stack` scopes the run to just that module (`cli/src/commands/init.ts`): scoped runs never re-prompt for or overwrite an existing `postkit.config.json`, only creating it — with the full db+auth+stack shape, since the db/auth config loaders throw on a missing section — if it doesn't exist yet, and reusing the existing project name otherwise. + --- ## Module System @@ -354,3 +356,5 @@ PostKit files in `.postkit/` are split between gitignored (ephemeral/user-specif - `.postkit/auth/providers/` - `.postkit/stack/` - `postkit.secrets.json` + +Full `postkit init` (not the scoped `init db`) also scaffolds a committed bootstrap migration at `.postkit/db/migrations/00000000000001_create_storage_migrations_table.sql`, creating a `storage.migrations` tracking table for a self-hosted storage service (e.g. Supabase storage-api) run against the `storage` schema. It's registered in `committed.json` like any other migration — delete both the file and its `committed.json` entry if you don't run one. diff --git a/cli/docs/db.md b/cli/docs/db.md index 534e38f..bdd7720 100644 --- a/cli/docs/db.md +++ b/cli/docs/db.md @@ -222,12 +222,15 @@ PostKit files in `.postkit/db/` are split between gitignored (ephemeral) and com │ └── 20250131_*.sql ├── committed.json # COMMITTED — migrations tracking index (shared) └── migrations/ # COMMITTED — committed migrations (for deploy) + ├── 00000000000001_create_storage_migrations_table.sql # scaffolded by init — see below ├── 20250130_add_users.sql └── 20250131_add_posts.sql ``` `postkit init` adds only the ephemeral paths to `.gitignore` (`.postkit/db/session.json`, `.postkit/db/plan.sql`, `.postkit/db/schema.sql`, `.postkit/db/session/`). The `migrations/` directory and `committed.json` are committed to git and shared across the team. +Full `postkit init` (not the scoped `init db`) always scaffolds `00000000000001_create_storage_migrations_table.sql` — a committed migration creating a `storage.migrations` tracking table for a self-hosted storage service (e.g. Supabase storage-api) run against the `storage` schema. If your project doesn't run one, delete the file and its entry in `committed.json`. + --- ## 🚀 Commands diff --git a/cli/docs/e2e-testing.md b/cli/docs/e2e-testing.md index cbfb877..52d2300 100644 --- a/cli/docs/e2e-testing.md +++ b/cli/docs/e2e-testing.md @@ -246,6 +246,11 @@ Tests infrastructure SQL (roles) and seed data management. Grant permissions are | `db status` in empty dir | CLI prevents running before init | | `init --force` | Creates complete project scaffold | | `init --force` re-init | Force flag allows re-initialization | +| `init bogus --force` | Unknown module name rejected cleanly, no files created | +| `init db --force` | Scopes scaffold to `.postkit/db/`, `db/infra/*.sql` only (no storage.migrations — full-init only) | +| `init auth --force` after `init db` | Adds only auth files; reuses the existing project name | +| `init stack --force` | Scopes scaffold to `.postkit/stack/` only | +| `init db --force` twice | Idempotent — no duplicate committed migrations or gitignore lines | ### Remote Management (`remote-management.test.ts`) diff --git a/cli/docs/stack.md b/cli/docs/stack.md index 4643f5c..80349af 100644 --- a/cli/docs/stack.md +++ b/cli/docs/stack.md @@ -94,7 +94,7 @@ Keycloak provider JARs are mounted at `/opt/keycloak/providers` inside the conta **Destination:** `.postkit/auth/providers/` — gitignored, rebuilt by `postkit init`. -If you add or update a project provider, re-run `postkit init` to sync the new JAR, then restart the stack. +If you add or update a project provider, re-run `postkit init` (or the auth-only `postkit init auth`) to sync the new JAR, then restart the stack. --- @@ -102,7 +102,7 @@ If you add or update a project provider, re-run `postkit init` to sync the new J On the first `stack up` (when `is_initial=true`), PostKit imports a Keycloak realm template. -The template path is configured via `stack.keycloak.realmTemplate` (default: `.postkit/auth/realm/postkit.json`). Scaffolded automatically by `postkit init`. +The template path is configured via `stack.keycloak.realmTemplate` (default: `.postkit/auth/realm/postkit.json`). Scaffolded automatically by `postkit init` (or the auth-only `postkit init auth`). Before importing, `cleanRealmTemplate()` transforms the raw template JSON: diff --git a/cli/package-lock.json b/cli/package-lock.json index 47d0dcd..5970d0b 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@appritech/postkit", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@appritech/postkit", - "version": "1.3.0", + "version": "1.3.1", "license": "Apache-2.0", "dependencies": { "chalk": "^5.3.0", diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 4ea7bd1..c620ca5 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -17,21 +17,24 @@ import { } from "../common/config"; import type {CommandOptions} from "../common/types"; import type {PostkitPublicConfig, PostkitSecrets} from "../common/config"; -import {scaffoldDbInfra} from "../modules/db/services/scaffold"; +import {scaffoldDbInfra, scaffoldStorageMigration} from "../modules/db/services/scaffold"; import {scaffoldRealmTemplate, DEFAULT_REALM_TEMPLATE_PATH} from "../modules/stack/services/scaffold"; import {syncKeycloakProviders} from "../modules/stack/services/sync-providers"; +const VALID_MODULES = ["db", "auth", "stack"] as const; +type InitModule = (typeof VALID_MODULES)[number]; + // Ephemeral/user-specific files are gitignored; committed migrations and auth state are tracked. // postkit.config.json is safe to commit. -const GITIGNORE_ENTRIES = [ - "# Postkit", +const SHARED_GITIGNORE_ENTRIES = ["# Postkit", "postkit.secrets.json"]; +const DB_GITIGNORE_ENTRIES = [ ".postkit/db/session.json", ".postkit/db/plan_*.sql", ".postkit/db/schema_*.sql", ".postkit/db/session/", - ".postkit/stack/", - "postkit.secrets.json", ]; +const AUTH_GITIGNORE_ENTRIES = [".postkit/auth/providers/"]; +const STACK_GITIGNORE_ENTRIES = [".postkit/stack/"]; // Non-sensitive settings committed to git — no remotes (user/env-specific, lives in secrets) const SCAFFOLD_PUBLIC_CONFIG: PostkitPublicConfig = { @@ -107,7 +110,180 @@ const SCAFFOLD_SECRETS_EXAMPLE: PostkitSecrets = { }, }; -export async function initCommand(options: CommandOptions): Promise { +/** + * Write postkit.config.json / postkit.secrets.json / postkit.secrets.example.json. + * Config always gets the full db+auth+stack shape, regardless of which module + * triggered the write — config loaders for db/auth throw on a missing section, + * so a partial config would break other modules the moment they're used later. + */ +function writeProjectConfig(projectName: string): void { + const publicConfig: PostkitPublicConfig = {...SCAFFOLD_PUBLIC_CONFIG, name: projectName}; + fs.writeFileSync(getConfigFilePath(), JSON.stringify(publicConfig, null, 2) + "\n"); + fs.writeFileSync(getSecretsFilePath(), JSON.stringify(SCAFFOLD_SECRETS, null, 2) + "\n"); + + const exampleFile = path.join(projectRoot, "postkit.secrets.example.json"); + fs.writeFileSync(exampleFile, JSON.stringify(SCAFFOLD_SECRETS_EXAMPLE, null, 2) + "\n"); +} + +/** + * Ensure postkit.config.json/postkit.secrets.json exist, only prompting for and + * generating a project name when the config file doesn't exist yet. Never + * overwrites an existing config — used by scoped (`init `) runs so + * running one module's init after another never clobbers the project. + */ +async function ensureProjectConfigExists(options: CommandOptions): Promise { + const configFile = getConfigFilePath(); + + if (fs.existsSync(configFile)) { + return; + } + + if (options.dryRun) { + logger.info(`Dry run: would create ${POSTKIT_CONFIG_FILE} and ${POSTKIT_SECRETS_FILE}`); + return; + } + + const rawName = await promptInput("Project name:", { + required: true, + force: options.force, + }); + const randomId = crypto.randomBytes(4).toString("hex"); + const projectName = `${rawName.trim().toLowerCase().replace(/\s+/g, "-")}_${randomId}`; + logger.info(`Project ID: ${projectName}`); + + const spinner = ora("Writing config files...").start(); + writeProjectConfig(projectName); + spinner.succeed(`${POSTKIT_CONFIG_FILE}, ${POSTKIT_SECRETS_FILE}, and postkit.secrets.example.json created`); +} + +function scaffoldDbFiles(options: CommandOptions): void { + if (options.dryRun) { + logger.info(`Dry run: would create ${POSTKIT_DIR}/db/, db/infra/*.sql, and the storage.migrations migration`); + return; + } + + const spinner = ora("Creating .postkit/db/ directory...").start(); + const postkitDbDir = path.join(getPostkitDir(), "db"); + fs.mkdirSync(postkitDbDir, {recursive: true}); + // session.json, plan_*.sql, schema_*.sql are intentionally excluded — created on demand + const committedFilePath = path.join(postkitDbDir, "committed.json"); + if (!fs.existsSync(committedFilePath)) { + fs.writeFileSync(committedFilePath, JSON.stringify({migrations: []}, null, 2)); + } + for (const subdir of ["session", "migrations"]) { + const subPath = path.join(postkitDbDir, subdir); + if (!fs.existsSync(subPath)) { + fs.mkdirSync(subPath, {recursive: true}); + } + } + spinner.succeed(".postkit/db/ directory created"); + + const infraSpinner = ora("Creating db/infra/roles.sql...").start(); + const infraCreated = scaffoldDbInfra(); + infraSpinner.succeed(infraCreated ? "db/infra/roles.sql created" : "db/infra/roles.sql already exists — skipped"); +} + +async function scaffoldDbMigration(options: CommandOptions): Promise { + if (options.dryRun) { + logger.info("Dry run: would create committed migration for storage.migrations table"); + return; + } + + const spinner = ora("Creating storage.migrations init migration...").start(); + const created = await scaffoldStorageMigration(); + spinner.succeed( + created + ? "storage.migrations init migration created" + : "storage.migrations init migration already exists — skipped", + ); +} + +function createAuthDir(options: CommandOptions): void { + if (options.dryRun) { + logger.info(`Dry run: would create ${POSTKIT_DIR}/auth/`); + return; + } + + const spinner = ora("Creating .postkit/auth/ directory...").start(); + const postkitAuthDir = getPostkitAuthDir(); + for (const subdir of ["raw", "realm", "providers"]) { + const subPath = path.join(postkitAuthDir, subdir); + if (!fs.existsSync(subPath)) { + fs.mkdirSync(subPath, {recursive: true}); + } + } + // Copy bundled Keycloak provider JARs from cli/vendor/providers/ + syncKeycloakProviders(); + spinner.succeed(".postkit/auth/ directory created"); +} + +function scaffoldRealmTemplateStep(options: CommandOptions): void { + if (options.dryRun) { + logger.info(`Dry run: would create ${DEFAULT_REALM_TEMPLATE_PATH}`); + return; + } + + const spinner = ora(`Creating ${DEFAULT_REALM_TEMPLATE_PATH}...`).start(); + const created = scaffoldRealmTemplate(); + spinner.succeed(created ? `${DEFAULT_REALM_TEMPLATE_PATH} created` : `${DEFAULT_REALM_TEMPLATE_PATH} already exists — skipped`); +} + +function scaffoldAuthFiles(options: CommandOptions): void { + createAuthDir(options); + scaffoldRealmTemplateStep(options); +} + +function scaffoldStackFiles(options: CommandOptions): void { + if (options.dryRun) { + logger.info(`Dry run: would create ${POSTKIT_DIR}/stack/`); + return; + } + + const spinner = ora("Creating .postkit/stack/ directory...").start(); + fs.mkdirSync(getStackDir(), {recursive: true}); + spinner.succeed(".postkit/stack/ directory created"); +} + +function updateGitignore(entries: string[], options: CommandOptions): void { + if (options.dryRun) { + logger.info("Dry run: would update .gitignore with Postkit entries"); + return; + } + + const spinner = ora("Updating .gitignore...").start(); + const gitignorePath = path.join(projectRoot, ".gitignore"); + let existingContent = ""; + if (fs.existsSync(gitignorePath)) { + existingContent = fs.readFileSync(gitignorePath, "utf-8"); + } + + const missingEntries = entries.filter((entry) => !existingContent.includes(entry)); + + if (missingEntries.length > 0) { + const suffix = existingContent.length > 0 && !existingContent.endsWith("\n") ? "\n" : ""; + const separator = existingContent.length > 0 ? "\n" : ""; + fs.appendFileSync(gitignorePath, suffix + separator + missingEntries.join("\n") + "\n"); + spinner.succeed(".gitignore updated"); + } else { + spinner.succeed(".gitignore already up to date"); + } +} + +export async function initCommand(options: CommandOptions, module?: string): Promise { + if (module && !VALID_MODULES.includes(module as InitModule)) { + logger.error(`Unknown module: "${module}". Available modules: ${VALID_MODULES.join(", ")}`); + return; + } + + if (module) { + await initModuleCommand(options, module as InitModule); + return; + } + + await initFullCommand(options); +} + +async function initFullCommand(options: CommandOptions): Promise { logger.heading("Postkit Init"); // Prompt for project name — required @@ -121,8 +297,7 @@ export async function initCommand(options: CommandOptions): Promise { const postkitDir = getPostkitDir(); const configFile = getConfigFilePath(); - const alreadyInitialized = - fs.existsSync(postkitDir) || fs.existsSync(configFile); + const alreadyInitialized = fs.existsSync(postkitDir) || fs.existsSync(configFile); if (alreadyInitialized && !options.force) { logger.warn("Postkit is already initialized in this directory."); @@ -132,10 +307,10 @@ export async function initCommand(options: CommandOptions): Promise { return; } - const confirmed = await promptConfirm( - "Overwrite existing configuration?", - {default: false, force: options.force}, - ); + const confirmed = await promptConfirm("Overwrite existing configuration?", { + default: false, + force: options.force, + }); if (!confirmed) { logger.info("Init cancelled."); @@ -145,126 +320,36 @@ export async function initCommand(options: CommandOptions): Promise { const totalSteps = 8; - // Step 1: Create .postkit/db/ directory logger.step(1, totalSteps, "Creating .postkit/db/ directory"); - if (options.dryRun) { - logger.info(`Dry run: would create ${POSTKIT_DIR}/db/`); - } else { - const spinner = ora("Creating .postkit/db/ directory...").start(); - const postkitDbDir = path.join(postkitDir, "db"); - fs.mkdirSync(postkitDbDir, {recursive: true}); - // session.json, plan_*.sql, schema_*.sql are intentionally excluded — created on demand - const committedFilePath = path.join(postkitDbDir, "committed.json"); - if (!fs.existsSync(committedFilePath)) { - fs.writeFileSync(committedFilePath, JSON.stringify({migrations: []}, null, 2)); - } - for (const subdir of ["session", "migrations"]) { - const subPath = path.join(postkitDbDir, subdir); - if (!fs.existsSync(subPath)) { - fs.mkdirSync(subPath, {recursive: true}); - } - } - spinner.succeed(".postkit/db/ directory created"); - } + scaffoldDbFiles(options); - // Step 2: Create .postkit/auth/ directory logger.step(2, totalSteps, "Creating .postkit/auth/ directory"); - if (options.dryRun) { - logger.info(`Dry run: would create ${POSTKIT_DIR}/auth/`); - } else { - const spinner = ora("Creating .postkit/auth/ directory...").start(); - const postkitAuthDir = getPostkitAuthDir(); - for (const subdir of ["raw", "realm", "providers"]) { - const subPath = path.join(postkitAuthDir, subdir); - if (!fs.existsSync(subPath)) { - fs.mkdirSync(subPath, {recursive: true}); - } - } - // Copy bundled Keycloak provider JARs from cli/vendor/providers/ - syncKeycloakProviders(); - spinner.succeed(".postkit/auth/ directory created"); - } + createAuthDir(options); - // Step 3: Create .postkit/stack/ directory logger.step(3, totalSteps, "Creating .postkit/stack/ directory"); - if (options.dryRun) { - logger.info(`Dry run: would create ${POSTKIT_DIR}/stack/`); - } else { - const spinner = ora("Creating .postkit/stack/ directory...").start(); - const stackDir = getStackDir(); - fs.mkdirSync(stackDir, {recursive: true}); - spinner.succeed(".postkit/stack/ directory created"); - } + scaffoldStackFiles(options); - // Step 4: Generate config and secrets files logger.step(4, totalSteps, "Generating config and secrets files"); if (options.dryRun) { logger.info(`Dry run: would create ${POSTKIT_CONFIG_FILE} (committed) and ${POSTKIT_SECRETS_FILE} (gitignored)`); } else { const spinner = ora("Writing config files...").start(); - - const publicConfig: PostkitPublicConfig = {...SCAFFOLD_PUBLIC_CONFIG, name: projectName}; - fs.writeFileSync(configFile, JSON.stringify(publicConfig, null, 2) + "\n"); - - const secretsFile = getSecretsFilePath(); - fs.writeFileSync(secretsFile, JSON.stringify(SCAFFOLD_SECRETS, null, 2) + "\n"); - - // Write example secrets template so teammates know the expected shape - const exampleFile = path.join(projectRoot, "postkit.secrets.example.json"); - fs.writeFileSync(exampleFile, JSON.stringify(SCAFFOLD_SECRETS_EXAMPLE, null, 2) + "\n"); - + writeProjectConfig(projectName); spinner.succeed(`${POSTKIT_CONFIG_FILE}, ${POSTKIT_SECRETS_FILE}, and postkit.secrets.example.json created`); } - // Step 5: Scaffold db/infra/roles.sql - logger.step(5, totalSteps, "Scaffolding db/infra/roles.sql"); - if (options.dryRun) { - logger.info("Dry run: would create db/infra/roles.sql with PostgREST roles"); - } else { - const spinner = ora("Creating db/infra/roles.sql...").start(); - const created = scaffoldDbInfra(); - spinner.succeed(created ? "db/infra/roles.sql created" : "db/infra/roles.sql already exists — skipped"); - } + logger.step(5, totalSteps, "Scaffolding db/infra/roles.sql and storage.migrations"); + await scaffoldDbMigration(options); - // Step 6: Scaffold realm template logger.step(6, totalSteps, "Scaffolding realm template"); - if (options.dryRun) { - logger.info(`Dry run: would create ${DEFAULT_REALM_TEMPLATE_PATH}`); - } else { - const spinner = ora(`Creating ${DEFAULT_REALM_TEMPLATE_PATH}...`).start(); - const created = scaffoldRealmTemplate(); - spinner.succeed(created ? `${DEFAULT_REALM_TEMPLATE_PATH} created` : `${DEFAULT_REALM_TEMPLATE_PATH} already exists — skipped`); - } + scaffoldRealmTemplateStep(options); - // Step 7: Update .gitignore logger.step(7, totalSteps, "Updating .gitignore"); - const gitignorePath = path.join(projectRoot, ".gitignore"); - if (options.dryRun) { - logger.info("Dry run: would update .gitignore with Postkit entries"); - } else { - const spinner = ora("Updating .gitignore...").start(); - let existingContent = ""; - if (fs.existsSync(gitignorePath)) { - existingContent = fs.readFileSync(gitignorePath, "utf-8"); - } - - const missingEntries = GITIGNORE_ENTRIES.filter( - (entry) => !existingContent.includes(entry), - ); - - if (missingEntries.length > 0) { - const suffix = existingContent.length > 0 && !existingContent.endsWith("\n") - ? "\n" - : ""; - const separator = existingContent.length > 0 ? "\n" : ""; - fs.appendFileSync(gitignorePath, suffix + separator + missingEntries.join("\n") + "\n"); - spinner.succeed(".gitignore updated"); - } else { - spinner.succeed(".gitignore already up to date"); - } - } + updateGitignore( + [...SHARED_GITIGNORE_ENTRIES, ...DB_GITIGNORE_ENTRIES, ...AUTH_GITIGNORE_ENTRIES, ...STACK_GITIGNORE_ENTRIES], + options, + ); - // Step 8: Summary logger.step(8, totalSteps, "Done"); logger.blank(); logger.success("Postkit project initialized!"); @@ -282,6 +367,7 @@ export async function initCommand(options: CommandOptions): Promise { logger.info(` .postkit/db/plan_*.sql — generated diffs (ephemeral, per schema)`); logger.info(` .postkit/db/schema_*.sql — generated schemas (ephemeral, per schema)`); logger.info(` .postkit/db/session/ — temporary session migrations`); + logger.info(` .postkit/auth/providers/ — synced Keycloak provider JARs`); logger.blank(); logger.info("Next steps:"); logger.info(` 1. Fill in ${POSTKIT_SECRETS_FILE} with your database credentials`); @@ -291,3 +377,46 @@ export async function initCommand(options: CommandOptions): Promise { logger.info(" postkit stack up"); logger.info(" 4. Or run postkit db start to begin a migration session"); } + +async function initModuleCommand(options: CommandOptions, module: InitModule): Promise { + logger.heading(`Postkit Init (${module})`); + + const totalSteps = 3; + + logger.step(1, totalSteps, "Ensuring project config exists"); + await ensureProjectConfigExists(options); + + logger.step(2, totalSteps, `Scaffolding ${module} module`); + switch (module) { + case "db": + scaffoldDbFiles(options); + break; + case "auth": + scaffoldAuthFiles(options); + break; + case "stack": + scaffoldStackFiles(options); + break; + } + + logger.step(3, totalSteps, "Updating .gitignore"); + const entries = { + db: [...SHARED_GITIGNORE_ENTRIES, ...DB_GITIGNORE_ENTRIES], + auth: [...SHARED_GITIGNORE_ENTRIES, ...AUTH_GITIGNORE_ENTRIES], + stack: [...SHARED_GITIGNORE_ENTRIES, ...STACK_GITIGNORE_ENTRIES], + }[module]; + updateGitignore(entries, options); + + logger.blank(); + logger.success(`Postkit ${module} module initialized!`); + logger.blank(); + logger.info("Next steps:"); + if (module === "db") { + logger.info(" postkit db remote add staging \"postgres://...\""); + logger.info(" postkit db start"); + } else if (module === "auth") { + logger.info(" postkit auth export / import / sync"); + } else { + logger.info(" postkit stack up"); + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index d7b5877..2f3b5f5 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -31,12 +31,12 @@ program // Register init command program - .command("init") - .description("Initialize a new Postkit project") + .command("init [module]") + .description("Initialize a Postkit project, or scaffold a single module: db, auth, stack") .option("-f, --force", "Skip confirmation prompts") - .action(async (cmdOptions) => { + .action(async (moduleArg, cmdOptions) => { const options = {...program.opts(), ...cmdOptions}; - await initCommand(options); + await initCommand(options, moduleArg); }); // Register modules diff --git a/cli/src/modules/db/services/scaffold.ts b/cli/src/modules/db/services/scaffold.ts index 29e39a4..f2d3a58 100644 --- a/cli/src/modules/db/services/scaffold.ts +++ b/cli/src/modules/db/services/scaffold.ts @@ -1,6 +1,8 @@ import fs from "fs"; import path from "path"; import {projectRoot} from "../../../common/config"; +import {getCommittedMigrationsPath, toRelativePath} from "../utils/db-config"; +import {getAllCommittedMigrations, addCommittedMigration} from "../utils/committed"; const ROLES_SQL = `DO $$ BEGIN @@ -75,3 +77,61 @@ export function scaffoldDbInfra(): boolean { return created; } + +// Fixed (not wall-clock) timestamp so the file name is stable across `postkit init` +// re-runs and sorts before any real migration in .postkit/db/migrations/. +const STORAGE_MIGRATION_TIMESTAMP = "00000000000001"; +const STORAGE_MIGRATION_NAME = `${STORAGE_MIGRATION_TIMESTAMP}_create_storage_migrations_table.sql`; +const STORAGE_MIGRATION_DESCRIPTION = "create storage.migrations table"; + +const STORAGE_MIGRATION_SQL = `-- migrate:up +-- If you don't run a storage service (e.g. Supabase storage-api) against the +-- storage schema, this migration is unused — delete this file and its entry +-- in .postkit/db/committed.json. +CREATE TABLE IF NOT EXISTS storage.migrations ( + id INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL, + hash VARCHAR(255) NOT NULL, + executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- migrate:down +DROP TABLE IF EXISTS storage.migrations; +`; + +/** + * Scaffold a committed migration that creates storage.migrations — the migration + * tracking table expected by storage services (e.g. Supabase's storage-api) run + * against the `storage` schema. Runs unconditionally as part of `postkit init`. + * Safe to call multiple times — never overwrites an existing migration file. + * Returns true if the migration was created, false if it already existed. + */ +export async function scaffoldStorageMigration(): Promise { + const migrationsDir = getCommittedMigrationsPath(); + fs.mkdirSync(migrationsDir, {recursive: true}); + + const migrationPath = path.join(migrationsDir, STORAGE_MIGRATION_NAME); + if (fs.existsSync(migrationPath)) { + return false; + } + + fs.writeFileSync(migrationPath, STORAGE_MIGRATION_SQL); + + const existing = await getAllCommittedMigrations(); + const alreadyTracked = existing.some((m) => m.migrationFile.name === STORAGE_MIGRATION_NAME); + + if (!alreadyTracked) { + await addCommittedMigration({ + migrationFile: { + name: STORAGE_MIGRATION_NAME, + path: toRelativePath(migrationPath), + timestamp: STORAGE_MIGRATION_TIMESTAMP, + }, + description: STORAGE_MIGRATION_DESCRIPTION, + sessionMigrations: [], + committedAt: new Date().toISOString(), + }); + } + + return true; +} diff --git a/cli/test/e2e/smoke/basic-commands.test.ts b/cli/test/e2e/smoke/basic-commands.test.ts index 17b5ae7..926025a 100644 --- a/cli/test/e2e/smoke/basic-commands.test.ts +++ b/cli/test/e2e/smoke/basic-commands.test.ts @@ -142,7 +142,7 @@ describe("init command — detailed tests (no Docker)", () => { } }); - it("initializes committed.json with empty migrations array", async () => { + it("initializes committed.json with only the storage.migrations bootstrap migration", async () => { const tmpDir = await createEmptyDir(); try { await runCli(["init", "--force"], {cwd: tmpDir}); @@ -150,7 +150,10 @@ describe("init command — detailed tests (no Docker)", () => { const committed = JSON.parse( fs.readFileSync(path.join(tmpDir, ".postkit", "db", "committed.json"), "utf-8"), ); - expect(committed).toEqual({migrations: []}); + expect(committed.migrations).toHaveLength(1); + expect(committed.migrations[0].migrationFile.name).toBe( + "00000000000001_create_storage_migrations_table.sql", + ); } finally { await cleanupDir(tmpDir); } @@ -168,11 +171,14 @@ describe("init command — detailed tests (no Docker)", () => { expect(gitignore).toContain(".postkit/db/schema_*.sql"); expect(gitignore).toContain(".postkit/db/session/"); expect(gitignore).toContain("postkit.secrets.json"); + // Synced Keycloak provider JARs must be gitignored + expect(gitignore).toContain(".postkit/auth/providers/"); // Committed files must NOT be gitignored expect(gitignore).not.toContain("postkit.config.json"); expect(gitignore).not.toContain(".postkit/db/migrations"); expect(gitignore).not.toContain(".postkit/db/committed.json"); - expect(gitignore).not.toContain(".postkit/auth"); + expect(gitignore).not.toContain(".postkit/auth/raw"); + expect(gitignore).not.toContain(".postkit/auth/realm"); } finally { await cleanupDir(tmpDir); } @@ -227,3 +233,109 @@ describe("init command — detailed tests (no Docker)", () => { } }); }); + +describe("init [module] — scoped init", () => { + it("rejects an unknown module without creating any files", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["init", "bogus", "--force"], {cwd: tmpDir}); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/Unknown module/); + expect(fs.existsSync(path.join(tmpDir, "postkit.config.json"))).toBe(false); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("`init db` on a fresh directory scaffolds only the db module", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["init", "db", "--force"], {cwd: tmpDir}); + expect(result.exitCode).toBe(0); + + expect(fs.existsSync(path.join(tmpDir, "postkit.config.json"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, "postkit.secrets.json"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, ".postkit", "db", "committed.json"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, "db", "infra", "001_roles.sql"))).toBe(true); + // storage.migrations is only bootstrapped by the full `postkit init`, not the scoped db-only init + expect(fs.existsSync(path.join(tmpDir, ".postkit", "db", "migrations", "00000000000001_create_storage_migrations_table.sql"))).toBe(false); + const committed = JSON.parse( + fs.readFileSync(path.join(tmpDir, ".postkit", "db", "committed.json"), "utf-8"), + ); + expect(committed.migrations).toHaveLength(0); + + // auth/stack were NOT scaffolded + expect(fs.existsSync(path.join(tmpDir, ".postkit", "auth"))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, ".postkit", "stack"))).toBe(false); + + const gitignore = fs.readFileSync(path.join(tmpDir, ".gitignore"), "utf-8"); + expect(gitignore).toContain(".postkit/db/session.json"); + expect(gitignore).not.toContain(".postkit/auth/providers/"); + expect(gitignore).not.toContain(".postkit/stack/"); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("`init auth` after `init db` adds only auth files and keeps the same project name", async () => { + const tmpDir = await createEmptyDir(); + try { + await runCli(["init", "db", "--force"], {cwd: tmpDir}); + const nameBefore = JSON.parse( + fs.readFileSync(path.join(tmpDir, "postkit.config.json"), "utf-8"), + ).name; + + const result = await runCli(["init", "auth", "--force"], {cwd: tmpDir}); + expect(result.exitCode).toBe(0); + + expect(fs.existsSync(path.join(tmpDir, ".postkit", "auth", "raw"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, ".postkit", "auth", "realm", "postkit.json"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, ".postkit", "stack"))).toBe(false); + + const nameAfter = JSON.parse( + fs.readFileSync(path.join(tmpDir, "postkit.config.json"), "utf-8"), + ).name; + expect(nameAfter).toBe(nameBefore); + + const gitignore = fs.readFileSync(path.join(tmpDir, ".gitignore"), "utf-8"); + expect(gitignore).toContain(".postkit/auth/providers/"); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("`init stack` scaffolds only the .postkit/stack/ directory", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["init", "stack", "--force"], {cwd: tmpDir}); + expect(result.exitCode).toBe(0); + + expect(fs.existsSync(path.join(tmpDir, ".postkit", "stack"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, ".postkit", "db"))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, ".postkit", "auth"))).toBe(false); + + const gitignore = fs.readFileSync(path.join(tmpDir, ".gitignore"), "utf-8"); + expect(gitignore).toContain(".postkit/stack/"); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("`init db` twice is idempotent — no duplicate committed migrations or gitignore lines", async () => { + const tmpDir = await createEmptyDir(); + try { + await runCli(["init", "db", "--force"], {cwd: tmpDir}); + await runCli(["init", "db", "--force"], {cwd: tmpDir}); + + const committed = JSON.parse( + fs.readFileSync(path.join(tmpDir, ".postkit", "db", "committed.json"), "utf-8"), + ); + expect(committed.migrations).toHaveLength(0); + + const gitignore = fs.readFileSync(path.join(tmpDir, ".gitignore"), "utf-8"); + expect(gitignore.match(/\.postkit\/db\/session\.json/g)).toHaveLength(1); + } finally { + await cleanupDir(tmpDir); + } + }); +}); diff --git a/cli/test/modules/db/services/scaffold.test.ts b/cli/test/modules/db/services/scaffold.test.ts new file mode 100644 index 0000000..3289a15 --- /dev/null +++ b/cli/test/modules/db/services/scaffold.test.ts @@ -0,0 +1,179 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +vi.mock("../../../../src/common/config", () => ({ + projectRoot: "/project", +})); + +vi.mock("../../../../src/modules/db/utils/db-config", () => ({ + getCommittedMigrationsPath: vi.fn(() => "/project/.postkit/db/migrations"), + toRelativePath: vi.fn((p: string) => p.replace("/project/", "")), +})); + +vi.mock("../../../../src/modules/db/utils/committed", () => ({ + getAllCommittedMigrations: vi.fn(), + addCommittedMigration: vi.fn(), +})); + +import fs from "fs"; +import {scaffoldDbInfra, scaffoldStorageMigration} from "../../../../src/modules/db/services/scaffold"; +import {getAllCommittedMigrations, addCommittedMigration} from "../../../../src/modules/db/utils/committed"; + +describe("scaffoldDbInfra()", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates db/infra/ with recursive mkdir", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldDbInfra(); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining("db/infra"), + {recursive: true}, + ); + }); + + it("writes both 001_roles.sql and 002_schemas.sql when neither exists, returns true", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = scaffoldDbInfra(); + + expect(result).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalledTimes(2); + const writtenPaths = vi.mocked(fs.writeFileSync).mock.calls.map((c) => c[0] as string); + expect(writtenPaths.some((p) => p.endsWith("001_roles.sql"))).toBe(true); + expect(writtenPaths.some((p) => p.endsWith("002_schemas.sql"))).toBe(true); + }); + + it("roles.sql content declares the expected PostgREST roles", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldDbInfra(); + + const rolesCall = vi.mocked(fs.writeFileSync).mock.calls.find((c) => + (c[0] as string).endsWith("001_roles.sql"), + )!; + const content = rolesCall[1] as string; + for (const role of ["anon", "authenticated", "service_role", "app_user", "authenticator"]) { + expect(content).toContain(`'${role}'`); + } + }); + + it("schemas.sql content declares public, auth, and storage schemas", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldDbInfra(); + + const schemasCall = vi.mocked(fs.writeFileSync).mock.calls.find((c) => + (c[0] as string).endsWith("002_schemas.sql"), + )!; + const content = schemasCall[1] as string; + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS public;"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS auth;"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS storage;"); + }); + + it("returns false and writes nothing when both files already exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = scaffoldDbInfra(); + + expect(result).toBe(false); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("creates only the missing file when one already exists, returns true", () => { + vi.mocked(fs.existsSync).mockImplementation((p) => (p as string).endsWith("001_roles.sql")); + + const result = scaffoldDbInfra(); + + expect(result).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + expect(vi.mocked(fs.writeFileSync).mock.calls[0]![0]).toContain("002_schemas.sql"); + }); +}); + +describe("scaffoldStorageMigration()", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAllCommittedMigrations).mockResolvedValue([]); + }); + + it("creates the committed migrations dir with recursive mkdir", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await scaffoldStorageMigration(); + + expect(fs.mkdirSync).toHaveBeenCalledWith("/project/.postkit/db/migrations", {recursive: true}); + }); + + it("skips writing and tracking when the migration file already exists, returns false", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = await scaffoldStorageMigration(); + + expect(result).toBe(false); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(addCommittedMigration).not.toHaveBeenCalled(); + }); + + it("writes the migration file with the storage.migrations SQL when missing", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await scaffoldStorageMigration(); + + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + const [writtenPath, content] = vi.mocked(fs.writeFileSync).mock.calls[0]!; + expect(writtenPath).toContain("00000000000001_create_storage_migrations_table.sql"); + expect(content).toContain("-- migrate:up"); + expect(content).toContain("CREATE TABLE IF NOT EXISTS storage.migrations"); + expect(content).toContain("-- migrate:down"); + expect(content).toContain("DROP TABLE IF EXISTS storage.migrations;"); + }); + + it("registers the migration in committed state with a fixed timestamp and empty sessionMigrations", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = await scaffoldStorageMigration(); + + expect(result).toBe(true); + expect(addCommittedMigration).toHaveBeenCalledOnce(); + const registered = vi.mocked(addCommittedMigration).mock.calls[0]![0]; + expect(registered.migrationFile.name).toBe("00000000000001_create_storage_migrations_table.sql"); + expect(registered.migrationFile.timestamp).toBe("00000000000001"); + expect(registered.description).toBe("create storage.migrations table"); + expect(registered.sessionMigrations).toEqual([]); + expect(typeof registered.committedAt).toBe("string"); + }); + + it("does not re-register when already tracked in committed.json, even though the file was missing", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(getAllCommittedMigrations).mockResolvedValue([ + { + migrationFile: { + name: "00000000000001_create_storage_migrations_table.sql", + path: ".postkit/db/migrations/00000000000001_create_storage_migrations_table.sql", + timestamp: "00000000000001", + }, + description: "create storage.migrations table", + sessionMigrations: [], + committedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + + const result = await scaffoldStorageMigration(); + + expect(result).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + expect(addCommittedMigration).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 83d7758..aa6754b 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -13,7 +13,7 @@ PostKit separates non-sensitive settings from credentials using two config files | `postkit.config.json` | **Yes** | Schema paths, non-sensitive project settings | | `postkit.secrets.json` | **No** (gitignored) | Database URLs, remotes, passwords, credentials | -Both files are deep-merged at load time. `postkit init` creates all three files: the config, the secrets file, and a `postkit.secrets.example.json` template your team can use as a reference. +Both files are deep-merged at load time. `postkit init` creates all three files: the config, the secrets file, and a `postkit.secrets.example.json` template your team can use as a reference. See the [Init Command reference](/docs/reference/init) for the scoped `init db`/`init auth`/`init stack` variants. ## `postkit.config.json` (committed) diff --git a/docs/docs/getting-started/quick-start.md b/docs/docs/getting-started/quick-start.md index 00be065..e465ca5 100644 --- a/docs/docs/getting-started/quick-start.md +++ b/docs/docs/getting-started/quick-start.md @@ -23,6 +23,8 @@ This creates: - `db/schema/` - Your schema files directory - `.postkit/` - Runtime state (ephemeral files gitignored; committed migrations and auth config are tracked by git) +To scaffold only one module instead of everything — e.g. to add auth to a project you only ever ran `postkit init db` on — pass its name: `postkit init db`, `postkit init auth`, or `postkit init stack`. See the [Init Command reference](/docs/reference/init) for what each variant creates. + ## 2. Configure Remotes Add your remote databases (all remote data is stored in `postkit.secrets.json` — remotes are user-specific and never committed): diff --git a/docs/docs/reference/init.md b/docs/docs/reference/init.md new file mode 100644 index 0000000..d853aa4 --- /dev/null +++ b/docs/docs/reference/init.md @@ -0,0 +1,92 @@ +--- +sidebar_position: 1 +--- + +# Init Command + +`postkit init` scaffolds a new PostKit project. It can scaffold everything at once, or be scoped to a single module. + +## Usage + +```bash +postkit init [module] +``` + +## Arguments + +| Argument | Description | +|----------|--------------| +| `module` | Optional. One of `db`, `auth`, `stack`. Omit to scaffold the entire project. | + +## Options + +| Option | Description | +|--------|-------------| +| `-f, --force` | Skip confirmation prompts | +| `-v, --verbose` | Enable verbose output | +| `--dry-run` | Show what would be done without making changes | + +## Examples + +```bash +# Full project scaffold (db + auth + stack) +postkit init + +# Scaffold only the db module +postkit init db + +# Scaffold only the auth module +postkit init auth + +# Scaffold only the stack module +postkit init stack + +# Skip confirmation prompts +postkit init -f +``` + +## What Each Variant Creates + +### `postkit init` (full) + +- `postkit.config.json` — committed project configuration +- `postkit.secrets.json` + `postkit.secrets.example.json` — gitignored secrets file and a template for teammates +- `.postkit/db/` — `session/`, `migrations/`, `committed.json` +- `db/infra/001_roles.sql`, `db/infra/002_schemas.sql` — PostgREST roles and the `public`/`auth`/`storage` schemas +- `.postkit/db/migrations/00000000000001_create_storage_migrations_table.sql` — a bootstrap migration for a self-hosted storage service (e.g. Supabase storage-api). Delete it (and its entry in `committed.json`) if you don't run one +- `.postkit/auth/` — `raw/`, `realm/`, `providers/`. Keycloak provider JARs are synced and the realm template is scaffolded +- `.postkit/stack/` +- `.gitignore` entries covering all of the above + +### `postkit init db` + +Scaffolds only `.postkit/db/` and `db/infra/*.sql`. Does **not** create the `storage.migrations` bootstrap migration — that file is full-init only. + +### `postkit init auth` + +Scaffolds only `.postkit/auth/` — syncs Keycloak provider JARs and scaffolds the realm template. + +### `postkit init stack` + +Scaffolds only `.postkit/stack/`. + +## Scoped Runs Are Additive + +A scoped `init ` run only adds that module's files — it never touches what other modules already scaffolded, so you can build up a project incrementally: + +```bash +postkit init db # project doesn't exist yet — prompts for a project name, creates config +postkit init auth # adds auth files only; reuses the existing project name, no prompt +``` + +`postkit.config.json`/`postkit.secrets.json` are only ever created the first time any `init` variant runs (always with the full db+auth+stack shape). A scoped run never re-prompts for the project name or overwrites an existing config. + +## Idempotency + +Every `init` variant is safe to re-run. Existing files are left untouched, and entries in `.gitignore` and `committed.json` are never duplicated. + +## Related + +- [Configuration](/docs/getting-started/configuration) +- [Project Structure](/docs/reference/project-structure) +- [Quick Start](/docs/getting-started/quick-start) diff --git a/docs/docs/reference/project-structure.md b/docs/docs/reference/project-structure.md index ee2d586..7fd4c1b 100644 --- a/docs/docs/reference/project-structure.md +++ b/docs/docs/reference/project-structure.md @@ -38,11 +38,13 @@ my-project/ │ │ ├── schema_app.sql │ │ ├── session/ # Session migrations (temporary) │ │ └── migrations/ # Committed migrations (for deploy) +│ │ └── 00000000000001_create_storage_migrations_table.sql # scaffolded by init │ └── auth/ # Auth module runtime files │ ├── raw/ # Raw exports from source Keycloak │ │ └── {realm}.json -│ └── realm/ # Cleaned configs for import -│ └── {realm}.json +│ ├── realm/ # Cleaned configs for import +│ │ └── {realm}.json +│ └── providers/ # Keycloak provider JARs (gitignored, rebuilt by init) ├── postkit.config.json # Project configuration ├── .env # Environment variables (gitignored) └── package.json @@ -85,25 +87,26 @@ Each schema has its own subdirectory under `db/schema//`: ## PostKit Runtime Directory -The `.postkit/` directory contains runtime files that are **not** tracked by git: +`.postkit/` is split between gitignored (ephemeral) and committed (shared with the team) files: ### Database Module (`db/`) -| File/Directory | Description | -|----------------|-------------| -| `session.json` | Current session state | -| `committed.json` | Committed migrations tracking | -| `plan_.sql` | Generated migration plan per schema (e.g. `plan_public.sql`, `plan_app.sql`) | -| `schema_.sql` | Generated schema artifact per schema (e.g. `schema_public.sql`) | -| `session/` | Session migrations (temporary, cleared on commit) | -| `migrations/` | Committed migrations (for deployment) | +| File/Directory | Tracked? | Description | +|----------------|----------|-------------| +| `session.json` | Gitignored | Current session state | +| `committed.json` | Committed | Committed migrations tracking | +| `plan_.sql` | Gitignored | Generated migration plan per schema (e.g. `plan_public.sql`, `plan_app.sql`) | +| `schema_.sql` | Gitignored | Generated schema artifact per schema (e.g. `schema_public.sql`) | +| `session/` | Gitignored | Session migrations (temporary, cleared on commit) | +| `migrations/` | Committed | Committed migrations (for deployment), including the `storage.migrations` bootstrap migration scaffolded by `init` | ### Auth Module (`auth/`) -| File/Directory | Description | -|----------------|-------------| -| `raw/{realm}.json` | Raw export from source Keycloak (includes IDs, secrets) | -| `realm/{realm}.json` | Cleaned config ready for import (IDs, secrets stripped) | +| File/Directory | Tracked? | Description | +|----------------|----------|-------------| +| `raw/{realm}.json` | Committed | Raw export from source Keycloak (includes IDs, secrets) | +| `realm/{realm}.json` | Committed | Cleaned config ready for import (IDs, secrets stripped) | +| `providers/` | Gitignored | Keycloak provider JARs (bundled + project), rebuilt by `init` | ## Config File @@ -121,4 +124,4 @@ The `.postkit/` directory contains runtime files that are **not** tracked by git Credentials and remote configurations belong in `postkit.secrets.json` (gitignored). See [Configuration](/docs/getting-started/configuration) for the full reference. -Run `postkit init` to create the `.postkit/` directory structure. +Run `postkit init` to create the `.postkit/` directory structure, or scope it to one module with `postkit init db`, `postkit init auth`, or `postkit init stack` — see the [Init Command reference](/docs/reference/init). diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 98c325c..03ba37a 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -144,6 +144,7 @@ const sidebars: SidebarsConfig = { collapsible: true, collapsed: true, items: [ + 'reference/init', 'reference/global-options', 'reference/project-structure', 'reference/session-state', From 9c4b199330a2758f6c9cfa0cd2a168e7b9ecc1d7 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Thu, 23 Jul 2026 22:10:38 +0530 Subject: [PATCH 2/3] fix: update version to 1.3.2 in package.json --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index cc60552..11492c6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@appritech/postkit", - "version": "1.3.1", + "version": "1.3.2", "description": "PostKit - Developer toolkit for database management and more", "type": "module", "main": "dist/index.js", From 88da07c4883ed7b4d249ee1b17604124169d50f4 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 24 Jul 2026 21:16:24 +0530 Subject: [PATCH 3/3] feat: implement non-blocking bootstrap migrations for db start command --- CLAUDE.md | 1 + cli/docs/db.md | 2 + cli/src/modules/db/commands/start.ts | 4 +- cli/src/modules/db/services/scaffold.ts | 1 + cli/src/modules/db/types/session.ts | 8 ++++ cli/src/modules/db/utils/committed.ts | 13 ++++++ cli/test/modules/db/services/scaffold.test.ts | 2 + cli/test/modules/db/utils/committed.test.ts | 46 +++++++++++++++++++ docs/docs/reference/init.md | 2 +- 9 files changed, 76 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f19bab..ec7b40f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,7 @@ PostKit files are split between committed (shared with team) and gitignored (use - `resolveApplyTarget(target?)` (`utils/apply-target.ts`) - Resolves `"local"` or `"remote"` apply target; used by infra and seed commands - `readJsonFile(path)` / `writeJsonFile(path, data)` (`utils/json-file.ts`) - Typed JSON helpers used by remotes and committed migration tracking - `scaffoldDbInfra()` (`services/scaffold.ts`) - Scaffolds `db/infra/001_roles.sql`+`002_schemas.sql`; runs on both full `postkit init` and `init db`. `scaffoldStorageMigration()` scaffolds the `storage.migrations` bootstrap migration; full `postkit init` only. Both are idempotent (safe to call multiple times, never overwrite) +- `getBlockingPendingCommittedMigrations()` (`utils/committed.ts`) - Like `getPendingCommittedMigrations()` but excludes entries with `blocksSessionStart: false` (e.g. the storage.migrations bootstrap). Used by `db start`'s pending-migrations guard so a brand-new project's first session isn't blocked by a migration nobody has deployed yet; `db deploy`/`db status` still use the unfiltered lookup so it's deployed and listed normally ### Stack Module Architecture diff --git a/cli/docs/db.md b/cli/docs/db.md index bdd7720..cd77a7e 100644 --- a/cli/docs/db.md +++ b/cli/docs/db.md @@ -231,6 +231,8 @@ PostKit files in `.postkit/db/` are split between gitignored (ephemeral) and com Full `postkit init` (not the scoped `init db`) always scaffolds `00000000000001_create_storage_migrations_table.sql` — a committed migration creating a `storage.migrations` tracking table for a self-hosted storage service (e.g. Supabase storage-api) run against the `storage` schema. If your project doesn't run one, delete the file and its entry in `committed.json`. +This migration is registered with `blocksSessionStart: false`, so it does **not** count toward `db start`'s "pending committed migrations" guard — a brand-new project can run `db start` immediately without deploying it first. `db deploy` and `db status` are unaffected and still treat it as a normal pending migration to deploy/list. + --- ## 🚀 Commands diff --git a/cli/src/modules/db/commands/start.ts b/cli/src/modules/db/commands/start.ts index 406d907..bd2ba29 100644 --- a/cli/src/modules/db/commands/start.ts +++ b/cli/src/modules/db/commands/start.ts @@ -17,7 +17,7 @@ import {runDbmateStatus} from "../services/dbmate"; import {checkDbPrerequisites} from "../services/prerequisites"; import {resolveLocalDb, cloneDatabaseViaContainer, stopSessionContainer, onContainerInterrupt} from "../services/container"; import {applyInfraStep} from "../services/infra-generator"; -import {getPendingCommittedMigrations} from "../utils/committed"; +import {getBlockingPendingCommittedMigrations} from "../utils/committed"; import type {CommandOptions} from "../../../common/types"; import {PostkitError} from "../../../common/errors"; @@ -122,7 +122,7 @@ export async function startCommand(options: StartOptions): Promise { logger.step(4, totalSteps, "Verifying database state..."); // Check 1: Pending committed migrations (check remote's schema_migrations table) - const pendingCommitted = await getPendingCommittedMigrations(targetRemoteUrl); + const pendingCommitted = await getBlockingPendingCommittedMigrations(targetRemoteUrl); if (pendingCommitted.length > 0) { logger.blank(); diff --git a/cli/src/modules/db/services/scaffold.ts b/cli/src/modules/db/services/scaffold.ts index f2d3a58..d1e7f0a 100644 --- a/cli/src/modules/db/services/scaffold.ts +++ b/cli/src/modules/db/services/scaffold.ts @@ -130,6 +130,7 @@ export async function scaffoldStorageMigration(): Promise { description: STORAGE_MIGRATION_DESCRIPTION, sessionMigrations: [], committedAt: new Date().toISOString(), + blocksSessionStart: false, }); } diff --git a/cli/src/modules/db/types/session.ts b/cli/src/modules/db/types/session.ts index 6d3b21f..fb89513 100644 --- a/cli/src/modules/db/types/session.ts +++ b/cli/src/modules/db/types/session.ts @@ -27,6 +27,14 @@ export interface CommittedMigration { description: string; sessionMigrations: {name: string; path: string}[]; committedAt: string; + /** + * When false, this migration is excluded from the "pending migrations" check + * that blocks `db start`. Used for bootstrap migrations scaffolded by `init` + * (e.g. storage.migrations) that haven't gone through the session workflow + * and shouldn't stop a brand-new project's first session. Defaults to true + * (blocking) when omitted, so existing user-committed migrations are unaffected. + */ + blocksSessionStart?: boolean; } export interface CommittedState { diff --git a/cli/src/modules/db/utils/committed.ts b/cli/src/modules/db/utils/committed.ts index 962094f..c93c939 100644 --- a/cli/src/modules/db/utils/committed.ts +++ b/cli/src/modules/db/utils/committed.ts @@ -92,3 +92,16 @@ export async function getPendingCommittedMigrations(remoteUrl: string): Promise< return state.migrations.filter(m => !appliedVersions.has(m.migrationFile.timestamp)); } + +/** + * Gets pending committed migrations that should block starting a new session, + * i.e. excludes migrations explicitly marked `blocksSessionStart: false` + * (e.g. the storage.migrations bootstrap scaffolded by `init`, which hasn't + * gone through the session workflow and shouldn't stop a brand-new project's + * first `db start`). `db deploy`/`db status` still use the unfiltered + * `getPendingCommittedMigrations()` so the migration is deployed/listed normally. + */ +export async function getBlockingPendingCommittedMigrations(remoteUrl: string): Promise { + const pending = await getPendingCommittedMigrations(remoteUrl); + return pending.filter(m => m.blocksSessionStart !== false); +} diff --git a/cli/test/modules/db/services/scaffold.test.ts b/cli/test/modules/db/services/scaffold.test.ts index 3289a15..55fbf09 100644 --- a/cli/test/modules/db/services/scaffold.test.ts +++ b/cli/test/modules/db/services/scaffold.test.ts @@ -153,6 +153,8 @@ describe("scaffoldStorageMigration()", () => { expect(registered.description).toBe("create storage.migrations table"); expect(registered.sessionMigrations).toEqual([]); expect(typeof registered.committedAt).toBe("string"); + // Must not block `db start` on a brand-new project that hasn't deployed anything yet + expect(registered.blocksSessionStart).toBe(false); }); it("does not re-register when already tracked in committed.json, even though the file was missing", async () => { diff --git a/cli/test/modules/db/utils/committed.test.ts b/cli/test/modules/db/utils/committed.test.ts index 73dd372..af6cda9 100644 --- a/cli/test/modules/db/utils/committed.test.ts +++ b/cli/test/modules/db/utils/committed.test.ts @@ -42,6 +42,7 @@ import { addCommittedMigration, getAllCommittedMigrations, getPendingCommittedMigrations, + getBlockingPendingCommittedMigrations, } from "../../../../src/modules/db/utils/committed"; const sampleMigration = { @@ -173,4 +174,49 @@ describe("committed", () => { expect(pending).toHaveLength(1); }); }); + + describe("getBlockingPendingCommittedMigrations()", () => { + it("excludes migrations marked blocksSessionStart: false", async () => { + const bootstrapMigration = { + ...sampleMigration, + migrationFile: {name: "00000000000001_bootstrap.sql", path: "/migrations/00000000000001_bootstrap.sql", timestamp: "00000000000001"}, + blocksSessionStart: false, + }; + const userMigration = { + ...sampleMigration, + migrationFile: {name: "20240116_user.sql", path: "/migrations/20240116_user.sql", timestamp: "20240116"}, + }; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({migrations: [bootstrapMigration, userMigration]})); + + // Neither has been applied on remote + mockQuery.mockResolvedValueOnce({rows: []}); + + const blocking = await getBlockingPendingCommittedMigrations(remoteUrl); + expect(blocking).toHaveLength(1); + expect(blocking[0]!.migrationFile.name).toBe("20240116_user.sql"); + }); + + it("treats a missing blocksSessionStart field as blocking (backward compatible)", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({migrations: [sampleMigration]})); + mockQuery.mockResolvedValueOnce({rows: []}); + + const blocking = await getBlockingPendingCommittedMigrations(remoteUrl); + expect(blocking).toHaveLength(1); + }); + + it("returns empty when the only pending migration is non-blocking", async () => { + const bootstrapMigration = { + ...sampleMigration, + blocksSessionStart: false, + }; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({migrations: [bootstrapMigration]})); + mockQuery.mockResolvedValueOnce({rows: []}); + + const blocking = await getBlockingPendingCommittedMigrations(remoteUrl); + expect(blocking).toHaveLength(0); + }); + }); }); diff --git a/docs/docs/reference/init.md b/docs/docs/reference/init.md index d853aa4..6bf9e50 100644 --- a/docs/docs/reference/init.md +++ b/docs/docs/reference/init.md @@ -53,7 +53,7 @@ postkit init -f - `postkit.secrets.json` + `postkit.secrets.example.json` — gitignored secrets file and a template for teammates - `.postkit/db/` — `session/`, `migrations/`, `committed.json` - `db/infra/001_roles.sql`, `db/infra/002_schemas.sql` — PostgREST roles and the `public`/`auth`/`storage` schemas -- `.postkit/db/migrations/00000000000001_create_storage_migrations_table.sql` — a bootstrap migration for a self-hosted storage service (e.g. Supabase storage-api). Delete it (and its entry in `committed.json`) if you don't run one +- `.postkit/db/migrations/00000000000001_create_storage_migrations_table.sql` — a bootstrap migration for a self-hosted storage service (e.g. Supabase storage-api). Delete it (and its entry in `committed.json`) if you don't run one. It never blocks `db start` on a brand-new project — `db deploy`/`db status` still treat it as a normal migration to deploy - `.postkit/auth/` — `raw/`, `realm/`, `providers/`. Keycloak provider JARs are synced and the realm template is scaffolded - `.postkit/stack/` - `.gitignore` entries covering all of the above