diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index d0c60f2..2f2087c 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -1,6 +1,8 @@ --- name: architecture description: Review and propose system architecture decisions, generate ADRs. +metadata: + internal: true --- # Architecture Skill diff --git a/.claude/skills/bugfix/SKILL.md b/.claude/skills/bugfix/SKILL.md index 4a787b7..7580346 100644 --- a/.claude/skills/bugfix/SKILL.md +++ b/.claude/skills/bugfix/SKILL.md @@ -1,6 +1,8 @@ --- name: bugfix -description: Diagnose and fix bugs with a 4-stage workflow: gather info, diagnose, fix, test, validate & review. +description: "Diagnose and fix bugs with a 4-stage workflow: gather info, diagnose, fix, test, validate & review." +metadata: + internal: true --- # Bugfix Skill diff --git a/.claude/skills/create-feature/SKILL.md b/.claude/skills/create-feature/SKILL.md index 3e6cea5..3fb793d 100644 --- a/.claude/skills/create-feature/SKILL.md +++ b/.claude/skills/create-feature/SKILL.md @@ -1,6 +1,8 @@ --- name: create-feature -description: Implement new features end-to-end: gather info, plan, implement, test, validate, review. +description: "Implement new features end-to-end: gather info, plan, implement, test, validate, review." +metadata: + internal: true --- # Create Feature Skill diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md index 70a34ac..50f4649 100644 --- a/.claude/skills/create-pr/SKILL.md +++ b/.claude/skills/create-pr/SKILL.md @@ -3,6 +3,8 @@ name: create-pr description: Generate a PR description for the current branch and save it to temp/pr-description.md. Analyzes commits against the base branch and fills in the project PR template exactly. argument-hint: "[base-branch]" allowed-tools: Bash, Read, Write +metadata: + internal: true --- # Create PR Skill diff --git a/.claude/skills/update-docs/SKILL.md b/.claude/skills/update-docs/SKILL.md index 052bca4..17a5fb6 100644 --- a/.claude/skills/update-docs/SKILL.md +++ b/.claude/skills/update-docs/SKILL.md @@ -1,6 +1,8 @@ --- name: update-docs description: Update project documentation when code changes occur. +metadata: + internal: true --- # Update Docs Skill diff --git a/.claude/skills/write-test-e2e/SKILL.md b/.claude/skills/write-test-e2e/SKILL.md index 8bb1be0..832033d 100644 --- a/.claude/skills/write-test-e2e/SKILL.md +++ b/.claude/skills/write-test-e2e/SKILL.md @@ -1,6 +1,8 @@ --- name: write-test-e2e description: Write end-to-end tests for PostKit CLI using testcontainers and black-box testing. +metadata: + internal: true --- # Write E2E Tests Skill diff --git a/.claude/skills/write-test-unit/SKILL.md b/.claude/skills/write-test-unit/SKILL.md index 4f65ebf..6290d64 100644 --- a/.claude/skills/write-test-unit/SKILL.md +++ b/.claude/skills/write-test-unit/SKILL.md @@ -1,6 +1,8 @@ --- name: write-test-unit description: Write unit tests for PostKit CLI using Vitest with proper mocking patterns. +metadata: + internal: true --- # Write Unit Tests Skill diff --git a/CLAUDE.md b/CLAUDE.md index 8d01b8b..aa22876 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,8 @@ 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) +- `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 @@ -197,10 +199,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 +279,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 | @@ -370,10 +384,10 @@ PostKit ships with Claude Code agent skills that teach AI assistants how to work ### Skill Anatomy -Each skill lives in its own directory under `agent/skills/`: +Each skill lives in its own directory under `skills/`: ``` -agent/skills/ +skills/ ├── postkit-migrate/ │ └── SKILL.md # Frontmatter (name, description, allowed-tools) + markdown instructions ├── postkit-setup/ @@ -409,19 +423,24 @@ Use the [skills CLI](https://github.com/vercel-labs/skills) to install PostKit s ```bash # Install all PostKit skills (interactive) -npx skills add appritechnologies/Postkit +npx skills add postkitstack/Postkit # List available skills first -npx skills add appritechnologies/Postkit --list +npx skills add postkitstack/Postkit --list # Install specific skills only -npx skills add appritechnologies/Postkit --skill postkit-migrate --skill postkit-schema +npx skills add postkitstack/Postkit --skill postkit-migrate --skill postkit-schema # Install for a specific agent (e.g., Claude Code) -npx skills add appritechnologies/Postkit -a claude-code - -# Non-interactive (CI/CD friendly) -npx skills add appritechnologies/Postkit --all -y +npx skills add postkitstack/Postkit -a claude-code + +# Non-interactive (CI/CD friendly) — name each skill explicitly. +# Avoid `--all`: it expands to every skill *and* every agent, and it +# bypasses the internal-skill filter, so it also pulls PostKit's own +# repo-maintenance skills into your project. +npx skills add postkitstack/Postkit -y --agent claude-code \ + --skill postkit-migrate --skill postkit-setup \ + --skill postkit-schema --skill postkit-auth ``` The CLI auto-detects which coding agents you have installed and places skills in the correct directory for each agent. By default, skills are symlinked (single source of truth, easy to update). Use `--copy` for independent copies. @@ -440,7 +459,7 @@ npx skills update postkit-auth # Update a specific skill ### Adding a New Skill -Create `agent/skills//SKILL.md`: +For a **public** skill (one PostKit users install), create `skills//SKILL.md`. For internal contributor tooling, use `.claude/skills/` instead and read [Public vs Internal Skills](#public-vs-internal-skills) first. ```yaml --- @@ -453,7 +472,7 @@ allowed-tools: Bash(postkit *) Skills can optionally include bundled resources for more complex workflows: ``` -agent/skills// +skills// ├── SKILL.md # Required — skill instructions ├── scripts/ # Optional — executable scripts for repetitive tasks ├── references/ # Optional — reference docs loaded into context as needed @@ -462,6 +481,28 @@ agent/skills// When a skill grows beyond ~500 lines, split domain-specific content into `references/` files and point to them from SKILL.md. +### Public vs Internal Skills + +Two kinds of skills live in this repo, and the split is load-bearing for packaging: + +| Location | Kind | Shipped by `npx skills add` | +|----------|------|-----------------------------| +| `skills/` | Public — for people *using* PostKit | Yes | +| `.claude/skills/` | Internal — for people *developing* PostKit | No | + +`skills/` is a directory the skills CLI searches by default; `agent/skills/` is **not**, which is why public skills live at `skills/`. + +`.claude/skills/` is also a directory the CLI searches, so every internal skill must carry: + +```yaml +metadata: + internal: true +``` + +Without it, contributor tooling (`/bugfix`, `/create-pr`, …) gets installed into end users' projects. The flag hides the skill from discovery, `--list`, and interactive install; Claude Code ignores the field and loads the skill locally as normal. Contributors can still fetch internal skills with `INSTALL_INTERNAL_SKILLS=1` or an explicit `--skill `. + +Descriptions containing `: ` (colon-space) **must be quoted** — unquoted, YAML parses them as a nested mapping and the skills CLI skips the file with a parse error. + ## Important Notes - All paths in `common/config.ts` are resolved relative to either `cliRoot` (the CLI installation) or `projectRoot` (where the user runs commands). @@ -486,6 +527,8 @@ Skills are invoked via `/` in Claude Code. Agents are sub-processes ### Skills Registry +These are **internal** skills — they live in `.claude/skills/` and must each carry `metadata: internal: true` so they are not shipped to end users. See [Public vs Internal Skills](#public-vs-internal-skills). + | Skill | Invocation | Purpose | Sub-Agents | |-------|-----------|---------|------------| | create-pr | `/create-pr` | Generate PR description to `temp/pr-description.md` | — | diff --git a/README.md b/README.md index 0f7d4e6..5c646f2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ PostKit brings together the following components: ## PostKit CLI -The PostKit CLI (`@appritech/postkit`) is the developer toolkit for working with the PostKit stack. It provides safe database migrations, auth management, and project scaffolding. +The PostKit CLI (`@postkitstack/postkit`) is the developer toolkit for working with the PostKit stack. It provides safe database migrations, auth management, and project scaffolding. **Documentation:** [https://docs.postkitstack.com/](https://docs.postkitstack.com/) @@ -30,7 +30,7 @@ The PostKit CLI (`@appritech/postkit`) is the developer toolkit for working with ```bash # Install the CLI -npm install -g @appritech/postkit +npm install -g @postkitstack/postkit # Initialize a new project postkit init @@ -81,15 +81,15 @@ No restart needed — Claude Code picks up changes automatically. Full documentation is available at [docs.postkitstack.com](https://docs.postkitstack.com/). -For more help, see [Troubleshooting](https://docs.postkitstack.com/docs/modules/db/troubleshooting) or open an issue on [GitHub](https://github.com/appritechnologies/postkit/issues). +For more help, see [Troubleshooting](https://docs.postkitstack.com/docs/modules/db/troubleshooting) or open an issue on [GitHub](https://github.com/postkitstack/Postkit/issues). ## Links - **CLI Tool**: [cli/README.md](cli/README.md) -- **npm Package**: https://www.npmjs.com/package/@appritech/postkit +- **npm Package**: https://www.npmjs.com/package/@postkitstack/postkit - **Documentation**: https://docs.postkitstack.com/ -- **GitHub**: https://github.com/appritechnologies/postkit -- **Issues**: https://github.com/appritechnologies/postkit/issues +- **GitHub**: https://github.com/postkitstack/Postkit +- **Issues**: https://github.com/postkitstack/Postkit/issues ## License diff --git a/cli/README.md b/cli/README.md index 1f5cc8a..c6e7700 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,11 +1,11 @@ -# @appritech/postkit +# @postkitstack/postkit > Developer toolkit for database migrations and backend automation **Note:** This tool is still under development and not recommended for production use. APIs may change between versions. -[![npm version](https://badge.fury.io/js/@appritech/postkit.svg)](https://www.npmjs.com/package/@appritech/postkit) -[![License](https://img.shields.io/npm/l/@appritech/postkit.svg)](LICENSE) +[![npm version](https://badge.fury.io/js/%40postkitstack%2Fpostkit.svg)](https://www.npmjs.com/package/@postkitstack/postkit) +[![License](https://img.shields.io/npm/l/@postkitstack/postkit.svg)](LICENSE) PostKit CLI is a modular toolkit for backend development with the Appri stack. It provides safe database migrations, auth management, and more. @@ -16,7 +16,7 @@ PostKit CLI is a modular toolkit for backend development with the Appri stack. I ### Installation ```bash -npm install -g @appritech/postkit +npm install -g @postkitstack/postkit ``` ### Requirements @@ -186,14 +186,14 @@ postkit db commit postkit db deploy --remote staging ``` -For more help, see [Troubleshooting](https://docs.postkitstack.com/docs/modules/db/troubleshooting) or open an issue on [GitHub](https://github.com/appritechnologies/postkit/issues). +For more help, see [Troubleshooting](https://docs.postkitstack.com/docs/modules/db/troubleshooting) or open an issue on [GitHub](https://github.com/postkitstack/Postkit/issues). ## 🔗 Links -- **npm Package**: https://www.npmjs.com/package/@appritech/postkit +- **npm Package**: https://www.npmjs.com/package/@postkitstack/postkit - **Documentation**: https://docs.postkitstack.com/ -- **GitHub**: https://github.com/appritechnologies/postkit -- **Issues**: https://github.com/appritechnologies/postkit/issues +- **GitHub**: https://github.com/postkitstack/Postkit +- **Issues**: https://github.com/postkitstack/Postkit/issues ## 📜 License 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..cd77a7e 100644 --- a/cli/docs/db.md +++ b/cli/docs/db.md @@ -222,12 +222,17 @@ 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`. + +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/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..e113d65 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@appritech/postkit", - "version": "1.3.0", + "name": "@postkitstack/postkit", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@appritech/postkit", - "version": "1.3.0", + "name": "@postkitstack/postkit", + "version": "1.3.2", "license": "Apache-2.0", "dependencies": { "chalk": "^5.3.0", diff --git a/cli/package.json b/cli/package.json index cc60552..3079cb5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { - "name": "@appritech/postkit", - "version": "1.3.1", + "name": "@postkitstack/postkit", + "version": "1.3.2", "description": "PostKit - Developer toolkit for database management and more", "type": "module", "main": "dist/index.js", @@ -46,11 +46,11 @@ "license": "Apache-2.0", "repository": { "type": "git", - "url": "https://github.com/appritechnologies/postkit.git" + "url": "https://github.com/postkitstack/Postkit.git" }, - "homepage": "https://github.com/appritechnologies/postkit#readme", + "homepage": "https://github.com/postkitstack/Postkit#readme", "bugs": { - "url": "https://github.com/appritechnologies/postkit/issues" + "url": "https://github.com/postkitstack/Postkit/issues" }, "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/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 29e39a4..d1e7f0a 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,62 @@ 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(), + blocksSessionStart: false, + }); + } + + return true; +} 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/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..55fbf09 --- /dev/null +++ b/cli/test/modules/db/services/scaffold.test.ts @@ -0,0 +1,181 @@ +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"); + // 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 () => { + 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/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/blog/2026-05-04-migrating-from-prisma.md b/docs/blog/2026-05-04-migrating-from-prisma.md index 9465633..b84ec8b 100644 --- a/docs/blog/2026-05-04-migrating-from-prisma.md +++ b/docs/blog/2026-05-04-migrating-from-prisma.md @@ -35,7 +35,7 @@ The transition has two phases: getting your current schema into PostKit, then sw PostKit's `db import` command reads your current PostgreSQL database (not your `schema.prisma`) and generates schema files: ```bash -npm install -g @appritech/postkit +npm install -g @postkitstack/postkit postkit init # Add your database as a remote diff --git a/docs/blog/2026-05-10-why-session-based-migrations.md b/docs/blog/2026-05-10-why-session-based-migrations.md index 12200ea..93261c9 100644 --- a/docs/blog/2026-05-10-why-session-based-migrations.md +++ b/docs/blog/2026-05-10-why-session-based-migrations.md @@ -113,7 +113,7 @@ For databases where a full clone is impractical (100GB+), you can use a represen ## Getting Started ```bash -npm install -g @appritech/postkit +npm install -g @postkitstack/postkit postkit init postkit db remote add prod "postgres://..." --default postkit db start # clone happens here diff --git a/docs/blog/2026-05-12-migrating-from-supabase-to-postkit.md b/docs/blog/2026-05-12-migrating-from-supabase-to-postkit.md index c3b7659..e3d1154 100644 --- a/docs/blog/2026-05-12-migrating-from-supabase-to-postkit.md +++ b/docs/blog/2026-05-12-migrating-from-supabase-to-postkit.md @@ -31,7 +31,7 @@ PostKit does not replace Supabase's auth, storage, or realtime features. If you ## Step 1: Install PostKit ```bash -npm install -g @appritech/postkit +npm install -g @postkitstack/postkit ``` Verify: @@ -205,7 +205,7 @@ If you have existing Supabase migration files (`.sql` files in `supabase/migrati | Step | Command | |------|---------| -| Install PostKit | `npm install -g @appritech/postkit` | +| Install PostKit | `npm install -g @postkitstack/postkit` | | Init project | `postkit init` | | Add Supabase remote | `postkit db remote add supabase "postgres://..."` | | Import existing schema | `postkit db import --url "postgres://..."` | diff --git a/docs/blog/authors.yml b/docs/blog/authors.yml index f7ec0e2..0a44554 100644 --- a/docs/blog/authors.yml +++ b/docs/blog/authors.yml @@ -1,5 +1,5 @@ postkit_team: name: PostKit Team title: Appri Technologies - url: https://github.com/appritechnologies - image_url: https://github.com/appritechnologies.png + url: https://github.com/postkitstack + image_url: https://github.com/postkitstack.png diff --git a/docs/docs/agent-skills/overview.md b/docs/docs/agent-skills/overview.md index 500e6b6..4d945ad 100644 --- a/docs/docs/agent-skills/overview.md +++ b/docs/docs/agent-skills/overview.md @@ -21,30 +21,45 @@ Use the [skills CLI](https://github.com/vercel-labs/skills) to install PostKit s ```bash # Install all PostKit skills (interactive) -npx skills add appritechnologies/Postkit +npx skills add postkitstack/Postkit # List available skills first -npx skills add appritechnologies/Postkit --list +npx skills add postkitstack/Postkit --list # Install specific skills only -npx skills add appritechnologies/Postkit --skill postkit-migrate --skill postkit-schema +npx skills add postkitstack/Postkit --skill postkit-migrate --skill postkit-schema # Install for a specific agent (e.g., Claude Code) -npx skills add appritechnologies/Postkit -a claude-code - -# Non-interactive (CI/CD friendly) -npx skills add appritechnologies/Postkit --all -y +npx skills add postkitstack/Postkit -a claude-code + +# Non-interactive (CI/CD friendly) — name each skill explicitly. +# Avoid `--all`: it expands to every skill *and* every agent, and it +# bypasses the internal-skill filter, so it also pulls PostKit's own +# repo-maintenance skills into your project. +npx skills add postkitstack/Postkit -y --agent claude-code \ + --skill postkit-migrate --skill postkit-setup \ + --skill postkit-schema --skill postkit-auth ``` ### Scope -| Scope | Flag | Location | Use Case | -|-------|------|----------|----------| -| **Project** (default) | | `.//skills/` | Committed with your project, shared with team | -| **Global** | `-g` | `~//skills/` | Available across all your projects | +| Scope | Flag | Location (Claude Code) | Use Case | +|-------|------|------------------------|----------| +| **Project** (default) | | `./.claude/skills/` | Committed with your project, shared with team | +| **Global** | `-g` | `~/.claude/skills/` | Available across all your projects | + +The exact directory depends on the agent. Claude Code uses `.claude/skills/`; Cursor, Codex, Cline, Gemini CLI, and GitHub Copilot share the standard `.agents/skills/`; Windsurf and Roo Code use `.windsurf/skills/` and `.roo/skills/`. The CLI picks the right one for each agent you install to. By default, skills are **symlinked** — a single source of truth that's easy to update. Use `--copy` for independent copies when symlinks aren't supported. +### Verify the Install + +```bash +npx skills list +``` + +You should see the four `postkit-*` skills. In Claude Code, `/postkit-migrate` and friends become available in new sessions. + ### Update Skills ```bash 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/installation.md b/docs/docs/getting-started/installation.md index bdb9dfb..07271e6 100644 --- a/docs/docs/getting-started/installation.md +++ b/docs/docs/getting-started/installation.md @@ -41,15 +41,15 @@ npm --version ### From npm ```bash -npm install -g @appritech/postkit +npm install -g @postkitstack/postkit ``` ### From source ```bash # Clone the repository -git clone https://github.com/appritechnologies/postkit.git -cd postkit/cli +git clone https://github.com/postkitstack/Postkit.git +cd Postkit/cli # Install dependencies npm install 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/intro.mdx b/docs/docs/intro.mdx index eae4bb8..1335a59 100644 --- a/docs/docs/intro.mdx +++ b/docs/docs/intro.mdx @@ -46,7 +46,7 @@ PostKit is built for the Appri stack: ```bash # Install CLI -npm install -g @appritech/postkit +npm install -g @postkitstack/postkit # Initialize a new project postkit init diff --git a/docs/docs/modules/db/troubleshooting.md b/docs/docs/modules/db/troubleshooting.md index 89c2112..ee8e2c1 100644 --- a/docs/docs/modules/db/troubleshooting.md +++ b/docs/docs/modules/db/troubleshooting.md @@ -102,4 +102,4 @@ The container ID is stored in `.postkit/db/session.json` under `containerID` if ## Getting Help -If you're still stuck, please open an issue on [GitHub](https://github.com/appritechnologies/postkit/issues). +If you're still stuck, please open an issue on [GitHub](https://github.com/postkitstack/Postkit/issues). diff --git a/docs/docs/reference/init.md b/docs/docs/reference/init.md new file mode 100644 index 0000000..6bf9e50 --- /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. 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 + +### `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/docusaurus.config.ts b/docs/docusaurus.config.ts index 1ebab87..8f2e4a6 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -22,8 +22,8 @@ const config: Config = { // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. - organizationName: 'appritechnologies', // Usually your GitHub org/user name. - projectName: 'postkit', // Usually your repo name. + organizationName: 'postkitstack', // Usually your GitHub org/user name. + projectName: 'Postkit', // Usually your repo name. onBrokenLinks: 'throw', @@ -57,7 +57,7 @@ const config: Config = { // Please change this to your repo. // Remove this to remove the "edit this page" links. editUrl: - 'https://github.com/appritechnologies/postkit/edit/main/docs/', + 'https://github.com/postkitstack/Postkit/edit/main/docs/', }, blog: { showReadingTime: true, @@ -68,7 +68,7 @@ const config: Config = { // Please change this to your repo. // Remove this to remove the "edit this page" links. editUrl: - 'https://github.com/appritechnologies/postkit/edit/main/docs/', + 'https://github.com/postkitstack/Postkit/edit/main/docs/', // Useful options to enforce blogging best practices onInlineTags: 'warn', onInlineAuthors: 'warn', @@ -106,7 +106,7 @@ const config: Config = { position: 'left', }, { - href: 'https://github.com/appritechnologies/postkit', + href: 'https://github.com/postkitstack/Postkit', label: 'GitHub', position: 'right', }, @@ -137,7 +137,7 @@ const config: Config = { items: [ { label: 'GitHub', - href: 'https://github.com/appritechnologies/postkit', + href: 'https://github.com/postkitstack/Postkit', }, { label: 'Documentation', 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', diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 9d54ae0..d0a011c 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -35,7 +35,7 @@ function HomepageHero() { GitHub diff --git a/agent/skills/postkit-auth/SKILL.md b/skills/postkit-auth/SKILL.md similarity index 100% rename from agent/skills/postkit-auth/SKILL.md rename to skills/postkit-auth/SKILL.md diff --git a/agent/skills/postkit-migrate/SKILL.md b/skills/postkit-migrate/SKILL.md similarity index 100% rename from agent/skills/postkit-migrate/SKILL.md rename to skills/postkit-migrate/SKILL.md diff --git a/agent/skills/postkit-schema/SKILL.md b/skills/postkit-schema/SKILL.md similarity index 100% rename from agent/skills/postkit-schema/SKILL.md rename to skills/postkit-schema/SKILL.md diff --git a/agent/skills/postkit-setup/SKILL.md b/skills/postkit-setup/SKILL.md similarity index 75% rename from agent/skills/postkit-setup/SKILL.md rename to skills/postkit-setup/SKILL.md index 9a10db4..bcda8ef 100644 --- a/agent/skills/postkit-setup/SKILL.md +++ b/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: