diff --git a/docs/release.md b/docs/release.md index 7f0f67d..8c61dba 100644 --- a/docs/release.md +++ b/docs/release.md @@ -51,7 +51,7 @@ Two things worth knowing: ## Versioning What couples the CLI to a payload is the manifest's `schemaVersion` (currently -`3`), not the package version. A CLI accepts a set of versions — `{2, 3}` today — +`4`), not the package version. A CLI accepts a set of versions — `{2, 3, 4}` 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 @@ -70,22 +70,8 @@ anything that moves that envelope, the exit codes, or `schemaVersion`. Generated notes only cover commit subjects, so add these by hand. -- **The default config location moved** to `streamctl.config.ts` in the repo - root. `init` writes it there. -- **`.streamctl/config.*` keeps working, permanently.** Not deprecated, no - warning, no removal planned. Existing repos need to do nothing. -- **One break:** `.config/.streamctl/config.ts` used to resolve through c12's - `.config/` convention and now raises `NOT_INITIALIZED`. Fix: - - ```sh - git mv .config/.streamctl/config.ts streamctl.config.ts - ``` - - Nothing else under `.config/` was ever read. Realistically nobody is affected: - the layout is undocumented, and it only ever worked on trees resolving - c12 >= 3.2.0. -- **New warning:** two extensions at one location (`streamctl.config.js` beside - `streamctl.config.ts`) now warn on stderr which one is being read, since c12 - orders `.js` ahead of `.ts`. Nothing is read differently than before, so a repo - that sees it needs no migration. -- Minor bump: new default, no removals. +- **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. +- Minor bump: new schema version, no removals. diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index 7e45898..b2502dc 100644 --- a/src/engine/manifest.ts +++ b/src/engine/manifest.ts @@ -3,7 +3,7 @@ import type { ManagedFile as EngineManagedFile } from "../config/types"; import type { ConfigKeyType, ManagedFile as ManifestManagedFile, PayloadManifest, PresetManifest, RenderDef } from "../manifest/schema"; import type { PayloadHandle } from "../payload/resolve"; import { StreamctlError } from "../errors"; -import { FROM_DEPENDENCY_MIN_SCHEMA_VERSION, payloadManifestSchema, presetManifestSchema, SUPPORTED_SCHEMA_VERSION_LIST, SUPPORTED_SCHEMA_VERSIONS, zodToIssues } from "../manifest/schema"; +import { 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"; @@ -102,17 +102,24 @@ export async function loadPresetManifest(payload: PayloadHandle, name: string, m } } - // Feature gate. zod strictness only stops `fromDependency` on an OLD CLI; without this - // check a payload author who forgets the schemaVersion bump ships a payload this CLI - // accepts and every older one rejects with a raw zod wall. - if (manifest.schemaVersion < FROM_DEPENDENCY_MIN_SCHEMA_VERSION) { + // Feature gates. zod strictness only stops a new key on an OLD CLI; without this check a + // payload author who forgets the schemaVersion bump ships a payload this CLI accepts and + // every older one rejects with a raw zod wall. + const gates = [ + { feature: "fromDependency", required: FROM_DEPENDENCY_MIN_SCHEMA_VERSION }, + { feature: "omitLineIfEmpty", required: OMIT_LINE_MIN_SCHEMA_VERSION }, + ] as const; + for (const { feature, required } of gates) { + if (manifest.schemaVersion >= required) { + continue; + } for (const [render, def] of Object.entries(preset.renders ?? {})) { for (const [key, placeholder] of Object.entries(def.placeholders ?? {})) { - if (placeholder.fromDependency !== undefined) { + if (placeholder[feature] !== undefined) { throw new StreamctlError( "CONFIG_INVALID", - `${file} renders.${render} placeholder "${key}" uses fromDependency, which requires schemaVersion ${FROM_DEPENDENCY_MIN_SCHEMA_VERSION}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${manifest.schemaVersion}.`, - { file, preset: name, render, placeholder: key, found: manifest.schemaVersion, required: FROM_DEPENDENCY_MIN_SCHEMA_VERSION }, + `${file} renders.${render} placeholder "${key}" uses ${feature}, which requires schemaVersion ${required}, but ${PAYLOAD_MANIFEST} declares schemaVersion ${manifest.schemaVersion}.`, + { file, preset: name, render, placeholder: key, found: manifest.schemaVersion, required }, ); } } diff --git a/src/engine/render.ts b/src/engine/render.ts index 7f08465..0dc72e5 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -145,6 +145,17 @@ function unresolvedTokens(text: string, values: ReadonlyMap, pas return names.sort().map(name => `\${${name}}`); } +/** Removes every line holding at least one of `tokens` and nothing else but spaces, tabs and a CR. */ +function dropTokenLines(text: string, tokens: readonly string[]): string { + return text + .split("\n") + .filter((line) => { + const rest = tokens.reduce((acc, token) => acc.replaceAll(token, ""), line); + return rest === line || /[^ \t\r]/.test(rest); + }) + .join("\n"); +} + /** * Assemble fragments in declared order (`toggle` includes when true, `forEach` * repeats per `string[]` item as `${ITEM}`), then substitute `${KEY}` placeholders @@ -195,6 +206,7 @@ export function renderFile( // Values are resolved (and pattern-checked) once, ahead of substitution: they come from // the config and the dependency map, never from the output being assembled. const values = new Map(); + const omitTokens: string[] = []; for (const [key, def] of Object.entries(renderDef.placeholders ?? {})) { const value = resolvePlaceholder(def, config, key, filePath, deps); if (def.pattern !== undefined && !new RegExp(def.pattern).test(value)) { @@ -204,6 +216,10 @@ export function renderFile( { file: filePath, placeholder: key, value }, ); } + if (def.omitLineIfEmpty === true && value === "") { + omitTokens.push(`\${${key}}`); + continue; + } values.set(key, value); } @@ -235,6 +251,9 @@ export function renderFile( } } + // Last, so "alone on its line" is judged on the rendered text. + output = omitTokens.reduce((acc, token) => acc.replaceAll(token, ""), dropTokenLines(output, omitTokens)); + for (const match of output.matchAll(LEFTOVER_TOKEN_RE)) { const token = match[0]; const name = match[1] ?? ""; diff --git a/src/manifest/schema.ts b/src/manifest/schema.ts index 61c192c..7cd9b3c 100644 --- a/src/manifest/schema.ts +++ b/src/manifest/schema.ts @@ -18,13 +18,14 @@ const LEGACY_CONFIG_DIR = ".streamctl"; * name because base-config's `scripts/validate-presets.mjs` imports it from * `@sidebase/streamctl/manifest`. Manifest and package versions are otherwise independent. */ -export const SUPPORTED_SCHEMA_VERSION = 3; +export const SUPPORTED_SCHEMA_VERSION = 4; /** * Every version this CLI accepts; anything else maps to `SCHEMA_UNSUPPORTED`. v2 payloads - * keep working unchanged, v3 additionally allows placeholder `fromDependency`. + * keep working unchanged, v3 additionally allows placeholder `fromDependency`, v4 + * placeholder `omitLineIfEmpty`. */ -export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet = new Set([2, SUPPORTED_SCHEMA_VERSION]); +export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet = new Set([2, 3, SUPPORTED_SCHEMA_VERSION]); /** Sorted rendering of {@link SUPPORTED_SCHEMA_VERSIONS} for error messages. */ export const SUPPORTED_SCHEMA_VERSION_LIST = [...SUPPORTED_SCHEMA_VERSIONS].sort((a, b) => a - b).join(", "); @@ -32,6 +33,9 @@ export const SUPPORTED_SCHEMA_VERSION_LIST = [...SUPPORTED_SCHEMA_VERSIONS].sort /** Lowest `schemaVersion` a payload may declare while using placeholder `fromDependency`. */ export const FROM_DEPENDENCY_MIN_SCHEMA_VERSION = 3; +/** Lowest `schemaVersion` a payload may declare while using placeholder `omitLineIfEmpty`. */ +export const OMIT_LINE_MIN_SCHEMA_VERSION = 4; + /** The CLI checks shape only; semantics stay payload-owned. `"object"` is a presence + `typeof` check with no deep validation. */ export const CONFIG_KEY_TYPES = ["boolean", "string", "string[]", "object"] as const; export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; @@ -44,6 +48,10 @@ export type ConfigKeyType = (typeof CONFIG_KEY_TYPES)[number]; * `fromDependency` names an npm package whose pin in the consumer's package.json supplies * the value (stripped to its range floor) when `configPath` yields nothing. Requires payload * `schemaVersion` {@link FROM_DEPENDENCY_MIN_SCHEMA_VERSION}, enforced in `loadPresetManifest`. + * + * `omitLineIfEmpty`: when the value is `""`, a line holding only such tokens (plus + * spaces/tabs) after all other placeholders are substituted is removed; any other occurrence + * becomes `""`. Requires `schemaVersion` {@link OMIT_LINE_MIN_SCHEMA_VERSION}. */ export const placeholderSchema = z.strictObject({ configPath: z.string().min(1), @@ -51,6 +59,7 @@ export const placeholderSchema = z.strictObject({ default: z.string(), pattern: z.string().min(1).optional(), join: z.enum(["space", "lines"]).optional(), + omitLineIfEmpty: z.boolean().optional(), }); /** diff --git a/test/manifest-loader.test.ts b/test/manifest-loader.test.ts index 91c5a5e..4b5d060 100644 --- a/test/manifest-loader.test.ts +++ b/test/manifest-loader.test.ts @@ -89,17 +89,17 @@ describe("schemaVersion contract", () => { // Checked ahead of zod so the user sees both version numbers instead of a refinement issue. it("names the payload's version and ours when they disagree", async () => { - const payload = payloadOf({ "manifest.json": { schemaVersion: 4, presets: ["base"], profiles: [], defaultBase: "base" } }); + const payload = payloadOf({ "manifest.json": { schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(loadPayloadManifest(payload)); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); - expect(error.message).toContain("schemaVersion 4"); - expect(error.message).toContain("schemaVersion 2, 3"); + expect(error.message).toContain("schemaVersion 5"); + expect(error.message).toContain("schemaVersion 2, 3, 4"); expect(error.message).toContain("Upgrade the CLI or the payload"); - expect(error.details).toMatchObject({ found: 4, supported: [2, 3] }); + expect(error.details).toMatchObject({ found: 5, supported: [2, 3, 4] }); }); it("surfaces through resolvePresetChain too", async () => { - const payload = payloadOf({ "manifest.json": { schemaVersion: 4, presets: ["base"], profiles: [], defaultBase: "base" } }); + const payload = payloadOf({ "manifest.json": { schemaVersion: 5, presets: ["base"], profiles: [], defaultBase: "base" } }); const error = await caught(resolvePresetChain(payload, "base", "nuxt-4")); expect(error.code).toBe("SCHEMA_UNSUPPORTED"); }); @@ -149,6 +149,32 @@ describe("fromDependency feature gate", () => { }); }); +describe("omitLineIfEmpty feature gate", () => { + function withOmitLine(schemaVersion: number): PayloadHandle { + return payloadOf({ + "manifest.json": { schemaVersion, presets: ["base"], profiles: [], defaultBase: "base" }, + "base/preset.json": { + name: "base", + files: [{ path: "Dockerfile", strategy: "full", source: "Dockerfile", render: "dockerfile" }], + renders: { dockerfile: { placeholders: { HOOK: { configPath: "docker.hook", default: "", omitLineIfEmpty: true } } } }, + configKeys: { "docker.hook": "string" }, + }, + }); + } + + it("lets a v4 payload declare it", async () => { + const { files } = await resolvePresetChain(withOmitLine(4), "base", "nuxt-4"); + expect(files.find(f => f.path === "Dockerfile")?.renderDef?.placeholders?.HOOK?.omitLineIfEmpty).toBe(true); + }); + + it("refuses it under a v3 payload, naming the placeholder and the version it needs", async () => { + const error = await caught(resolvePresetChain(withOmitLine(3), "base", "nuxt-4")); + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.message).toContain("\"HOOK\" uses omitLineIfEmpty, which requires schemaVersion 4"); + expect(error.details).toMatchObject({ file: "presets/base/preset.json", render: "dockerfile", placeholder: "HOOK", found: 3, required: 4 }); + }); +}); + describe("resolvePresetChain (v2 payload)", () => { it("walks the extends chain parents-first and merges the baseline", async () => { const { files, baseline } = await resolvePresetChain(validPayload(), "nuxt-app", "nuxt-4"); diff --git a/test/manifest-schema.test.ts b/test/manifest-schema.test.ts index 9bada7c..1d7d766 100644 --- a/test/manifest-schema.test.ts +++ b/test/manifest-schema.test.ts @@ -292,14 +292,14 @@ describe("PayloadManifest schemaVersion contract", () => { // The exported constant is base-config's `validate-presets.mjs` contract: it writes this // value into new payloads, so it has to be the current one, not just a supported one. it("names the current version, which is supported", () => { - expect(SUPPORTED_SCHEMA_VERSION).toBe(3); + expect(SUPPORTED_SCHEMA_VERSION).toBe(4); expect(SUPPORTED_SCHEMA_VERSIONS.has(SUPPORTED_SCHEMA_VERSION)).toBe(true); }); // Plain refinement failure here; the loader is what turns it into SCHEMA_UNSUPPORTED. it("rejects anything older or newer", () => { expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 1 }).success).toBe(false); - expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 4 }).success).toBe(false); + expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 5 }).success).toBe(false); expect(payloadManifestSchema.safeParse({ ...payload(), schemaVersion: 2.5 }).success).toBe(false); }); diff --git a/test/render-generic.test.ts b/test/render-generic.test.ts index d1d3de1..75024a7 100644 --- a/test/render-generic.test.ts +++ b/test/render-generic.test.ts @@ -249,6 +249,47 @@ describe("renderFile: nested placeholder tokens", () => { }); }); +describe("renderFile: omitLineIfEmpty", () => { + const def = (omitLineIfEmpty?: boolean): RenderDef => ({ placeholders: { HOOK: { configPath: "docker.hook", default: "", omitLineIfEmpty } } }); + const source = "FROM a\n${HOOK}\n\nCOPY b\n"; + + it("drops the whole line when the value is empty", () => { + expect(renderFile(source, def(true), cfg(), {})).toBe("FROM a\n\nCOPY b\n"); + }); + + it("keeps the line when the value is set", () => { + expect(renderFile(source, def(true), cfg({ docker: { hook: "RUN x" } }), {})).toBe("FROM a\nRUN x\n\nCOPY b\n"); + }); + + it("leaves an empty line without the flag, as before", () => { + expect(renderFile(source, def(), cfg(), {})).toBe("FROM a\n\n\nCOPY b\n"); + }); + + it("drops an indented token line, CRLF included", () => { + expect(renderFile("a:\n ${HOOK} \nb\n", def(true), cfg(), {})).toBe("a:\nb\n"); + expect(renderFile("a\r\n\t${HOOK}\r\nb\r\n", def(true), cfg(), {})).toBe("a\r\nb\r\n"); + }); + + it("substitutes a token that shares its line with other text", () => { + expect(renderFile("RUN a ${HOOK}\n", def(true), cfg(), {})).toBe("RUN a \n"); + }); + + it("drops a line of several flagged tokens, in any order", () => { + const a = { configPath: "docker.a", default: "", omitLineIfEmpty: true }; + const b = { configPath: "docker.b", default: "", omitLineIfEmpty: true }; + const source = "before\n${A}${B}${A}\nafter\n"; + expect(renderFile(source, { placeholders: { A: a, B: b } }, cfg(), {})).toBe("before\nafter\n"); + expect(renderFile(source, { placeholders: { B: b, A: a } }, cfg(), {})).toBe("before\nafter\n"); + }); + + it("does not depend on placeholder declaration order", () => { + const prefix = { configPath: "docker.prefix", default: "RUN a\n" }; + const hook = { configPath: "docker.hook", default: "", omitLineIfEmpty: true }; + expect(renderFile("${PREFIX}${HOOK}\n", { placeholders: { PREFIX: prefix, HOOK: hook } }, cfg(), {})).toBe("RUN a\n"); + expect(renderFile("${PREFIX}${HOOK}\n", { placeholders: { HOOK: hook, PREFIX: prefix } }, cfg(), {})).toBe("RUN a\n"); + }); +}); + describe("renderFile: fragments", () => { const frags = { a: "FRAG-A", b: "FRAG-B", c: "FRAG-C" }; const def: RenderDef = {