@optitech/config-runtime is the package the optitech CLI itself uses to run a optitech.ts policy: read a branch's live state, diff a policy against it, apply the result, and bundle and deploy OptiTech Functions. Import it directly when you're writing your own CI step or script and the CLI commands (optitech config plan, optitech deploy) don't fit. For example, a custom GitHub Actions job that applies a policy to a freshly created preview branch.
If you just want to run optitech.ts from the command line, use the optitech config / optitech deploy commands instead. Reach for this package only when you need to call the same logic from your own Node.js code.
note
@optitech/config-runtime is filesystem- and env-agnostic: it never reads a .optitech context file or OPTITECH_* environment variables (except the OPTITECH_API_KEY / OPTITECH_API_HOST fallbacks documented under Authentication). You resolve projectId and branchId yourself and pass them in explicitly. This is different from the optitech CLI, which resolves both from .optitech / OPTITECH_* for you.
Install
npm install @optitech/config-runtimeRequires Node.js 20.19 or later. Import from the /v1 subpath to pin a specific major version:
import { inspect, plan, apply } from "@optitech/config-runtime/v1";This package pulls in esbuild (a native binary) to bundle OptiTech Functions for deploy, so it belongs in a CLI, CI job, or deploy script, not in optitech.ts itself. optitech.ts should only ever import @optitech/config, which has no native dependencies.
Resolving project and branch ids
Every function on this page takes projectId and a OptiTech branch id (br-…), not a branch name. Look these up yourself before calling in:
optitech projects list --output jsonandoptitech branches list --output json(the OptiTech CLI)- The OptiTech API's list branches endpoint
- The
.optitechcontext file written byoptitech link(the CLI's own resolution mechanism, if your script runs alongside it)
Passing a branch name where an id is expected fails with a PLATFORM_BRANCH_NOT_FOUND error (see Error handling).
Quick example
import config from "../optitech";
import { apply, inspect, plan } from "@optitech/config-runtime/v1";
const target = { projectId: "solitary-fog-12345678", branchId: "br-cool-forest-12345678" };
const live = await inspect(target); // read the branch's current live state
const diff = await plan(config, target); // dry-run: what would apply change?
const result = await apply(config, target); // apply the policy for realapply throws if the branch already has settings your policy would override. See apply for how to allow that.
Authentication
None of the functions on this page take a OptiTech session or browser login. They authenticate with a OptiTech API key, resolved in this order:
- The
apiKeyoption, if you pass one. - The
OPTITECH_API_KEYenvironment variable. ~/.config/optitechctl/credentials.json, written after runningoptitech auth(ornpx optitechctl auth): the same file the CLI itself uses.
If none of these resolve, every function throws a PlatformError with code PLATFORM_MISSING_API_KEY and a message pointing you to app.optitech-sverige.se/app/settings/api-keys to generate a key. apiHost follows the same pattern for inspect, plan, apply, pullConfig, and pushConfig: pass it explicitly, or let it fall back to OPTITECH_API_HOST and then OptiTech's production API. createBranch is the exception: CreateBranchOptions has no apiHost field, so you can't override the host per call there. It still honors OPTITECH_API_HOST from the environment if you set it.
For CI, don't rely on the local CLI credentials fallback. A script that works on a developer machine because optitech auth wrote ~/.config/optitechctl/credentials.json will fail in a clean CI runner. Pass apiKey explicitly or set OPTITECH_API_KEY in the job environment.
Pass api instead to inject your own OptiTechApi adapter (used internally for tests; most callers don't need this).
Operations
The three functions below mirror the Terraform mental model (inspect reads state, plan previews a diff, apply reconciles it) and are what optitech config status / plan / apply call internally.
inspect
function inspect(options: ConfigOperationOptions): Promise<PulledBranchConfig>;Reads a branch's live OptiTech state and reverse-engineers it into a optitech.ts-shaped Config, plus raw project/branch metadata. Read-only, and never mutates anything.
ConfigOperationOptions field | Type | Description |
|---|---|---|
projectId | string | Required. |
branchId | string | Required. Must already exist on the project; inspect never creates one. |
apiKey | string? | See Authentication. |
apiHost | string? | See Authentication. |
api | OptiTechApi? | Inject a custom adapter (mainly for tests). |
Returns a PulledBranchConfig:
| Field | Type | Description |
|---|---|---|
project | { id, name, region, pgVersion, orgId? } | Project metadata. |
branch | { id, name, parent?, isDefault, protected, expiresAt? } | Branch metadata. |
config | Config | The branch's state, expressed as a optitech.ts-shaped policy (static auth/dataApi toggles plus a branch closure carrying its lifecycle/compute tuning). |
preview | PulledPreview? | Buckets, functions, and issued credentials on the branch. Omitted entirely when the branch has none. |
A pulled function is reported as { slug, name } only: the remote has no record of the local source file path, so a pulled config can't redeploy a function without you re-adding source by hand.
plan
function plan(config: Config, options: ConfigOperationOptions): Promise<PushResult>;Computes what apply would do, without mutating anything (the equivalent of terraform plan). Takes the same options as inspect.
Returns a PushResult with the same shape as apply. On a dry run, applied describes the changes that would be applied; no remote state is modified.
apply
function apply(config: Config, options: ApplyOptions): Promise<PushResult>;Applies a optitech.ts policy to an existing branch. Never creates a project or branch: both must already exist (use createBranch to provision one from a policy). ApplyOptions extends ConfigOperationOptions with:
| Field | Type | Default | Description |
|---|---|---|---|
updateExisting | boolean | false | Auto-confirm overriding existing remote settings (TTL, protected, compute settings). Without it, drift from the branch's current state throws PushConflictError. |
allowProtectedBranch | boolean | false | Auto-confirm applying to a branch marked protected on OptiTech (see the note below the table). |
bundleFunction | FunctionBundler? | esbuild | Custom bundler for function source. See Function bundling. |
apply doesn't accept an interactive confirmation callback (that's only on the lower-level pushConfig), so it's either non-interactive (pass updateExisting/allowProtectedBranch up front) or it fails closed: unresolved drift throws PushConflictError (see Error handling). Note that a protected branch with no other drift is not itself blocked by allowProtectedBranch: false; that flag only matters together with the interactive confirm callback on pushConfig.
Returns a PushResult:
| Field | Type | Description |
|---|---|---|
projectId | string | Target project id. |
orgId | string? | Organization id for the target project, when the API returns one. |
branchId | string | Target branch id. |
branchName | string | Target branch name. |
dryRun | boolean | true for plan / pushConfig({ dryRun: true }); applied then records planned changes only. |
applied | AppliedChange[] | Ordered list of policy changes that were applied or, on dry runs, would be applied. Each entry identifies the changed resource and field. |
conflicts | ConflictReport[] | Conflicts found while comparing local policy with remote state. Empty when the push can proceed. |
createBranch
function createBranch(config: Config, options: CreateBranchOptions): Promise<CreateBranchResult>;Creates a branch from a optitech.ts policy and brings it up with its declared settings in one step: it calls the OptiTech API directly to create the branch, then applies the rest of the policy to it with pushConfig. This is the flow optitech checkout <new-name> needs when it creates a new branch, and the CLI calls this function to get it (not the other way around). Concretely, createBranch evaluates the policy with branch.exists: false (so creation-time tuning gated on !branch.exists actually resolves), creates the branch from the policy's parent (falling back to the project's default branch), then reconciles the rest of the policy onto it.
CreateBranchOptions field | Type | Description |
|---|---|---|
projectId | string | Required. |
branchName | string | Required. Must not already exist on the project. |
apiKey | string? | See Authentication. |
api | OptiTechApi? | Inject a custom adapter. |
bundleFunction | FunctionBundler? | Custom bundler. See Function bundling. |
Returns { branchId, branchName, result: PushResult }. Throws a PlatformError (PLATFORM_CONFLICT) if branchName already exists, or (PLATFORM_BRANCH_NOT_FOUND) if the policy's parent names a branch that doesn't exist on the project.
Lower-level:pullConfigandpushConfig
inspect, plan, and apply are thin, intent-revealing wrappers over two lower-level primitives. Reach for these directly when you need control they don't expose: most commonly, an interactive confirmation prompt, or evaluating the branch closure as a creation (branchExists: false) so creation-time tuning resolves.
pushConfig always requires a branchId that already exists on the project; branchExists: false only changes how the policy is evaluated, not whether the branch has to exist. pushConfig never creates a branch, on dryRun or otherwise. If the branch doesn't exist yet, use createBranch instead.
pullConfig
function pullConfig(options: PullConfigOptions): Promise<PulledBranchConfig>;Functionally identical to inspect: inspect forwards its options (projectId, branchId, api, apiKey, apiHost) to pullConfig unchanged and returns its result directly, with no other logic in between. The two names exist so call sites can read naturally (inspect next to plan/apply) while the engine module stays named after what it does.
pushConfig
function pushConfig(config: Config, options: PushConfigOptions): Promise<PushResult>;Returns the same PushResult shape as apply. With dryRun: true, applied is the ordered list of changes that would be applied, and the function does not mutate remote state.
The engine behind plan and apply. PushConfigOptions is ApplyOptions plus:
| Field | Type | Default | Description |
|---|---|---|---|
branchExists | boolean | true | Evaluate the policy's branch closure as if the target branch doesn't exist yet (branch.exists: false), without changing whether it physically exists on OptiTech. createBranch uses this internally so creation-time tuning (TTL, compute, parent) resolves right after provisioning. |
confirm | (context: PushConfirmContext) => boolean | Promise<boolean> | none | Invoked once, before any mutation, when the push needs confirmation: either the branch is protected (and allowProtectedBranch isn't true) or applying would override existing settings (and updateExisting isn't true). Return true to proceed; a false return throws PushAbortedError. Not invoked, and no mutation runs, when the plan has unresolvable conflicts: those throw PushConflictError regardless of confirm. Never invoked on dryRun. |
dryRun | boolean | false | Compute the full plan against live remote state without executing any mutations. plan(config, target) is exactly pushConfig(config, { ...target, dryRun: true, updateExisting: true }). |
PushConfirmContext passed to confirm:
| Field | Type | Description |
|---|---|---|
branchName | string | Target branch's name. |
protectedBranch | boolean | true when the branch is protected on OptiTech and allowProtectedBranch wasn't set. |
overrideUpdates | boolean | true when the plan would override existing remote settings and updateExisting wasn't set. Additive changes (enabling a service for the first time) never set this. |
Use confirm to render your own "are you sure?" prompt instead of failing closed with PushConflictError, for example in an interactive CLI built on top of this package.
Function bundling
Deploying a OptiTech Function means bundling its source into a ZIP archive. By default, apply, pushConfig, and createBranch do this with esbuild:
type FunctionBundler = (fn: ResolvedFunctionConfig) => Promise<Uint8Array>;
function buildFunctionBundle(fn: ResolvedFunctionConfig): Promise<Uint8Array>;buildFunctionBundle is loaded lazily and only when a deploy actually bundles a function, so a caller that never deploys functions, or that supplies its own bundleFunction, never pulls esbuild's native binary into their build. Inject a custom bundler via the bundleFunction option on apply / pushConfig / createBranch when your runtime can't ship that binary, for example a single-file packaged CLI, or a restricted CI sandbox.
ResolvedFunctionConfig (what your bundler receives) has all deploy defaults already applied: slug, name, source (path to the entry file), env (resolved key/value pairs), runtime, and an optional dev block used only by optitech dev.
note
The optitech CLI has its own separate OPTITECH_ESBUILD_PATH escape hatch for "esbuild not found" errors (see Deploy and manage OptiTech Functions). That variable is read by the CLI's own bundler, not by buildFunctionBundle in this package, so it has no effect when you call @optitech/config-runtime directly. Pass bundleFunction instead.
Load aoptitech.tsfile
function loadConfigFromFile(options?: LoadConfigOptions): Promise<{ config: Config; resolvedPath: string }>;Re-exported from @optitech/config for convenience. Use it when your script doesn't already have a Config object in scope, for example a CI step that runs independently of a bundler that could import optitech.ts directly.
LoadConfigOptions field | Type | Description |
|---|---|---|
path | string? | Explicit path to a config file. Takes precedence over the search below. |
cwd | string? | Starting directory for the upward search. Defaults to process.cwd(). |
stopAt | string? | Hard ceiling for the upward walk. Defaults to the OS home directory. |
Without path, it walks up from cwd looking for optitech.ts / optitech.mts / optitech.js / optitech.mjs, stopping at the first directory containing .git (monorepo-friendly: an intermediate package.json doesn't stop the walk).
A custom CI step
Putting the pieces together: a script that plans and applies a policy against a specific branch, suitable as its own CI job:
import { loadConfigFromFile, plan, apply } from "@optitech/config-runtime/v1";
// Resolve these yourself: from CI variables you set, `optitech branches list --output json`,
// or the OptiTech API. This package never reads `.optitech` or `OPTITECH_*` (besides OPTITECH_API_KEY / OPTITECH_API_HOST).
const projectId = process.env.OPTITECH_PROJECT_ID!;
const branchId = process.env.OPTITECH_BRANCH_ID!;
const shouldApply = process.env.OPTITECH_APPLY === "true";
const { config } = await loadConfigFromFile();
// OPTITECH_API_KEY in the environment is picked up automatically, so there's no need to pass apiKey.
const target = {
projectId,
branchId,
updateExisting: true,
allowProtectedBranch: true,
};
const planned = await plan(config, target);
console.log(`Plan: ${planned.applied.length} change(s) for ${planned.branchName}`);
if (!shouldApply) {
console.log("Dry run only. Set OPTITECH_APPLY=true to apply this plan.");
process.exit(0);
}
const result = await apply(config, target);
console.log(`Applied ${result.applied.length} change(s) to ${result.branchName}`);Run it with tsx or after compiling with tsc, the same as any Node.js script:
npx tsx scripts/deploy-branch.tsError handling
Every error this package throws extends PlatformError (also re-exported here from @optitech/config), which carries a stable code string plus optional details. Prefer isPlatformError(err) over err instanceof PlatformError: a optitech.ts loaded through the internal TypeScript loader (jiti) imports its own copy of this package, so an error it throws can fail instanceof across that boundary. isPlatformError checks the code string instead, which survives it.
The subclasses relevant to programmatic use:
| Class | code | Thrown by | When |
|---|---|---|---|
PushConflictError | PLATFORM_PUSH_CONFLICT | apply, pushConfig | Local policy conflicts with remote state and you didn't pass updateExisting. Carries a conflicts: ConflictReport[] array. |
PushAbortedError | PLATFORM_PUSH_ABORTED | pushConfig | Your confirm callback returned false. Carries branchName and reasons. |
ConfigLoadError | PLATFORM_CONFIG_LOAD_FAILED | loadConfigFromFile | No config file found, or it failed to evaluate. |
PlatformError with code PLATFORM_MISSING_API_KEY | PLATFORM_MISSING_API_KEY | any operation | No apiKey could be resolved. See Authentication. |
PlatformError with code PLATFORM_BRANCH_NOT_FOUND | PLATFORM_BRANCH_NOT_FOUND | any operation | branchId doesn't exist on the project (see Resolving project and branch ids), or a policy's parent names a branch that doesn't exist. |
A ConflictReport (on PushConflictError.conflicts and PushResult.conflicts) has kind, identifier, field, current, desired, and a human-readable reason.
Related
optitech.tsreference: the policy this package operates on, and theoptitech config/optitech deployCLI commands that wrap it.- optitech-pkgs source on GitHub.
Need help?
Join our Discord Server to ask questions or see what others are doing with OptiTech. For paid plan support options, see Support.