diff --git a/README.md b/README.md index ba1d7a4..4053bfe 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/reference.md b/docs/reference.md index b288ca1..23b9f40 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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. @@ -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 diff --git a/docs/release.md b/docs/release.md index dbe9e60..bd4d019 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 ` / `streamctl:else ` / - `streamctl:end ` 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 418ecfe..0d1f773 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,8 @@ importers: specifier: ^5.0.3 version: 5.0.3(@types/node@26.6.4)(vite@7.3.6(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + test/fixtures/directives-payload: {} + test/fixtures/synthetic-payload: {} test/fixtures/uninitialized: {} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 85c5444..2a85747 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -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"; @@ -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. diff --git a/src/config/validate.ts b/src/config/validate.ts index e24777d..26e3076 100644 --- a/src/config/validate.ts +++ b/src/config/validate.ts @@ -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 { @@ -91,6 +92,28 @@ export function validateStreamctlConfig(input: unknown): StreamctlConfig { return asValidatedConfig(input); } +function withDefault(target: Record, segments: string[], value: unknown): Record { + 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): StreamctlConfig { + let result: Record = { ...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"; @@ -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); @@ -162,7 +171,7 @@ export function validateConfigKeys(rest: Record, 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) diff --git a/src/engine/check.ts b/src/engine/check.ts index b7967d2..69b9316 100644 --- a/src/engine/check.ts +++ b/src/engine/check.ts @@ -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"; @@ -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 { - 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)); diff --git a/src/engine/init.ts b/src/engine/init.ts index 9574335..ab31b93 100644 --- a/src/engine/init.ts +++ b/src/engine/init.ts @@ -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"; @@ -453,13 +453,13 @@ export async function runInit(opts: RunInitOptions): Promise { 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 diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index 39cbea8..ba8089a 100644 --- a/src/engine/manifest.ts +++ b/src/engine/manifest.ts @@ -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"; @@ -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; } @@ -258,7 +269,7 @@ export async function resolvePresetChain( payload: PayloadHandle, base: string, profile: string, -): Promise<{ files: EngineManagedFile[]; baseline: Record; configKeys: Record }> { +): Promise<{ files: EngineManagedFile[]; baseline: Record; configKeys: Record; configDefaults: Record }> { const manifest = await loadPayloadManifest(payload); if (!manifest.presets.includes(base)) { @@ -287,6 +298,7 @@ export async function resolvePresetChain( const renders = new Map(); const renderKeyByPath = new Map(); const configKeys: Record = {}; + const configDefaults: Record = {}; const baseline: Record = {}; const renderRefs: { file: string; render: string }[] = []; const configPathRefs: ConfigPathRef[] = []; @@ -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 @@ -392,5 +413,5 @@ export async function resolvePresetChain( } } - return { files, baseline, configKeys }; + return { files, baseline, configKeys, configDefaults }; } diff --git a/src/engine/status.ts b/src/engine/status.ts index de70a43..4d0fdfd 100644 --- a/src/engine/status.ts +++ b/src/engine/status.ts @@ -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"; @@ -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 { 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)); diff --git a/src/engine/upgrade.ts b/src/engine/upgrade.ts index 09421b5..0cc84f2 100644 --- a/src/engine/upgrade.ts +++ b/src/engine/upgrade.ts @@ -9,7 +9,7 @@ import type { LatestVersionProbe, VersionExistsProbe } from "./versions"; import { rm } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { loadStreamctlConfig } from "../config/load"; -import { validateStreamctlConfigWithKeys } from "../config/validate"; +import { applyConfigDefaults, validateStreamctlConfigWithKeys } from "../config/validate"; import { StreamctlError } from "../errors"; import { resolvePayload } from "../payload/resolve"; import { dirtyTrackedPaths } from "./git"; @@ -304,8 +304,8 @@ async function validateConfigKnobs(opts: RunUpgradeOptions, config: StreamctlCon async function previewSync(opts: RunUpgradeOptions, config: StreamctlConfig, onPreview: SyncPreview): Promise { try { const payload = await resolvePayload(opts.cwd, config.package, config.version, { resolvePackageDir: opts.resolvePackageDir }); - const { files: managedFiles, baseline } = await resolvePresetChain(payload, config.base, config.profile); - return await runSync({ cwd: opts.cwd, payload, config, managedFiles, baseline, dryRun: true, onPreview, logger: opts.logger }); + const { files: managedFiles, baseline, configDefaults } = await resolvePresetChain(payload, config.base, config.profile); + return await runSync({ cwd: opts.cwd, payload, config: applyConfigDefaults(config, configDefaults), managedFiles, baseline, dryRun: true, onPreview, logger: opts.logger }); } catch (error) { if (error instanceof StreamctlError && (error.code === "CONFIG_VERSION_MISMATCH" || error.code === "CONFIG_PKG_MISSING")) { return null; @@ -508,11 +508,11 @@ export async function runUpgrade(opts: RunUpgradeOptions): Promise typeof entry === "string"); + case "object": + return typeof value === "object" && value !== null && !Array.isArray(value); + } +} + +/** + * A `configKeys` entry: the bare type, or `{ type, default }`. The default fills the + * value when the consumer config leaves it unset; `object` keys take none. + */ +export const configKeySchema = z.union([ + z.enum(CONFIG_KEY_TYPES), + z.strictObject({ type: z.enum(CONFIG_KEY_TYPES), default: z.unknown() }).superRefine((entry, ctx) => { + if (entry.type === "object") { + ctx.addIssue({ code: "custom", path: ["default"], message: "object keys cannot have a default" }); + } else if (!matchesConfigKeyType(entry.default, entry.type)) { + ctx.addIssue({ code: "custom", path: ["default"], message: `must be a ${entry.type}` }); + } + }), +]); +export type ConfigKeyEntry = z.infer; + /** * A `string[]` value is deduped + sorted, then joined per `join` (`space`, `lines` or * `comma`; `comma` requires `schemaVersion` {@link COMMA_JOIN_MIN_SCHEMA_VERSION}). @@ -189,7 +222,7 @@ export const presetManifestSchema = z.strictObject({ // "Invalid key in record" message would hide the actual pattern. versionProfiles: z.record(z.string().min(1), z.record(z.string().min(1), z.string())).optional(), renders: z.record(z.string().min(1), renderDefSchema).optional(), - configKeys: z.record(z.string().min(1), z.enum(CONFIG_KEY_TYPES)).optional(), + configKeys: z.record(z.string().min(1), configKeySchema).optional(), }).superRefine((preset, ctx) => { if (preset.files.length === 0 && (preset.extends === undefined || preset.extends.length === 0)) { ctx.addIssue({ code: "custom", path: ["files"], message: "must be non-empty unless the preset extends a parent" }); diff --git a/test/check.test.ts b/test/check.test.ts index 642e531..05f5604 100644 --- a/test/check.test.ts +++ b/test/check.test.ts @@ -214,3 +214,40 @@ describe("runCheck lockfileStale flag", () => { expect(details.lockfileStale).toBeFalsy(); }); }); + +/** A v5 payload whose CI template gates a line on `features.prisma`, default on. */ +const defaultsPayload: PayloadHandle = { + version: "2.0.0", + async read(source) { + const entries: Record = { + "manifest.json": JSON.stringify({ schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" }), + "base/preset.json": JSON.stringify({ + name: "base", + files: [{ path: "ci.yaml", strategy: "full", source: "base/ci.yaml" }], + configKeys: { "features.prisma": { type: "boolean", default: true } }, + }), + "base/ci.yaml": "steps:\n # streamctl:if features.prisma\n - run: prisma generate\n # streamctl:end features.prisma\n - run: lint\n", + }; + const content = entries[source]; + if (content === undefined) { + throw new Error(`missing fixture source: ${source}`); + } + return content; + }, + async list() { + return ["manifest.json", "base/preset.json", "base/ci.yaml"]; + }, +}; + +describe("runCheck configKeys defaults", () => { + it("renders a block gated on a defaulted key the config leaves unset", async () => { + await writeFile(join(cwd, "ci.yaml"), "steps:\n - run: prisma generate\n - run: lint\n"); + expect((await runCheck(cwd, defaultsPayload, config)).inSync).toBe(true); + }); + + it("lets the config switch the default off", async () => { + await writeFile(join(cwd, "ci.yaml"), "steps:\n - run: lint\n"); + const off = { ...config, features: { prisma: false } }; + expect((await runCheck(cwd, defaultsPayload, off)).inSync).toBe(true); + }); +}); diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 5d6a8a7..d1d3a2f 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -579,3 +579,36 @@ describe("chain-time directive validation", () => { expect((await caught(resolvePresetChain(payload, "base", "nuxt-4"))).details).toMatchObject({ reason: "unopened-end" }); }); }); + +describe("configKeys defaults", () => { + function withDefaults(schemaVersion: number, child: Record = {}): PayloadHandle { + return payloadOf({ + "manifest.json": { schemaVersion, presets: ["base", "app"], profiles: [], defaultBase: "app" }, + "base/preset.json": { + name: "base", + files: [{ path: ".editorconfig", strategy: "full", source: "base/editorconfig" }], + configKeys: { "features.prisma": { type: "boolean", default: true }, "features.redis": { type: "boolean", default: false } }, + }, + "app/preset.json": { name: "app", extends: ["base"], files: [], configKeys: child }, + }); + } + + it("returns the merged defaults next to the plain key types", async () => { + const { configKeys, configDefaults } = await resolvePresetChain(withDefaults(5), "app", "std"); + expect(configKeys).toMatchObject({ "features.prisma": "boolean", "features.redis": "boolean" }); + expect(configDefaults).toEqual({ "features.prisma": true, "features.redis": false }); + }); + + it("lets a child override a default or drop it by redeclaring the bare type", async () => { + const child = { "features.prisma": { type: "boolean", default: false }, "features.redis": "boolean" }; + const { configDefaults } = await resolvePresetChain(withDefaults(5, child), "app", "std"); + expect(configDefaults).toEqual({ "features.prisma": false }); + }); + + it("refuses a default under a v4 payload", async () => { + const error = await caught(resolvePresetChain(withDefaults(4), "app", "std")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toContain(`configKeys "features.prisma" has a default, which requires schemaVersion 5`); + expect(error.details).toMatchObject({ file: "presets/base/preset.json", configKey: "features.prisma", found: 4, required: 5 }); + }); +}); diff --git a/test/manifest-schema.test.ts b/test/manifest-schema.test.ts index a8f7ef5..5fac7df 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -284,6 +284,20 @@ describe("PresetManifest", () => { expect(presetManifestSchema.safeParse({ ...preset(), configKeys: { eslint: "number" } }).success).toBe(false); }); + it("accepts a configKeys entry with a matching default", () => { + const configKeys = { "features.prisma": { type: "boolean", default: true }, "ci.branches": { type: "string[]", default: ["main"] } }; + expect(presetManifestSchema.safeParse({ ...preset(), configKeys }).success).toBe(true); + }); + + it.each([ + [{ type: "boolean", default: "yes" }], + [{ type: "string[]", default: [1] }], + [{ type: "object", default: {} }], + [{ type: "boolean" }], + ])("rejects the configKeys entry %j", (entry) => { + expect(presetManifestSchema.safeParse({ ...preset(), configKeys: { key: entry } }).success).toBe(false); + }); + it("rejects the old v1 shape with its packages/description keys", () => { expect(presetManifestSchema.safeParse({ ...preset(), packages: ["@x/y"], description: "d" }).success).toBe(false); }); diff --git a/test/upgrade.test.ts b/test/upgrade.test.ts index 48c25b2..a10b7d7 100644 --- a/test/upgrade.test.ts +++ b/test/upgrade.test.ts @@ -1117,3 +1117,27 @@ describe("runUpgrade: legacy config location", () => { expect(details.recover).toContain(legacyRel); }); }); + +describe("runUpgrade: configKeys defaults", () => { + // The target payload introduces a block gated on a defaulted key that no repo config sets yet. + beforeEach(async () => { + await writeFile(join(configPkg, "presets", "manifest.json"), JSON.stringify({ schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" })); + await writeFile(join(configPkg, "presets", "base", "preset.json"), JSON.stringify({ + name: "base", + files: [{ path: "ci.yaml", strategy: "full", source: "base/ci.yaml" }], + configKeys: { "features.prisma": { type: "boolean", default: true } }, + })); + await writeFile(join(configPkg, "presets", "base", "ci.yaml"), "steps:\n # streamctl:if features.prisma\n - run: prisma generate\n # streamctl:end features.prisma\n"); + }); + + it("renders the defaulted block on the upgrade run itself", async () => { + await runUpgrade(baseOpts()); + expect(await readFile(join(repo, "ci.yaml"), "utf8")).toBe("steps:\n - run: prisma generate\n"); + }); + + it("previews the defaulted block on --dry-run", async () => { + const proposed = new Map(); + await runUpgrade(baseOpts({ dryRun: true, onPreview: change => proposed.set(change.path, change.after) })); + expect(proposed.get("ci.yaml")).toBe("steps:\n - run: prisma generate\n"); + }); +}); diff --git a/test/validate.test.ts b/test/validate.test.ts index b4df9f4..28f3202 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -1,6 +1,6 @@ import type { ConfigKeyType } from "../src/manifest/schema"; import { describe, expect, it } from "vitest"; -import { validateConfigKeys, validateStreamctlConfig, validateStreamctlConfigWithKeys } from "../src/config/validate"; +import { applyConfigDefaults, validateConfigKeys, validateStreamctlConfig, validateStreamctlConfigWithKeys } from "../src/config/validate"; import { StreamctlError } from "../src/errors"; const base = { package: "@acme/payload", base: "nuxt-app", version: "1.0.0", profile: "nuxt-4" } as const; @@ -186,3 +186,22 @@ describe("validateStreamctlConfigWithKeys (combined stages)", () => { expect(config.profile).toBe("nuxt-4"); }); }); + +describe("applyConfigDefaults", () => { + const defaults = { "features.prisma": true, "ci.branches": ["main"] }; + + it("fills unset paths, creating parent objects", () => { + expect(applyConfigDefaults({ ...base }, defaults)).toEqual({ ...base, features: { prisma: true }, ci: { branches: ["main"] } }); + }); + + it("keeps values the config sets, false and [] included", () => { + const config = { ...base, features: { prisma: false }, ci: { branches: [] } }; + expect(applyConfigDefaults(config, defaults)).toEqual(config); + }); + + it("does not mutate the input", () => { + const config = { ...base, ci: { e2e: true } }; + applyConfigDefaults(config, defaults); + expect(config).toEqual({ ...base, ci: { e2e: true } }); + }); +});