feat(cli): add pbkit schema subcommand for schema management - #60
Conversation
Adds list/get/pull/apply/add-field/add-index/set-rule/create-view commands operating over PocketBase's /api/collections admin API with superuser auth. Credentials resolve from env (POCKETBASE_URL/ADMIN_EMAIL/ADMIN_PASSWORD/ ADMIN_TOKEN) with config fallback, never inline. pull matches the generator's snapshot shape, apply is non-destructive by default, and partial ops fetch-and-patch to avoid clobbering unrelated fields/indexes/rules. Closes #59 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a CLI reference section, a "Manage schema from the CLI" how-to guide, and a schema-management section in the pbkit skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Two functional issues found in the new schema CLI:
-cshort flag is silently ignored —splitArgsonly handles--prefixed flags, so-c ./config.tsis treated as two positional args. The help text advertises-cas a working alias.- Incomplete email/password pair silently falls through — setting
POCKETBASE_ADMIN_EMAILwithoutPOCKETBASE_ADMIN_PASSWORD(or vice versa) skips email/password auth without warning, leading to a misleading "No superuser credentials found" error.
| const flags = new Map<string, string | true>() | ||
| for (let i = 0; i < args.length; i++) { | ||
| const arg = args[i] | ||
| if (arg.startsWith("--")) { |
There was a problem hiding this comment.
Fixed in faefb37 — splitArgs now recognizes single-dash short flags (-c <path>), and the look-ahead treats a following -x/--flag as a boolean rather than swallowing it as a value. Added tests for short/long/boolean parsing.
| const auth = resolveAuthSettings(config) | ||
|
|
||
| let token = auth.token | ||
| if (auth.email && auth.password) { |
There was a problem hiding this comment.
Fixed in faefb37 — createSchemaClient now throws a specific error when only one of POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD is set, before the generic token fallback. Added a test asserting the partial-pair rejection.
- splitArgs now recognizes single-dash short flags (e.g. `-c <path>`), which were previously dropped into positionals despite being advertised in help. - createSchemaClient throws a specific error when only one of POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD is set, instead of silently falling through to a misleading "No superuser credentials" error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| const flags = new Map<string, string | true>() | ||
| for (let i = 0; i < args.length; i++) { | ||
| const arg = args[i] | ||
| if (arg.startsWith("--")) { |
There was a problem hiding this comment.
Fixed in faefb37 — splitArgs now recognizes single-dash short flags (-c <path>), and the look-ahead treats a following -x/--flag as a boolean rather than swallowing it as a value. Added tests for short/long/boolean parsing.
| const auth = resolveAuthSettings(config) | ||
|
|
||
| let token = auth.token | ||
| if (auth.email && auth.password) { |
There was a problem hiding this comment.
Fixed in faefb37 — createSchemaClient now throws a specific error when only one of POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD is set, before the generic token fallback. Added a test asserting the partial-pair rejection.
There was a problem hiding this comment.
One test reliability issue found in the new schema command tests:
withEnvhelper doesn't support async callbacks — the partial-credential test passes anasyncfunction whose assertions become fire-and-forget, so failures could be silently swallowed as unhandled rejections.
| expect(() => resolveAuthSettings(undefined)).toThrow("No PocketBase URL") | ||
| }) | ||
| }) | ||
|
|
There was a problem hiding this comment.
Problem: withEnv is typed as fn: () => void and calls fn() synchronously without awaiting the result. This test passes an async callback, so the returned promise is fire-and-forget — withEnv's finally block restores env vars immediately, and await withEnv(...) resolves before the assertions run.
Impact: The test currently passes because resolveAuthSettings reads env vars synchronously before the first await, so the values are correct at read time. However, if createSchemaClient were ever refactored to read env vars asynchronously, or if the assertion itself failed, the failure would surface as an unhandled promise rejection rather than a test failure — the test runner might mark the test as passed before the rejection is reported.
Fix: Make withEnv async-aware so the env scope covers the full callback lifetime:
async function withEnv(
env: Record<string, string | undefined>,
fn: () => void | Promise<void>,
) {
const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
for (const k of ENV_KEYS) delete process.env[k]
Object.assign(process.env, env)
try {
await fn()
} finally {
for (const k of ENV_KEYS) delete process.env[k]
for (const [k, v] of Object.entries(saved)) if (v !== undefined) process.env[k] = v
}
}The existing synchronous callers work unchanged since await on a non-promise is a no-op.
| for (const k of ENV_KEYS) delete process.env[k] | ||
| Object.assign(process.env, env) | ||
| try { | ||
| fn() |
There was a problem hiding this comment.
Problem: withEnv calls fn() synchronously without awaiting it. The "rejects a partial email/password pair" test passes an async callback, but withEnv returns void — so await withEnv(...) is a no-op and the inner expect(...).rejects.toThrow(...) assertions are fire-and-forget promises.
Impact: The test currently works because resolveAuthSettings runs synchronously before the first await in createSchemaClient, so env vars are read before the finally block restores them. But if the assertion fails, the failure surfaces as an unhandled rejection rather than a proper test failure. More importantly, if createSchemaClient is ever refactored to do async work before calling resolveAuthSettings, the env vars would be restored before they're read, and the test would fail with a misleading error.
Fix: Make withEnv async and await the callback:
async function withEnv(env: Record<string, string | undefined>, fn: () => Promise<void> | void) {
const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
for (const k of ENV_KEYS) delete process.env[k]
Object.assign(process.env, env)
try {
await fn()
} finally {
for (const k of ENV_KEYS) delete process.env[k]
for (const [k, v] of Object.entries(saved)) if (v !== undefined) process.env[k] = v
}
}
Closes #59
What
Adds a
pbkit schemaCLI subcommand that manages PocketBase collection definitions directly against the admin API (/api/collections), replacing the ad-hoccurl+pythonflow. Schema-only — record CRUD is intentionally out of scope.Auth
Superuser auth via
_superusers/auth-with-password. Credentials resolve from env (preferred) with config fallback, never inline:POCKETBASE_URL,POCKETBASE_ADMIN_EMAIL,POCKETBASE_ADMIN_PASSWORD, orPOCKETBASE_ADMIN_TOKEN.Acceptance criteria
pullproduces the same shape the generator reads (raw/api/collectionsarray →parseJson).applyis idempotent and non-destructive by default (deleteMissing: false); opt-in--delete-missing.add-field,add-index,set-rule) fetch current state and patch — never clobber unrelated fields/indexes/rules.Notes
add-field/add-indexsend the full merged array because PocketBase replaces array fields wholesale on PATCH.set-rule, a value of"null"makes a rule superuser-only and""makes it public.Verification
tsc --noEmitcleanbun test— 138 pass / 0 fail (8 new tests covering merge + auth resolution)🤖 Generated with Claude Code