Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
93f7f5f
refactor: move commentSyntaxFor into pure top-level comment-syntax mo…
kboshold Oct 19, 2026
f9dce9f
feat: add scanDirectives parser for template if/else/end directives
kboshold Oct 19, 2026
9d27e04
feat: add applyDirectives to strip directive lines and inactive branches
kboshold Oct 19, 2026
b2b8caa
feat(engine): evaluate directive conditions and apply them in compose
kboshold Oct 19, 2026
98f1382
test: use eslint config paths instead of casts for unevaluable condit…
kboshold Oct 19, 2026
7221a1a
feat(manifest): accept schemaVersion 5 and add directives gate constant
kboshold Oct 19, 2026
1d337d3
test: sort schema versions numerically
kboshold Oct 19, 2026
c4bc5fc
feat(engine): validate template directives when resolving the preset …
kboshold Oct 19, 2026
c54b6da
feat(render): indent continuation lines of multiline placeholder values
kboshold Oct 19, 2026
ce01e7a
fix(render): make nested multiline indent independent of placeholder …
kboshold Oct 19, 2026
ca239e1
feat(init): run the first sync with the scaffolded config
kboshold Oct 19, 2026
175ca49
fix(init): name the config file and keep the load-failure reason to o…
kboshold Oct 19, 2026
b373069
feat(manifest): export scanDirectives and commentSyntaxFor on the man…
kboshold Oct 19, 2026
7591ae6
docs: document template directives, indent-aware substitution and sch…
kboshold Oct 19, 2026
d5464a2
test: assert directive errors from the public manifest import
kboshold Oct 19, 2026
ed077d7
docs: correct directive error shapes and caveats in the reference
kboshold Oct 19, 2026
b43d0f8
feat(manifest): accept join: "comma" on placeholders, gated at schema 5
kboshold Oct 19, 2026
3f16657
fix(manifest): type directive error found/required as the values actu…
kboshold Oct 19, 2026
2f47786
test: narrow directive error details with a type guard instead of a cast
kboshold Oct 19, 2026
99778ad
feat(render): join string[] placeholders with ", " for join: "comma"
kboshold Oct 19, 2026
98343ca
docs(release): bump schemaVersion to 5 in versioning and note comma join
kboshold Oct 19, 2026
fcf8948
style(manifest): re-wrap placeholder join JSDoc
kboshold Oct 19, 2026
774073f
fix(render): only resolve placeholders reachable from the rendered text
kboshold Oct 19, 2026
3ce4b16
fix(render): substitute all tokens of a line against its original indent
kboshold Oct 19, 2026
cef3c56
fix(manifest): treat streamctl: comments as content below schemaVersi…
kboshold Oct 19, 2026
8c4778c
refactor(engine): drop the commentSyntaxFor re-export from block.ts
kboshold Oct 19, 2026
996803e
fix(render): indent content that follows a value ending in a newline
kboshold Oct 19, 2026
0039b2b
fix(compose): report fragment condition errors against the target path
kboshold Oct 19, 2026
50036d7
refactor(directives): drop unused line field from parsed directives
kboshold Oct 19, 2026
02fd39b
test(directives): normalize CRLF in payload fixture renders
kboshold Oct 19, 2026
07d738d
fix(directives): make line optional in DirectiveErrorDetails
kboshold Oct 19, 2026
a004cdc
docs: tighten substitution comment and directive docs
kboshold Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ When `sync` cannot proceed on a file, the conflict carries a `kind`, so the repo

