The contracts behind the CLI: exit codes, the rules sync and check follow, how conflicts are classified, structured-file safety, the upgrade transaction and the caveats worth knowing. For day-to-day usage see the README.
Mapped in src/exit-codes.ts; success (0) is emitted by the reporter.
| Exit | Code | Meaning |
|---|---|---|
0 |
none | Success / no-op |
1 |
any other actionable error | e.g. NOT_INITIALIZED, CONFIG_INVALID, PAYLOAD_INVALID, SCHEMA_UNSUPPORTED, CONFIG_PKG_MISSING, CONFIG_VERSION_MISMATCH, REGISTRY_AUTH_FAILED, WRITE_FAILED, INSTALL_FAILED, ROLLBACK_FAILED, NOT_A_REPO, ALREADY_INITIALIZED, PROFILE_DETECT_FAILED, NO_NEWER_VERSION, TARGET_NOT_FOUND |
2 |
CONFLICTS_PENDING |
sync hit an unresolved conflict: a full-file edit, a marker/merge fault, a structural fault, or the dirty-tree guard. Re-run with --force or --interactive. Under upgrade this also rolls the bump back (rolledBack: true, conflict plan in details) |
3 |
DRIFT_DETECTED |
check found file drift, version skew, or a structural fault |
4 |
OUTDATED |
check --fail-on outdated|any and a newer release is published |
A structural fault (composed .json*/.ya?ml output that does not parse) surfaces as exit 3 under check (it makes inSync false, so DRIFT_DETECTED) and as exit 2 under sync (a hard conflict that blocks the write; --force cannot fix invalid output).
Loading the payload can fail in two ways, both of which exit 1:
PAYLOAD_INVALID: the installed package is not a streamctl payload. Itspresets/manifest.jsonis missing, unreadable or not JSON, or a preset the manifest lists has nopreset.json.SCHEMA_UNSUPPORTED: the payload'smanifest.jsondeclares aschemaVersionthis CLI does not understand. That is a CLI/payload compatibility break, so upgrade one side or the other.
upgrade adds one more exit-1 code. ROLLBACK_FAILED means a transactional rollback could not restore a snapshotted file and the repo needs a manual git restore. The message and the details payload (restore[], failed[], recover, perFile[]) name the exact files and the recovery command.
With --json, both success and failure print an envelope:
{ "ok": true, "command": "...", "data": {}, "error": { "code": "...", "message": "...", "details": {} }, "exitCode": 0 }data is present on success, error on failure.
CI runs
check; humans runsync --interactive.
CI uses streamctl check, the read-only gate, and never runs sync. Humans run streamctl sync --interactive and confirm each conflict before it overwrites anything.
Run non-interactively, sync is still conflict-safe. A full-file conflict, meaning the consumer edited streamctl-owned content, aborts with exit 2 and overwrites nothing. Safe block/merge reconciles and clean creates are written as usual.
--force is the escape hatch. It accepts every change including full-conflicts, and it bypasses the dirty-tree guard. Keep it out of unattended CI.
The dirty-tree guard: if a streamctl-owned path has uncommitted tracked edits, sync refuses rather than clobber the work. Commit or stash and re-run, or pass --force. Edits to project-owned scaffold files only produce a warning.
sync is transactional: the managed set is composed and structurally validated before any file is written, so a partial-batch failure writes nothing. Per-file writes are atomic. It is also idempotent, so a second run with no upstream change writes nothing and exits 0.
The streamctl.config.ts version pin is the payload's version, never the CLI's. The two
packages release on their own cadence. init resolves the pin in this order, before it
writes anything:
--payload-version <version>, checked against the registry unless the registry is off-limits (see below). An unpublished version fails withTARGET_NOT_FOUND.- The version embedded in a local override value, when the payload is pinned through
pnpm.overrides/overrides(file:/…/payload-1.4.0.tgzgives1.4.0). - The latest published release, probed from the registry.
If none of those resolves, init fails with CONFIG_INVALID naming --payload-version.
Nothing has been written at that point, so a retry is never blocked.
--skip-registry-check and an override both mean "no registry traffic for this payload",
so neither probe runs. With an override the version is read from its value; without one,
--payload-version becomes required.
The @sidebase/streamctl devDep is pinned separately, to the version of the CLI that ran
init. upgrade moves the payload pin only.
Every conflict carries a kind, both in the --json conflicted[] entries and in the grouped human report. It tells you whether you are being asked to adopt a file or to review something you changed:
kind |
What it means | What to do |
|---|---|---|
edit |
A managed file diverged from its target after being in-sync | sync --interactive (accept/skip per file), or sync --force to overwrite |
adoption |
A pre-existing full file the payload now manages (declared via the manifest's adoption hint). expected softens the wording, unexpected warns; messaging only, same exit code |
sync --interactive to adopt per file, sync --force to take ownership, or --only <glob> to scope |
fault |
A structural impossibility (broken block markers, unparseable merge/compose output) | Not fixable with --force; fix the payload. --only <glob> can skip it meanwhile |
dirty |
The dirty-tree guard: an owned path has uncommitted tracked edits | Commit or stash, or re-run with sync --force |
The classification is informational. Exit 2 (CONFLICTS_PENDING) semantics are unchanged.
block is forbidden on structured paths (.json, .jsonc, .json5, .yaml, .yml), because block's append semantics corrupt a single-root document. A preset that declares block on such a path fails to load with CONFIG_INVALID (block strategy is not allowed on structured file ...). Use full or merge for those files.
Composed output is also parse-validated before it is written. A .json*/.ya?ml target whose composed content does not parse counts as a structural fault and never reaches disk (see Exit codes).
strategy is a closed set: a value outside full/block/merge/scaffold fails at load against the payload's manifest schema (CONFIG_INVALID). The old internal deferred skip-status was removed, so an unknown strategy can no longer silently fall through.
When the version reconcile edits package.json (non---dry-run) and a lockfile exists, the sync report appends a one-line hint naming the detected package manager's plain install command (e.g. pnpm install / npm install), and the --json data adds lockfileStale: true. The hint is informational and never affects the exit code. sync does not install anything, so refreshing the lockfile is your job; only init and upgrade run installs of their own.
check --json carries the same lockfileStale: true when reconcile drift would edit package.json and a lockfile exists (i.e. a sync would stale it). Also informational, never exit-affecting.
upgrade is the only command that moves the pinned version forward. Either it applies in full, or it puts the repo back exactly as it was.
The flow runs in this order. Resolve the target, which is the latest published version or whatever --to names. Snapshot the three files a failed run could leave inconsistent: the config file (wherever it resolved), package.json, and the detected package manager's lockfile. Bump the pin and the payload devDependency, leaving the CLI's own version alone. Install, so the new bundled presets land on disk. Preflight and apply the first sync against the new version. Finally, when that sync's version reconcile edited package.json and a lockfile exists, install once more so the lockfile matches, without a second prompt.
The preflight comes for free from sync being transactional. It composes and validates the whole batch and writes nothing until the batch is clean, so a non-interactive conflict or an invalid new payload throws before any managed file changes.
Any failure after the snapshot rolls back. The three files are restored byte-for-byte and the original error is re-thrown with rolledBack: true. If the install had already completed, the rollback re-runs the package manager's install so node_modules matches the restored package.json again. That reinstall is best-effort: when it fails you get a node_modules is stale -- run <pm> install hint and the original error is still preserved. And if the restore itself fails, ROLLBACK_FAILED (exit 1) names the exact files and prints a git restore recovery command.
--no-install bumps the pin and devDependency but skips the install and the preflight sync. The bump is kept (this path is exempt from rollback); the report tells you to install and run sync yourself.
The --json data adds rolledBack: boolean and plan: { path, kind }[] (the preflight summary of what the sync would do); on a rolled-back failure, the same plan and rolledBack ride in the error details.
When the payload is pinned through a local override (a pnpm.overrides / root overrides entry pointing at a file: tarball or dir), its latest release cannot be resolved from the registry, so upgrade requires an explicit --to <version>.
Keep the override itself in step. Before upgrade bumps or installs anything it reads the override value, and if that value embeds a different version than --to (say file:/.../config-0.1.2.tgz while you pass --to 0.1.3), it fails fast with CONFIG_INVALID and tells you to point the override at the 0.1.3 artifact first. It will not rewrite the override for you, since the syntax is package-manager-specific. A version-less override, a bare dir path or a link:, is only noted; the post-install version check still catches a real mismatch.
Per-file state in streamctl status is one of:
| State | Meaning |
|---|---|
in-sync |
Matches the composed target |
drift |
A block/merge region a sync would reconcile |
conflict |
A full file that differs |
missing |
The managed file does not exist yet |
off |
Opted out via files: "off" |
disabled |
The preset's enabledBy condition is false |
scaffolded |
A scaffold file's steady state (project-owned) |
fault |
A structural impossibility |
The --json data is { files: { path, state, strategy }[], payload: { package, pinned, installed, latest? }, profile, cliVersion, lockfileStale }.
A configKeys entry is either the bare type or { "type": ..., "default": ... }:
"configKeys": {
"features.prisma": { "type": "boolean", "default": true },
"ci.lint": "boolean"
}- When the consumer config leaves the path unset, the default is used everywhere the config is read: directive conditions,
enabledByand placeholders. A value the config sets wins,false,""and[]included. - The default must match
type.objectkeys take no default. - A child preset overrides a parent's default; redeclaring the key with the bare type removes it.
- Needs manifest
schemaVersion: 5and CLI 0.5.1 or newer. CLI 0.5.0 rejects the object form when loading the preset. upgraderenders with the defaults of the payload it installs. Defaults set in the payload's config wrapper (the functionstreamctl.config.tscalls) do not reach that run: the config is evaluated before the install, with the wrapper version that was installed before. UseconfigKeysdefaults for anything the upgrade sync must already see.
Payload templates and fragments can mark runs of whole lines as conditional. Requires manifest schemaVersion: 5 (DIRECTIVES_MIN_SCHEMA_VERSION). Below schemaVersion 5, streamctl: comments are plain content; only a well-formed if/else/end line is detected, to raise the schema-version error.
One directive per line: optional indent, the file's comment token, optional spaces, streamctl:, the verb, one or more spaces, the condition, and for HTML-comment files optional spaces then -->. Nothing else may follow.
<token> streamctl:if <condition>
<token> streamctl:else <condition> (optional, at most one)
<token> streamctl:end <condition>
- The condition is
[!]segment(.segment)*, segments[A-Za-z0-9_-]+.!negates. elseandendrepeat theifcondition byte for byte,!included.- Blocks do not nest. Empty bodies and empty
elsebranches are allowed. - A trailing
\ris tolerated; every other line is kept byte for byte. - Only a line that starts (after whitespace) with the comment token and
streamctl:is a directive.run: echo "# streamctl:if x"is content.
| Comment token | Files |
|---|---|
# |
YAML, Dockerfile, ignore files, .editorconfig, ini, sh, any unknown extension |
// |
js, mjs, cjs, ts, mts, cts (json, jsonc and json5 also use // but reject directives) |
<!-- ... --> |
md, markdown, html, htm, vue |
full, scaffold and block templates accept directives, with or without a render definition, as do fragment sources. .json, .jsonc and .json5 targets reject them (structured-json), and so do merge templates (merge-strategy). The token is chosen by the target path for templates and by the source path for fragments.
- When the preset chain resolves, every template and fragment source is read and scanned, including files switched off by
files: offorenabledBy. Syntax, placement, schema-version and condition-declaration errors fail every command here. - Per active file: the directive pass runs on the template body, then on each fragment body.
- Fragments are appended (
toggle,forEach). - Placeholders are substituted. A
${TOKEN}inside a dropped block is gone and needs no value: placeholders are only resolved and pattern-checked when their token is reachable from the rendered text. - Structured output (
.json*,.ya?ml) is parse-checked.
The directive pass removes every directive line, the if branch of a false block and the else branch of a true one.
The condition must be declared in the merged configKeys, either exactly or under an object key (ci declared as object allows ci.lint). Naming the object key itself is an error.
| Config value | Result |
|---|---|
true |
true |
| non-empty string | true |
| array with at least one element | true |
absent (and no default), false, "", [] |
false |
number, object, null |
CONFIG_INVALID (unevaluable-value) |
if,elseandenduse identical leading whitespace.- In
.yaml/.ymltargets every non-blank line of both branches starts with the directives' indentation. YAML itself allows comments at any indentation; this is a deliberate lint. - With
else, the template must stay valid with both branches present. Alternative list items, Dockerfile instructions, Markdown paragraphs or TS statements are fine. Two versions of the same YAML mapping key are not (the YAML language server flags the duplicate); use a placeholder for key-level variants.
From schemaVersion: 5, streamctl: right after a comment token is reserved. Prose like # streamctl: the CLI is an unknown-directive error. The verbs each, insert and raw are reserved for a future version and fail the same way with a hint.
Every directive error is CONFIG_INVALID (exit 1), with details.reason set to one of the values below.
- Parse and chain-time errors read
presets/<source>:<line>: <sentence>.detailscarriesfile,line,reasonand, where relevant,directive,condition,expected,openedAt,found,required. undeclared-conditionuses the message shared with placeholders andenabledBy:template "presets/<source>" line N references config path "...", which no preset declares in configKeys.(fragment "..."for fragments).detailscarriespath,declared,file,line,condition,reason.unevaluable-valueis raised at render time against the target path:<target>: condition "..." has a <type> value in the config, ....detailsis{ file, condition, reason, found }, withoutline.
reason |
Meaning |
|---|---|
unknown-directive |
Verb is not if, else or end (reserved verbs get a hint) |
missing-condition |
Directive without a condition |
malformed-condition |
Condition fails the grammar, extra text on the line, or missing --> |
unopened-end |
end with no open block |
unopened-else |
else with no open block |
duplicate-else |
Second else in one block |
unclosed-if |
if without end before the end of the text |
end-mismatch |
end names a different condition than the open if |
else-mismatch |
else names a different condition than the open if |
nested-if |
if while another block is open |
indent-mismatch |
else or end indented differently from its if |
body-outdented |
YAML only: a body line indented less than the directives |
structured-json |
Directive in a .json/.jsonc/.json5 target |
merge-strategy |
Directive in a merge template |
schema-version |
Well-formed directive present but the manifest declares schemaVersion below 5 |
undeclared-condition |
Condition not declared in any preset's configKeys |
object-condition |
Condition names an object key exactly |
unevaluable-value |
At render: the consumer config holds a number, object or null at the condition |
Payload tooling can list a template's blocks with scanDirectives(text, path) from @sidebase/streamctl/manifest.
scaffoldfiles evaluate directives once, when they are written. Later config changes do not touch them.- Dockerfile parser directives (
# syntax=,# escape=) must stay the first lines. Docker stops looking for them after the first ordinary comment, so put streamctl directives after them. A block inside aRUN \continuation must not hold the line that ends the continuation: dropping it leaves a dangling\. - Inside a YAML block scalar (
run: |) a# streamctl:ifline is string content to YAML but still a directive to streamctl. It is removed before YAML parses the output. - Flipping a knob that changes a block in a
fullfile is a render change like any other:checkreports drift (exit3),statusshows the file asconflict, andsyncblocks it as aneditconflict until you resolve it with--interactiveor--force(see Known caveats).
A multiline placeholder value takes the indentation of the line holding its token: continuation lines get that line's leading whitespace, empty continuation lines stay empty. Tokens at column 0 render as before. This is not gated by schemaVersion.
run: |
${SCRIPT}With SCRIPT = "pnpm lint\npnpm test" this renders both commands at the run body's indentation.
A string[] placeholder value is deduped and sorted alphabetically (not config order), then joined per join:
join |
Separator | Shell-metacharacter check | Needs schemaVersion |
|---|---|---|---|
space (default) |
" " |
yes, per item | 2 |
lines |
"\n" |
no | 2 |
comma |
", " |
no | 5 |
The metacharacter check rejects whitespace and ; | & $ ` < > ( ) { } \ ' " * ? [ ] in each item, because space-joined values typically land in an unquoted shell interpolation. comma targets YAML flow sequences such as branches: [${BRANCHES}] and skips it, so release/* passes; guard it with the placeholder pattern, which is tested against the whole joined value in every mode.
Type-aware lint needs generated types first. With LINT_TYPEAWARE=true, the Prisma client and the Nuxt types have to exist before lint (and typecheck) runs, so run prisma generate and nuxi prepare first. The managed ci.yaml already orders prisma generate ahead of lint, and postinstall: "nuxt prepare" covers local installs. There is no preflight check for this.
Local-tarball adoption skips init's registry probe. If a pnpm.overrides or root overrides entry already resolves the payload package, an auth probe against the registry proves nothing, so init skips it. --skip-registry-check is still there for other setups.
Payload content that changes under the same version shows up as an edit conflict. Drift is derived and there is no state file, so sync cannot tell a repacked local tarball apart from a local edit. It blocks and asks for review (--interactive or --force). Fleet updates are better carried by a version bump and streamctl upgrade, where the preflight previews the change for you.
Config changes hit the same wall. Edit a streamctl.config.ts knob that feeds a full-strategy render (placeholders, fragment toggles) and the next sync reports the render delta as edit/adoption conflicts and exits 2. Resolve with sync --interactive, sync --force or --only <glob>. block/merge reconciles are unaffected. A rendered-content baseline would remove this friction, and may show up later.
The shipped ESLint wrapper is not a drop-in for a complex repo. A bare createStreamctlEslint() lints everything, scratch and artifact directories included. Real adopters chain their own ignores onto it: createStreamctlEslint().append({ ignores: ["scratch/**", /* ... */] }).
The Node engines floor is inherited. ^22.22.2 || ^24.15.0 || >=26.0.0 copies write-file-atomic@8's own engines requirement verbatim, because that atomic-write dependency is what sets the real floor. Relaxing streamctl's range below it would just move the install warning down to the dependency.