Skip to main content

Provisioning recipes

provision(name, operation, dependsOn?) declares a setup operation that is inspected, repaired if necessary, and verified before success is recorded. Put these declarations in resources. Names identify operations; dependencies are resolved resource IDs.

Provision operations remain on the machine when removed from configuration. Workstation forgets their state; it does not untap repositories, undo preferences, revoke access, remove copied data, or uninstall vendor software. Snapshot rollback of provisioning is unsupported. Use generated-file or service resources when their managed removal behavior is what you need.

Available operations

TypePlatformPurpose
brew-tapmacOSAdd a tap, verify its origin, optionally trust it
apt-repositoryLinux with APTInstall a deb822 source and digest-verified signing key
copy-filemacOS/LinuxCopy source bytes, seed defaults, or migrate a symlink
macos-defaultmacOSSet a Boolean, string, or integer preference
macos-installermacOSVerify a signed .pkg and run the native installer
lingerLinuxEnable a user's services outside login sessions
group-memberLinuxAdd an existing user to an existing group
serviceNative managerActivate an existing systemd/launchd service
checkmacOS/LinuxRun a read-only probe and conditional repair commands

Use darwin(...) and linux(...) around platform-specific fragments. Commands and URLs below containing example or REPLACE are templates, not working vendor installation instructions. Supply the actual repository, signer, account, or app.

Add a Homebrew tap before packages

import { darwin, defineConfig, provision, tools } from "@dovocode/workstation";

export default defineConfig(darwin({ resources: [
provision("vendor-tap", {
type: "brew-tap",
tap: "vendor/tools",
url: "https://github.com/vendor/homebrew-tools.git",
trust: true,
}),
...tools.brew(["vendor/tools/example"]).map(resource => ({
...resource,
dependsOn: ["provision:vendor-tap"],
})),
] }));

Repository provisioning runs before package version resolution during builds. The optional URL is checked against the existing tap origin. trust: true explicitly requests Homebrew trust and verifies its reported status; omit it when not required. Existing taps are retained after declaration removal.

Add an APT repository with a verified key

import { defineConfig, linux, provision, tools } from "@dovocode/workstation";

export default defineConfig(linux({ resources: [
provision("vendor-repository", {
type: "apt-repository",
name: "example-vendor",
uri: "https://packages.example.com/{distribution}",
suite: "auto",
components: ["stable"],
architecture: "auto",
keyUrl: "https://packages.example.com/{distribution}/key.asc",
keySha256: "REPLACE_WITH_64_HEX_DIGITS_FROM_THE_VERIFIED_KEY_FILE",
conflicts: ["example-legacy-package"],
}),
...tools.apt(["example-package"]).map(resource => ({
...resource,
dependsOn: ["provision:vendor-repository"],
})),
] }));

Obtain the key from the vendor's trusted distribution channel and verify it before recording the SHA-256 digest of the downloaded ASCII file. This is not the OpenPGP fingerprint. The placeholder intentionally fails validation until replaced. URLs must use credential-free HTTPS. suite: "auto" and architecture: "auto" resolve Debian/Ubuntu codename and architecture; {distribution} substitutes debian or ubuntu in the URLs during automatic resolution.

The operation installs /etc/apt/keyrings/<name>.asc and /etc/apt/sources.list.d/<name>.sources, then updates metadata. Downloads and digest checks precede privileged writes. curl, CA certificates, APT tools, and sudo must already work. Listed conflicting packages cause an error; they are never silently removed. Migrate them explicitly before retrying. Existing repositories are not prepared by a read-only plan or standalone lock update.

Copy or seed application settings

import { provision } from "@dovocode/workstation";

provision("example-settings", {
type: "copy-file",
source: "defaults/settings.json",
target: "~/.config/example/settings.json",
seed: true,
mode: 0o600,
});

The source must exist, even if the destination already exists: source bytes are captured during configuration loading. With seed: true, an existing regular file is preserved, including its content and mode. Without seed, content and permissions converge to the source and mode (default 0o644).

To convert an old symlink into an independent file while preserving its current contents, use both seed: true and migrateSymlink: true. The linked source stays intact. If the link is broken, the declared source supplies the seed. Without seed, migration copies the declared source instead. Originals are recorded in private state, but removing the provision declaration does not restore them.

Set privileged: true for copies that require sudo install. Privileged symlink migration is deliberately rejected and needs an explicit manual migration. Copies can contain arbitrary bytes; do not provide the internal resolved content field in ordinary source declarations.

Set a macOS preference

import { darwin, defineConfig, provision } from "@dovocode/workstation";

