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
26 changes: 6 additions & 20 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
`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
Expand All @@ -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.
23 changes: 15 additions & 8 deletions src/engine/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 },
);
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/engine/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,17 @@ function unresolvedTokens(text: string, values: ReadonlyMap<string, string>, 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
Expand Down Expand Up @@ -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<string, string>();
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)) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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] ?? "";
Expand Down
15 changes: 12 additions & 3 deletions src/manifest/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@ 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<number> = new Set([2, SUPPORTED_SCHEMA_VERSION]);
export const SUPPORTED_SCHEMA_VERSIONS: ReadonlySet<number> = 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(", ");

/** 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];
Expand All @@ -44,13 +48,18 @@ 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),
fromDependency: z.string().min(1).optional(),
default: z.string(),
pattern: z.string().min(1).optional(),
join: z.enum(["space", "lines"]).optional(),
omitLineIfEmpty: z.boolean().optional(),
});

/**
Expand Down
36 changes: 31 additions & 5 deletions test/manifest-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions test/manifest-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
41 changes: 41 additions & 0 deletions test/render-generic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading