Palbase
Sign inGet started

CLI

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:

GeneratorProducesReadsWho runs it
palbe-gen (in @palbase/web)the web client — pb.todos.* in the browserpalbase/environments/<env>/{openapi.json,web-config.json}you, via predev/prebuild
palbase-swiftgen (in the iOS SDK)the Swift client — palbase/environments/<env>/PalbaseGenerated.swiftpalbase/environments/<env>/openapi.jsonthe CLI, inside spec and link
palbase buildpalbase/palbase-env.d.tsDatabase.public.* in your controllers; and palbase/strings/ — your t() sentences (Localization)your db/*.ts and your t() callsyou, 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

PathCommitted?Written by
palbase/environments/<env>/openapi.jsonyespalbase link, spec — same path for every platform
palbase/environments/<env>/<platform>-config.jsonyespalbase linkios-config.json, macos-config.json, android-config.json or web-config.json
palbase/environments/<env>/PalbaseGenerated.swiftyespalbase link, spec (Apple only)
palbase/environments/<env>/palbe.gen.tsyespalbe-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, or Palbase/Generated/, Palbase/Config/, openapi.json, roles.json or palbase-config.json sitting directly under the visible root. palbase link refuses 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 under palbase/ 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.json and no platform slot at all, spec fails 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 it

In a linked checkout — what the rest of this page assumes — there is no such check: spec fetches the contract, writes palbase/environments/<env>/openapi.json, finds neither a web nor an Apple slot, generates nothing and succeeds silently.

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.

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.

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.

FlagApplies toWhat it does
--entrywebthe entry file to wire the generated client into (auto-detected when absent)
--outwebthe generated client's name (default palbe.gen.ts)

For a web checkout it does the whole setup in one command:

  1. Takes back any leftover .palbase ignore rule from the retired layout — dropped, not narrowed, because nothing under palbase/ is per-machine any more. It adds nothing new: the contract, the platform slot, the generated client and palbase-env.d.ts are all committed.
  2. Writes palbase/environments/<env>/openapi.json and palbase/environments/<env>/web-config.json.
  3. Installs @palbase/web@latest when the project does not have it, using the package manager its lockfile implies — pnpm add, yarn add, bun add, or npm install for a project with no lockfile.
  4. Runs palbe-gen against palbase/environments, PALBASE_ENV set to the linked environment. It writes that environment's client at palbase/environments/<env>/palbe.gen.ts (--out renames that file) and the stable palbase/client.ts barrel that re-exports it.
  5. Adds predev and prebuild scripts running palbe-gen --soft || exit 0, by splicing bytes into package.json rather 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.
  6. Inserts an import of palbase/client.ts — never the per-environment file — into the detected entry file (--entry names 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.
  7. For a Next.js App Router layout, adds providers.tsx so the browser bundle configures the client too, and proxy.ts so the session cookie refreshes before the RSC tree renders. The proxy embeds no environment value: it imports palbase/config.ts, which palbe-gen rewrites alongside palbase/client.ts on every run, so the session proxy and the data client always name the same stack.
  8. Warns loudly — and exits 0 — if .gitignore would 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.