Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ steps:
```

- `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.
- A condition is a config path declared in some preset's `configKeys`; `true`, a non-empty string or a non-empty array is true. An unset path is false unless its `configKeys` entry has a [default](docs/reference.md#config-key-defaults).
- 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).
Expand Down
19 changes: 18 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,23 @@ 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 }`.

## Config key defaults

A `configKeys` entry is either the bare type or `{ "type": ..., "default": ... }`:

```json
"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, `enabledBy` and placeholders. A value the config sets wins, `false`, `""` and `[]` included.
- The default must match `type`. `object` keys take no default.
- A child preset overrides a parent's default; redeclaring the key with the bare type removes it.
- Needs manifest `schemaVersion: 5` and CLI 0.5.1 or newer. CLI 0.5.0 rejects the object form when loading the preset.
- `upgrade` renders with the defaults of the payload it installs. Defaults set in the payload's config wrapper (the function `streamctl.config.ts` calls) do not reach that run: the config is evaluated before the install, with the wrapper version that was installed before. Use `configKeys` defaults for anything the upgrade sync must already see.

## 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.
Expand Down Expand Up @@ -182,7 +199,7 @@ The condition must be declared in the merged `configKeys`, either exactly or und
| `true` | true |
| non-empty string | true |
| array with at least one element | true |
| absent, `false`, `""`, `[]` | false |
| absent (and no [default](#config-key-defaults)), `false`, `""`, `[]` | false |
| number, object, `null` | `CONFIG_INVALID` (`unevaluable-value`) |

### Placement rules
Expand Down
38 changes: 11 additions & 27 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,30 +70,14 @@ anything that moves that envelope, the exit codes, or `schemaVersion`.

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

- **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.
- **`configKeys` defaults** (schemaVersion 5): an entry can be
`{ "type": "boolean", "default": true }`. An unset config path then takes the
default in directive conditions, `enabledBy` and placeholders. `upgrade` already
renders with the defaults of the payload it installs. See
[config key defaults](reference.md#config-key-defaults).
- Payloads that use defaults need CLI 0.5.1 or newer in every consumer; 0.5.0
rejects the object form when loading the preset.
- Code that parses presets with the exported `presetManifestSchema` now sees
`configKeys` values that are either a type string or `{ type, default }`; read
the type with `typeof entry === "string" ? entry : entry.type`.
- Patch bump: no schema version change, no removals.
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SyncChange, SyncDecider, SyncPreview } from "../engine/sync";
import { defineCommand } from "citty";
import { loadStreamctlConfig } from "../config/load";
import { validateStreamctlConfigWithKeys } from "../config/validate";
import { applyConfigDefaults, validateStreamctlConfigWithKeys } from "../config/validate";
import { warnProfileMismatch } from "../engine/detect";
import { renderDiff } from "../engine/diff";
import { resolvePresetChain } from "../engine/manifest";
Expand Down Expand Up @@ -40,13 +40,13 @@ export const syncCommand = defineCommand({
rejectEmptyFlags(args);
const cwd = process.cwd();
const { config: loaded } = await loadStreamctlConfig(cwd, { logger: reporter });
const config = options.versionSync ? loaded : { ...loaded, versionSync: false };
const payload = await resolvePayload(cwd, config.package, config.version);
await warnProfileMismatch(cwd, payload, config.profile, reporter);
const { files: managedFiles, baseline, configKeys } = await resolvePresetChain(payload, config.base, config.profile);
const userConfig = options.versionSync ? loaded : { ...loaded, versionSync: false };
const payload = await resolvePayload(cwd, userConfig.package, userConfig.version);
await warnProfileMismatch(cwd, payload, userConfig.profile, reporter);
const { files: managedFiles, baseline, configKeys, configDefaults } = await resolvePresetChain(payload, userConfig.base, userConfig.profile);
// Stage 2: validate payload-declared config knobs against the merged
// configKeys; a payload without configKeys makes this a no-op.
validateStreamctlConfigWithKeys(config, configKeys);
const config = applyConfigDefaults(validateStreamctlConfigWithKeys(userConfig, configKeys), configDefaults);

// Prompt only on a real TTY; otherwise (CI / piped) degrade to a deterministic,
// greppable plan printed to stderr while applying the headless policy.
Expand Down
39 changes: 24 additions & 15 deletions src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ConfigKeyType } from "../manifest/schema";
import type { StreamctlConfig } from "./types";
import { z } from "zod";
import { StreamctlError } from "../errors";
import { matchesConfigKeyType } from "../manifest/schema";
import { RECONCILABLE_KEY_PATTERN } from "./types";

export interface ConfigIssue {
Expand Down Expand Up @@ -91,6 +92,28 @@ export function validateStreamctlConfig(input: unknown): StreamctlConfig {
return asValidatedConfig(input);
}

function withDefault(target: Record<string, unknown>, segments: string[], value: unknown): Record<string, unknown> {
const [head, ...tail] = segments;
if (head === undefined) {
return target;
}
const current = target[head];
if (tail.length === 0) {
return current === undefined ? { ...target, [head]: value } : target;
}
const child = isPlainObject(current) ? current : current === undefined ? {} : undefined;
return child === undefined ? target : { ...target, [head]: withDefault(child, tail, value) };
}

/** Fills each `configKeys` default whose path the config leaves unset; set values (`false`, `""`, `[]` included) win. */
export function applyConfigDefaults(config: StreamctlConfig, defaults: Record<string, unknown>): StreamctlConfig {
let result: Record<string, unknown> = { ...config };
for (const [path, value] of Object.entries(defaults)) {
result = withDefault(result, path.split("."), value);
}
return asValidatedConfig(result);
}

function describeType(value: unknown): string {
if (value === null) {
return "null";
Expand All @@ -101,20 +124,6 @@ function describeType(value: unknown): string {
return typeof value;
}

/** True when `value` satisfies the declared {@link ConfigKeyType} (object = shape-only). */
function matchesType(value: unknown, type: ConfigKeyType): boolean {
switch (type) {
case "boolean":
return typeof value === "boolean";
case "string":
return typeof value === "string";
case "string[]":
return Array.isArray(value) && value.every(entry => typeof entry === "string");
case "object":
return isPlainObject(value);
}
}

/** Levenshtein edit distance (iterative single-row; no dependency). */
function editDistance(a: string, b: string): number {
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
Expand Down Expand Up @@ -162,7 +171,7 @@ export function validateConfigKeys(rest: Record<string, unknown>, configKeys: Re

if (isLeaf(path)) {
const type = configKeys[path];
if (type !== undefined && !matchesType(value, type)) {
if (type !== undefined && !matchesConfigKeyType(value, type)) {
issues.push({ path, message: `expected ${type}, got ${describeType(value)}` });
}
continue; // declared leaf, object included; never recurse (shape-only)
Expand Down
10 changes: 5 additions & 5 deletions src/engine/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Logger } from "../logger";
import type { PayloadHandle } from "../payload/resolve";
import type { DriftKind } from "./drift";
import type { LatestVersionProbe } from "./versions";
import { validateStreamctlConfigWithKeys } from "../config/validate";
import { applyConfigDefaults, validateStreamctlConfigWithKeys } from "../config/validate";
import { StreamctlError } from "../errors";
import { stderrLogger } from "../logger";
import { warnProfileMismatch } from "./detect";
Expand Down Expand Up @@ -53,14 +53,14 @@ export interface RunCheckOptions {
export async function runCheck(
cwd: string,
payload: PayloadHandle,
config: StreamctlConfig,
userConfig: StreamctlConfig,
failOn: FailOn = "drift",
opts: RunCheckOptions = {},
): Promise<CheckResult> {
await warnProfileMismatch(cwd, payload, config.profile, opts.logger ?? stderrLogger);
await warnProfileMismatch(cwd, payload, userConfig.profile, opts.logger ?? stderrLogger);

const { files: managedFiles, baseline, configKeys } = await resolvePresetChain(payload, config.base, config.profile);
validateStreamctlConfigWithKeys(config, configKeys);
const { files: managedFiles, baseline, configKeys, configDefaults } = await resolvePresetChain(payload, userConfig.base, userConfig.profile);
const config = applyConfigDefaults(validateStreamctlConfigWithKeys(userConfig, configKeys), configDefaults);
const report = await detectDrift(managedFiles, payload, config, cwd);

const hasEslintConfig = managedFiles.some(file => file.path.endsWith("eslint.config.ts") && isFileActive(file, config));
Expand Down
8 changes: 4 additions & 4 deletions src/engine/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { loadStreamctlConfig } from "../config/load";
import { CONFIG_FILE, resolveConfigFile } from "../config/resolve";
import { validateStreamctlConfigWithKeys } from "../config/validate";
import { applyConfigDefaults, validateStreamctlConfigWithKeys } from "../config/validate";
import { StreamctlError } from "../errors";
import { stderrLogger } from "../logger";
import { resolvePayload } from "../payload/resolve";
Expand Down Expand Up @@ -453,13 +453,13 @@ export async function runInit(opts: RunInitOptions): Promise<InitResult> {
config = { package: opts.package, base, version, profile };
}
const payload = await getPayload();
const { files: managedFiles, baseline, configKeys } = await resolvePresetChain(payload, base, profile);
const { files: managedFiles, baseline, configKeys, configDefaults } = await resolvePresetChain(payload, base, profile);
// Stage 2 against the payload's configKeys.
validateStreamctlConfigWithKeys(config, configKeys);
const effective = applyConfigDefaults(validateStreamctlConfigWithKeys(config, configKeys), configDefaults);
const sync = await runSync({
cwd,
payload,
config,
config: effective,
managedFiles,
baseline,
// init just wrote `streamctl.config.ts` and bumped `package.json` devDeps, so the
Expand Down
29 changes: 25 additions & 4 deletions src/engine/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest
import type { PayloadHandle } from "../payload/resolve";
import { findDirectiveLine, scanDirectives } from "../directives";
import { StreamctlError } from "../errors";
import { COMMA_JOIN_MIN_SCHEMA_VERSION, DIRECTIVES_MIN_SCHEMA_VERSION, FROM_DEPENDENCY_MIN_SCHEMA_VERSION, OMIT_LINE_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema";
import { COMMA_JOIN_MIN_SCHEMA_VERSION, CONFIG_KEY_DEFAULTS_MIN_SCHEMA_VERSION, DIRECTIVES_MIN_SCHEMA_VERSION, FROM_DEPENDENCY_MIN_SCHEMA_VERSION, OMIT_LINE_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema";

const PAYLOAD_MANIFEST = "presets/manifest.json";

Expand Down Expand Up @@ -140,6 +140,17 @@ export async function loadPresetManifest(payload: PayloadHandle, name: string, m
}
}
}
if (manifest.schemaVersion < CONFIG_KEY_DEFAULTS_MIN_SCHEMA_VERSION) {
for (const [key, entry] of Object.entries(preset.configKeys ?? {})) {
if (typeof entry !== "string") {
throw new StreamctlError(
"CONFIG_INVALID",
`${file} configKeys "${key}" has a default, which requires schemaVersion ${CONFIG_KEY_DEFAULTS_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${manifest.schemaVersion}.`,
{ file, preset: name, configKey: key, found: manifest.schemaVersion, required: CONFIG_KEY_DEFAULTS_MIN_SCHEMA_VERSION },
);
}
}
}
return preset;
}

Expand Down Expand Up @@ -258,7 +269,7 @@ export async function resolvePresetChain(
payload: PayloadHandle,
base: string,
profile: string,
): Promise<{ files: EngineManagedFile[]; baseline: Record<string, string>; configKeys: Record<string, ConfigKeyType> }> {
): Promise<{ files: EngineManagedFile[]; baseline: Record<string, string>; configKeys: Record<string, ConfigKeyType>; configDefaults: Record<string, unknown> }> {
const manifest = await loadPayloadManifest(payload);

if (!manifest.presets.includes(base)) {
Expand Down Expand Up @@ -287,6 +298,7 @@ export async function resolvePresetChain(
const renders = new Map<string, RenderDef>();
const renderKeyByPath = new Map<string, string>();
const configKeys: Record<string, ConfigKeyType> = {};
const configDefaults: Record<string, unknown> = {};
const baseline: Record<string, string> = {};
const renderRefs: { file: string; render: string }[] = [];
const configPathRefs: ConfigPathRef[] = [];
Expand All @@ -311,7 +323,16 @@ export async function resolvePresetChain(
configPathRefs.push({ where: `renders.${key} fragment`, path: "toggle" in fragment ? fragment.toggle : fragment.forEach });
}
}
Object.assign(configKeys, preset.configKeys ?? {}); // child overrides parent (parents visited first)
// Child overrides parent (parents visited first); redeclaring without a default drops the parent's.
for (const [key, entry] of Object.entries(preset.configKeys ?? {})) {
delete configDefaults[key];
if (typeof entry === "string") {
configKeys[key] = entry;
} else {
configKeys[key] = entry.type;
configDefaults[key] = entry.default;
}
}
for (const file of preset.files) {
byPath.set(file.path, toEngineFile(file));
// Track the render key per path so a child override that drops render clears
Expand Down Expand Up @@ -392,5 +413,5 @@ export async function resolvePresetChain(
}
}

return { files, baseline, configKeys };
return { files, baseline, configKeys, configDefaults };
}
8 changes: 4 additions & 4 deletions src/engine/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Logger } from "../logger";
import type { PayloadHandle } from "../payload/resolve";
import type { LatestVersionProbe } from "./versions";
import { join } from "node:path";
import { validateStreamctlConfigWithKeys } from "../config/validate";
import { applyConfigDefaults, validateStreamctlConfigWithKeys } from "../config/validate";
import { stderrLogger } from "../logger";
import { compose } from "./compose";
import { contentEquals } from "./jsonc";
Expand Down Expand Up @@ -95,13 +95,13 @@ async function fileState(file: ManagedFile, payload: PayloadHandle, config: Stre
export async function runStatus(
cwd: string,
payload: PayloadHandle,
config: StreamctlConfig,
userConfig: StreamctlConfig,
opts: RunStatusOptions,
): Promise<StatusResult> {
const logger = opts.logger ?? stderrLogger;
const { files: managedFiles, baseline, configKeys } = await resolvePresetChain(payload, config.base, config.profile);
const { files: managedFiles, baseline, configKeys, configDefaults } = await resolvePresetChain(payload, userConfig.base, userConfig.profile);
// Stage 2: a malformed config here is a hard exit-1.
validateStreamctlConfigWithKeys(config, configKeys);
const config = applyConfigDefaults(validateStreamctlConfigWithKeys(userConfig, configKeys), configDefaults);

// Read once per run, so `status` composes the same bytes `sync`/`check` do.
const deps = dependencyMap(await readPackageJson(cwd));
Expand Down
Loading
Loading