Codegen
The CLI writes the committed inputs the SDK generators read, and runs exactly one generator itself. palbase link fetches a project's OpenAPI contract for every environment and writes it where each toolchain looks for it — one file per environment, at palbase/environments/<env>/openapi.json, the same path for every platform including web. palbase spec refreshes that contract on its own after a deploy, and on an Apple checkout it also re-emits the committed Swift client into that same environment's directory. Web is generated by palbe-gen, which ships inside @palbase/web and runs from your predev/prebuild scripts; Android is generated by the Gradle plugin on the next build. Separately, palbase build writes palbase/palbase-env.d.ts, which types your own handlers rather than any client. Everything these commands produce is meant to be committed — a fresh clone builds without a login, a network or a re-link.
Quick example
palbase link k3xq81w4m --platform ios
palbase push
palbase spec
# ▸ todoapp/main
# ✓ wrote palbase/environments/main/openapi.json (48213 bytes)
# ✓ wrote /Users/you/todoapp/palbase/environments/main/PalbaseGenerated.swift
# ✓ wrote /Users/you/todoapp/palbase/environments/main/Palbase-Info.plist
palbase push already runs that last step for you on success, only in a checkout with a client platform — the contract has just changed, and that is the only moment a committed client can be brought level with the stack without anybody remembering to. A backend checkout holds no per-environment files, so a push writes none there. Run spec by hand when you want the contract refreshed without a deploy; asked for, it writes the contract in any checkout.
Three generators, and who runs each
Don't confuse them. They produce three different things from three different inputs:
| Generator | Produces | Reads | Who runs it |
|---|---|---|---|
palbe-gen (in @palbase/web) | the web client — pb.todos.* in the browser | palbase/environments/<env>/{openapi.json,web-config.json} | you, via predev/prebuild |
palbase-swiftgen (in the iOS SDK) | the Swift client — palbase/environments/<env>/PalbaseGenerated.swift | palbase/environments/<env>/openapi.json | the CLI, inside spec and link |
palbase build | palbase/palbase-env.d.ts — Database.public.* in your controllers; and palbase/strings/ — your t() sentences (Localization) | your db/*.ts and your t() calls | you, and every deploy |
Apple is the only platform the CLI generates for, because it is the only one with no build step of its own: web regenerates on npm run dev/build, Android on the next Gradle build. That asymmetry is the only difference between platforms here, which is why spec is one command and not four.
Where the contracts live
| Path | Committed? | Written by |
|---|---|---|
palbase/environments/<env>/openapi.json | yes | palbase link, spec — same path for every platform |
palbase/environments/<env>/<platform>-config.json | yes | palbase link — ios-config.json, macos-config.json, android-config.json or web-config.json |
palbase/environments/<env>/PalbaseGenerated.swift | yes | palbase link, spec (Apple only) |
palbase/environments/<env>/palbe.gen.ts | yes | palbe-gen, via predev/prebuild (web) |
Every platform's config is flat and per environment: app_id, base_url and api_key at the top level, plus an oauth block when Apple or Google sign-in is configured. There used to be a second shape — a native slot carrying a map of every environment in one file ({default_environment, environments: {…}}) — because that file lived at a single path, and pointing an app at a different environment meant overwriting it. Now every environment has its own directory, so that map has nothing left to do: native and web write the same flat shape, and palbe-gen requires just those top-level fields.
Neither config carries an environment ref or a branch. The base URL and the key already identify the Environment, and a second name for the same runtime is a copy that has to equal its original.
Note: A checkout can still be carrying the retired layout — the dotted
.palbase/root, orPalbase/Generated/,Palbase/Config/,openapi.json,roles.jsonorpalbase-config.jsonsitting directly under the visible root.palbase linkrefuses to run against one rather than migrating it: there is no automatic path from a tree holding both layouts, because it would have two contracts and two clients with no way to tell which one a build read. The fix is the message it prints — delete the retired paths, commit that deletion, and link again; everything underpalbase/regenerates from the project.
palbase spec
spec takes no arguments and no flags of its own. Like push it is target-relative: in a checkout with a palbase/project.json it refreshes the environment this checkout resolves to — the selected one, or the one the global --env <name> names for this call — and it prints that destination to stderr before it does anything.
palbase spec
# ▸ todoapp/main
# ✓ wrote palbase/environments/main/openapi.json (48213 bytes)
It asks the stack for what it is currently serving — GET <target>/v1/management/openapi — and the answer is parsed as JSON before a byte of it is written, so a document that is not a contract fails here rather than later, inside a generator, about a file nobody remembers writing.
Which environments get written is read from the committed slot files the link commands wrote — palbase/environments/<env>/<platform>-config.json, the same shape for web and native alike — never guessed from the directory layout and never read from a per-machine file. A fresh clone therefore behaves exactly like the machine that linked it, and a monorepo linked for both web and iOS gets both contracts from a single run (one write, since the contract itself does not vary by platform).
The contract that gets refreshed is the one this checkout resolves to: the environment palbase env use selected or --env names, or local while palbase start is running and --env names none. On an Apple checkout every environment's client is re-emitted afterwards, not just the refreshed one — a client missing for a configuration is a build that fails in whichever configuration nobody was using today. The environments this run did not reach are named out loud, with the age of what they were built from:
only main was refreshed. The others still describe what they last served:
local (from 2026-08-22 11:04)
`palbase link` fetches every environment's contract; `palbase spec --env <name>` refreshes one, and `palbase env use <name>` remembers the choice.
The last line is literal: palbase link with no target rewrites every environment's contract in one run, and spec refreshes the one environment it resolves to.
That report exists because the alternative was measured and is worse: after a route was added to the local stack and spec was run, the local client was byte-identical and carried none of it, while the line above it said the file had been written.
When there is nothing deployed yet, the project's own sentence is what you see, rather than a status code:
https://k3xq81w4m.palbase.studio has nothing to describe yet — push a backend to it first (palbase push)
Note: That refusal is on the unlinked arm only. In a directory with no
palbase/project.jsonand no platform slot at all,specfails before it fetches anything and names the ways to link instead of inventing an empty tree:this checkout is not linked to a project. palbase link <project> a project in the cloud palbase link <ref> the project that environment belongs to palbase link <url> a stack you host, or an environment's address palbase start bring one up here and link to itIn a linked checkout — what the rest of this page assumes — there is no such check:
specfetches the contract, writespalbase/environments/<env>/openapi.json, finds neither a web nor an Apple slot, generates nothing and succeeds silently.
One link, every platform
palbase link reads the checkout before it does anything and says what it found:
▸ ios, web
An Xcode project or workspace makes it Apple; an applicationId in
build.gradle[.kts] makes it Android; a package.json beside an
index.html/public//src/app makes it web. Each platform found gets what its
own generator reads, in the same run.
--platform is there to ask for less than what is present, never to repeat
it. It used to default to ios, which meant a web-only checkout silently
received an Xcode build configuration and a Swift client it had no use for — a
wrong default looks exactly like a right one. An unknown value is refused by
name, before any network.
There are no palbase ios link, palbase web link, palbase macos link or
palbase android link commands, and no <platform> use. They did the same work
this one does and were removed with no shim and no alias. palbase unlink
removes a checkout's binding; re-running palbase link rebinds it.
In a checkout with a client platform, one link writes every environment of the
project — its config, contract and roles — and palbase env use <name> chooses
which one the CLI's commands act on without touching a committed file. A checkout
with no client platform gets palbase/project.json and nothing per environment:
a checkout with no client platform has no app to hold them. See
Linking a Checkout.
What an Apple link writes
An Apple checkout gets every environment's contract, platform slot, generated Swift client and Palbase-Info.plist, each inside its own palbase/environments/<env>/ — link downloads them all, not just the one you happened to link last. Which one actually ends up in the built app is a build-time decision, not a runtime one, and the CLI writes nothing into the build system to make it: it prints a one-time snippet instead.
Add these to your own build configuration (xcconfig, build settings, Tuist —
whichever you already use). PALBASE_ENV is the only line you change; the other
two never do:
PALBASE_ENV = main
EXCLUDED_SOURCE_FILE_NAMES = */palbase/environments/*/*
INCLUDED_SOURCE_FILE_NAMES = */palbase/environments/$(PALBASE_ENV)/*
Then add palbase/environments to your app target. Set PALBASE_ENV to any
directory under it; an UNSET one expands to nothing in Xcode, so the include
pattern matches nothing and the app ships unconfigured.
The two-level glob is not a typo. * does not cross a directory boundary in these Xcode settings — measured on a real Xcode 26.6 build: the one-level form (*/palbase/environments/*) excludes nothing at all, while the two-level form above excludes correctly, verified in both directions. Get it wrong and the unselected environment's plist enters the app bundle right alongside the selected one.
This replaces an older mechanism that wrote one xcconfig per environment into Palbase/Config/ and asked you to add an INFOPLIST_KEY_PALBASE_ENV key to your Info.plist by hand. That mechanism is retired — it shipped half a fix, because the CLI could never assign an xcconfig to a build configuration (that is a pbxproj edit it has no way to make). Measured on a real simulator: a build in the Local configuration signed up against the main environment's address, because the build settings still read local and nothing tied the two together. The build-time include/exclude pattern above does not have that failure mode — a build either has an environment's files compiled in, or it does not, and there is no second setting to fall out of step with the first.
The Swift generator
The generator is not in the CLI. It ships with the iOS SDK (Sources/palbase-swiftgen in the palbackend-ios package), so the code it emits always matches the SDK version your app pins. The CLI locates the checkout SwiftPM already resolved for this project — a local package first, then .build/, then DerivedData — compiles it once for the host with xcrun swiftc, and caches the binary at ~/.palbase/tools/swiftgen-<source-hash>/palbase-swiftgen. The source hash is the version, so "is this the right build?" and "does it exist?" are the same question:
→ compiling the SDK's Swift generator (one-time per SDK version) ...
spec preflights that generator before any file moves. A spec written next to a client that cannot be re-emitted is exactly the drift these commands exist to prevent, and on a re-link the clients already on disk still compile — so the drift would stay invisible until a call 404s on a device. When the generator is unavailable, the run deletes every generated client and fails loudly rather than leaving stale ones behind.
Output is committed inside each environment's own directory (palbase/environments/<env>/) rather than produced at build time. Build-time output lands in DerivedData, where it is invisible to git diff, to the editor before a first build, and to anyone — or anything — reading the repository to learn which pb.<ns>.<op> calls actually exist. For a headless agent or a CI worker that means nothing extra is needed: there is no build-tool plugin, so xcodebuild needs no plugin-validation flag and no Xcode trust prompt ever appears.
What palbase link does for a web checkout
Run it in the web app root — the directory with package.json. Without one there
is nothing to wire, so it writes the artifacts, says so, and moves on rather than
failing the whole link.
| Flag | Applies to | What it does |
|---|---|---|
--entry | web | the entry file to wire the generated client into (auto-detected when absent) |
--out | web | the generated client's name (default palbe.gen.ts) |
For a web checkout it does the whole setup in one command:
- Takes back any leftover
.palbaseignore rule from the retired layout — dropped, not narrowed, because nothing underpalbase/is per-machine any more. It adds nothing new: the contract, the platform slot, the generated client andpalbase-env.d.tsare all committed. - Writes
palbase/environments/<env>/openapi.jsonandpalbase/environments/<env>/web-config.json. - Installs
@palbase/web@latestwhen the project does not have it, using the package manager its lockfile implies —pnpm add,yarn add,bun add, ornpm installfor a project with no lockfile. - Runs
palbe-genagainstpalbase/environments,PALBASE_ENVset to the linked environment. It writes that environment's client atpalbase/environments/<env>/palbe.gen.ts(--outrenames that file) and the stablepalbase/client.tsbarrel that re-exports it. - Adds
predevandprebuildscripts runningpalbe-gen --soft || exit 0, by splicing bytes intopackage.jsonrather than round-tripping it — key order, nested objects, indentation and&&in existing script values all stay byte-identical. An existing script with a different value is warned about, never overwritten. - Inserts an
importofpalbase/client.ts— never the per-environment file — into the detected entry file (--entrynames it explicitly), so switching environments later never touches the application's own source. The insert is directive-prologue aware, so'use client'stays first, and it never splices into the middle of a multiline import. - For a Next.js App Router layout, adds
providers.tsxso the browser bundle configures the client too, andproxy.tsso the session cookie refreshes before the RSC tree renders. The proxy embeds no environment value: it importspalbase/config.ts, whichpalbe-genrewrites alongsidepalbase/client.tson every run, so the session proxy and the data client always name the same stack. - Warns loudly — and exits 0 — if
.gitignorewould ignore either the per-environment client or the barrel. Both must be committed; the rule is reported, never edited.
If the SDK installation or generation fails, link fails and preserves the previous artifacts. A first link also requires a working generator; it never reports success with missing configuration or imports — with one exception: when the environment the link reads from has nothing deployed, there is no contract to generate from. Then link writes every environment's config, generates and wires nothing, prints the web client is not generated yet: main has no contract — `palbase push --env main`, then `palbase link` and exits 0; the client, the import and the scripts arrive with that palbase link after the push. Apple projects must have their SDK package resolved before linking.
The --soft in the hook is what makes a clone on a machine with no login and no network still build: palbe-gen reads the committed palbase/environments/<env>/openapi.json offline, and any error degrades to a warning instead of failing npm run dev.
Re-running palbase link refreshes everything the contract touches: it re-fetches
the environment's artifacts, regenerates the client — which it must, because the
generated file embeds the environment's base URL and key and is stale the moment
the config moves — and leaves the scripts, the import and the providers it
already wired exactly as they are.
palbase unlink removes the binding. It deliberately leaves the project's own
files alone:
left in place (remove manually if you are dropping Palbase for good):
- the generated client file, the environment config leaf beside it, and both barrels
- the entry-file import
- the predev/prebuild scripts in package.json
- app/providers.tsx (Next.js App Router only)
- app/proxy.ts (Next.js App Router only)
palbase build — typing your own handlers
palbase build is the third generator and the only one that has nothing to do with a client. It writes palbase/palbase-env.d.ts from your project's db/*.ts, so your handlers get a fully typed Database.public.* with no imports and no generics:
palbase build
# ✓ @palbase/backend <version>
# ✓ palbase/palbase-env.d.ts
# build OK — 37 route(s) across the controllers would deploy cleanly, plus 1 job(s), 1 webhook(s)
There is no separate types verb, on purpose: palbase-env.d.ts is derived output, and one command produces everything derived. Run build after editing a schema file; every deploy regenerates it too, and a project with no db/*.ts gets no file and needs none. When the file has not changed, the line says so — ✓ palbase/palbase-env.d.ts (unchanged). It also keeps palbase/strings/, the table of your backend's t() sentences, in step with your code — see Localization.
It needs Node.js and npm, and it is not offline: when @palbase/backend is absent it installs it, and it installs the runtime's own zod-to-json-schema with --no-save. Both failures warn and continue. Full behaviour, including the exit contract, is in Deploying.
What to commit
Commit all of palbase/ — there is nothing left to exclude. palbase-env.d.ts, the generated Swift client, the generated web client and its barrel all live inside it now, and none of it is per-machine any more. palbase link never adds anything to .gitignore for this directory; the only thing it does to .gitignore is take back a leftover .palbase rule from the retired layout, dropping it rather than narrowing it — a repository that still ignores that name is a repository carrying a rule for a producer that no longer exists.
For an Apple checkout that means each environment's PalbaseGenerated.swift and Palbase-Info.plist, committed inside palbase/environments/<env>/; for web it means that environment's palbe.gen.ts in the same place, plus the stable palbase/client.ts barrel the application actually imports.
Related
- Web Codegen — the generated
palbe.gen.ts, thepbsingleton, and project wiring in depth - iOS Codegen —
palbase link, the committed contract, and the committed Swift client - Linking a Checkout —
palbase link, its--platformflag, and the files that decide what a directory acts on - Deploying —
palbase build,palbase push, and the refresh a successful push runs - Backend calls (web) · Backend calls (iOS) — using the generated typed calls
- Schema · Database — what
palbase-env.d.tstypes