Structured files (`.json*`, `.ya?ml`) get extra safety: composed output is parse-validated before writing, and the `block` strategy is forbidden on them. Details in [structured-file safety](docs/reference.md#structured-file-safety).

### Template directives

A payload template can mark whole lines as conditional on a config knob. Directive lines are comments in the file's own syntax, so the template stays valid YAML, Dockerfile, TS or Markdown:

```yaml
steps:
- run: pnpm typecheck
# streamctl:if ci.lint
- run: pnpm lint
# streamctl:else ci.lint
- run: echo "lint disabled"
# streamctl:end ci.lint
```

- `if`, optional `else`, and `end` all name the same condition; `!` negates it. No nesting.
- A condition is a config path declared in some preset's `configKeys`; `true`, a non-empty string or a non-empty array is true.
- Directive lines are always removed; the inactive branch is removed with them.
- `if`/`else`/`end` share one indentation; JSON files and `merge` files reject directives.
- Needs manifest `schemaVersion: 5`. Every mistake fails with `CONFIG_INVALID`, naming the file (and line).

Full rules in [template directives](docs/reference.md#template-directives).

## Configuration

`init` scaffolds `streamctl.config.ts` at the repo root, from the payload's own template. The payload exports a typed define function (via its `./config` subpath), so the knobs get full editor inference:
Expand Down
123 changes: 123 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,129 @@ Per-file `state` in `streamctl status` is one of:

The `--json` `data` is `{ files: { path, state, strategy }[], payload: { package, pinned, installed, latest? }, profile, cliVersion, lockfileStale }`.

## Template directives

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.

### Grammar

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.
- `else` and `end` repeat the `if` condition byte for byte, `!` included.
- Blocks do not nest. Empty bodies and empty `else` branches are allowed.
- A trailing `\r` is 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.

### Where directives work

| 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.

### Pipeline order

1. When the preset chain resolves, every template and fragment source is read and scanned, including files switched off by `files: off` or `enabledBy`. Syntax, placement, schema-version and condition-declaration errors fail every command here.
2. Per active file: the directive pass runs on the template body, then on each fragment body.
3. Fragments are appended (`toggle`, `forEach`).
4. 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.
5. 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.

### Condition truthiness

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, `false`, `""`, `[]` | false |
| number, object, `null` | `CONFIG_INVALID` (`unevaluable-value`) |

### Placement rules

- `if`, `else` and `end` use identical leading whitespace.
- In `.yaml`/`.yml` targets 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.

### Reserved namespace

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.

### Errors

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>`. `details` carries `file`, `line`, `reason` and, where relevant, `directive`, `condition`, `expected`, `openedAt`, `found`, `required`.
- `undeclared-condition` uses the message shared with placeholders and `enabledBy`: `template "presets/<source>" line N references config path "...", which no preset declares in configKeys.` (`fragment "..."` for fragments). `details` carries `path`, `declared`, `file`, `line`, `condition`, `reason`.
- `unevaluable-value` is raised at render time against the target path: `<target>: condition "..." has a <type> value in the config, ...`. `details` is `{ file, condition, reason, found }`, without `line`.

| `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`.

### Caveats

- `scaffold` files 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 a `RUN \` continuation must not hold the line that ends the continuation: dropping it leaves a dangling `\`.
- Inside a YAML block scalar (`run: |`) a `# streamctl:if` line 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 `full` file is a render change like any other: `check` reports drift (exit `3`), `status` shows the file as `conflict`, and `sync` blocks it as an `edit` conflict until you resolve it with `--interactive` or `--force` (see [Known caveats](#known-caveats)).

### Indent-aware substitution

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`.

```yaml
run: |
${SCRIPT}
```

With `SCRIPT` = `"pnpm lint\npnpm test"` this renders both commands at the `run` body's indentation.

### Placeholder join modes

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.

## Known caveats

**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.
Expand Down
32 changes: 27 additions & 5 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Two things worth knowing:
## Versioning

What couples the CLI to a payload is the manifest's `schemaVersion` (currently
`4`), not the package version. A CLI accepts a set of versions — `{2, 3, 4}` today —
`5`), not the package version. A CLI accepts a set of versions — `{2, 3, 4, 5}` today —
exported at the `./manifest` subpath as `SUPPORTED_SCHEMA_VERSION` (the current
one) plus `SUPPORTED_SCHEMA_VERSIONS` (the accepted set). Adding a version is a
minor when older ones keep parsing, **a major once one is dropped**; either way
Expand All @@ -70,8 +70,30 @@ anything that moves that envelope, the exit codes, or `schemaVersion`.

Generated notes only cover commit subjects, so add these by hand.

- **Manifest schemaVersion 4.** With placeholder `omitLineIfEmpty: true`, a line
holding only the token is removed when the value is `""` instead of left blank.
- v2 and v3 payloads parse unchanged. Using the key needs `schemaVersion: 4` and
this CLI or newer.
- **Manifest schemaVersion 5: template directives.** Templates and fragments can
wrap lines in `streamctl:if <cond>` / `streamctl:else <cond>` /
`streamctl:end <cond>` comments; inactive branches and all directive lines are
removed at render. See [template directives](reference.md#template-directives).
- **Placeholder `join: "comma"`** (schemaVersion 5): a `string[]` knob renders as
`a, b, c`, e.g. for YAML flow sequences like `branches: [${BRANCHES}]`. No
shell-metacharacter check in this mode; use the placeholder `pattern`. Gate
constant `COMMA_JOIN_MIN_SCHEMA_VERSION` is exported from `./manifest`.
- `scanDirectives`, `commentSyntaxFor` and `DIRECTIVES_MIN_SCHEMA_VERSION` are
exported from `@sidebase/streamctl/manifest`; `SUPPORTED_SCHEMA_VERSION` is now 5.
- Chain resolution now reads and scans every template and fragment source,
including files that are `off` or disabled by `enabledBy`. A payload missing a
template source therefore fails every command up front, not only when the file
is active.
- `init` loads the `streamctl.config.ts` it just scaffolded and runs the first sync
with it, so payload wrapper defaults apply right away. If the file cannot be
loaded, `init` prints a one-line warning and falls back to package, base,
version and profile only, as before.
- Indent-aware substitution: continuation lines of a multiline placeholder value
get the token line's indentation. Not gated; tokens at column 0 render as before.
- Placeholders are resolved and pattern-checked only when their token is reachable
from the rendered text, directly or through another placeholder's value. A
declared placeholder whose token is unused (or sits in a dropped directive block)
no longer fails the render.
- v2 to v4 payloads parse unchanged, including comments that start with
`streamctl:`; directives need `schemaVersion: 5` and this CLI or newer.
- Minor bump: new schema version, no removals.
43 changes: 43 additions & 0 deletions src/comment-syntax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { basename, extname } from "node:path";

// Pure comment-syntax map importing only `node:path`, so both `engine/*` and the
// public `manifest/*` subpath (directive parser) can use it without engine imports.

/** Marker comment delimiters for a file format. Line comments leave `suffix` empty. */
export interface CommentSyntax {
prefix: string;
suffix: string;
}

const HASH: CommentSyntax = { prefix: "# ", suffix: "" };
const SLASH: CommentSyntax = { prefix: "// ", suffix: "" };
const HTML: CommentSyntax = { prefix: "<!-- ", suffix: " -->" };

export function commentSyntaxFor(path: string): CommentSyntax {
const base = basename(path).toLowerCase();
if (base === "dockerfile") {
return HASH;
}
switch (extname(base)) {
case ".js":
case ".mjs":
case ".cjs":
case ".ts":
case ".mts":
case ".cts":
case ".json":
case ".jsonc":
case ".json5":
// JSONC-tolerant config files (oxlintrc/tsconfig) use `//`, never `#`.
return SLASH;
case ".html":
case ".htm":
case ".md":
case ".markdown":
case ".vue":
return HTML;
// `#` covers npmrc, gitignore, editorconfig, yaml/yml, toml, ini, conf, sh.
default:
return HASH;
}
}
2 changes: 2 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ManagedFile {
renderDef?: RenderDef;
/** Config boolean dot-path. Absent or `false` excludes the file, same as `files: off`. */
enabledBy?: string;
/** Set by `resolvePresetChain` below schemaVersion 5: template and fragments skip the directive pass. */
skipDirectives?: boolean;
/**
* Marks a file the payload is newly taking over, so a pre-existing differing
* `full` file is classified as an `adoption` conflict rather than an `edit`.
Expand Down
Loading
Loading