export default defineConfig(darwin({ resources: [
provision("example-preference", {
type: "macos-default",
domain: "com.example.app",
key: "ShowStatusItem",
value: true,
}),
] }));

Use the actual application's documented domain and key. Values accept Boolean, string, or safe integer types. Workstation reads and writes through defaults; it does not automatically restart the application or manage arbitrary plist arrays/dictionaries through this operation.

Install a signed macOS vendor package

import { darwin, defineConfig, provision } from "@dovocode/workstation";

export default defineConfig(darwin({ resources: [
provision("vendor-app", {
type: "macos-installer",
url: "https://downloads.example.com/Example.pkg",
teamId: "REPLACE1234",
installedPath: "/Applications/Example.app",
version: "1.2.3",
sha256: "REPLACE_WITH_64_HEX_DIGITS_FROM_THE_VERIFIED_PACKAGE",
}),
] }));

Supply the actual ten-character uppercase alphanumeric Developer Team ID and optional SHA-256 digest. Workstation downloads the package, checks the digest when supplied, verifies the Developer ID Installer signer with pkgutil, runs spctl assessment, and invokes sudo installer only after verification succeeds.

Without version, presence of installedPath satisfies inspection. With version, inspection compares the app bundle's CFBundleShortVersionString. There is no latest-version discovery: update the declaration to request another version. Installation effects and application data are not undone on removal or rollback.

Enable Linux lingering and group membership

import { defineConfig, linux, provision } from "@dovocode/workstation";

export default defineConfig(linux({ resources: [
provision("worker-linger", { type: "linger", user: "dominic" }),
provision("worker-group", {
type: "group-member", user: "dominic", group: "example-workers",
}),
] }));

Replace account/group names with existing local accounts. Workstation uses sudo loginctl enable-linger and sudo usermod -aG; it does not create users or groups. Group membership takes effect on the next login. Lingering does not by itself guarantee a usable user bus in the current process environment.

Activate a service supplied by another installer

import { defineConfig, linux, provision } from "@dovocode/workstation";

export default defineConfig(linux({ resources: [
provision("example-daemon", {
type: "service", manager: "systemd", scope: "system", name: "example.service",
}),
] }));

The unit must already exist. Systemd inspection checks active and enabled state; repair enables, starts, and restarts the service. User scope uses systemctl --user; system scope uses sudo for mutations.

For launchd, supply manager: "launchd", scope, name (the label), and plist. optionalSession: true permits a missing GUI domain or plist to remain inactive instead of failing; use it only when skipping activation is intended. Use generated service resources to own and remove the unit/plist itself.

Check and repair an initialized application

import { provision } from "@dovocode/workstation";

provision("example-health", {
type: "check",
requiresFile: "~/.config/example/initialized",
check: { command: "example", args: ["health"] },
repair: [
{ command: "example", args: ["repair"] },
],
});

Checks can run during plan, status, and builds: keep them read-only. Exit zero means healthy; nonzero requests repair. Failure to spawn a command is an error. Repairs execute sequentially during apply and must leave the subsequent probe successful. Arguments are literal; supply a shell explicitly for shell syntax.

requiresFile makes the operation inactive while a prerequisite is absent. This lets login, pairing, or vault creation remain an explicit task. It is not an initialization command. Working directories default to the entry point's directory; commands accept cwd and environment overrides.

Add dependsOn as the third argument when checks must follow package/file changes. Dependent checks are re-evaluated after prerequisite changes. Set restartOnChange: true when dependency changes should force repair even if the post-change check succeeds. Repairs and generated service restarts can propagate through dependencies; see resource IDs.

Repair a mise npm tool without upgrading it

import { configure, defineConfig, files, npmHealth, tools } from "@dovocode/workstation";

export default defineConfig(configure(({ home }) => {
const versions = { node: "lts", "npm:example-cli": "latest" };
return { resources: [
tools.mise(versions),
files.mise("~/.config/mise/config.toml", versions),
npmHealth("example-cli-health", {
tool: "npm:example-cli",
package: "example-cli",
executable: "example",
home,
dependsOn: ["package:mise:node", "package:mise:npm:example-cli"],
}),
] };
}));

Replace the example package/tool/executable with the actual installed package. npmHealth probes its executable with --version. A normal repair reinstalls the exact observed package version into its existing mise prefix with optional dependencies. It does not refresh the package pin.

For a package using node-pty, set nativePty: true. This probes a real PTY under the selected Node runtime and repairs the dependency's install/postinstall scripts; it does not use the executable smoke test in this mode. Runtime activation and build prerequisites must be correct. Environment overrides are available for the probe and repair. Authentication and account setup still belong in explicit tasks.