rahman-resources 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/contract.ts CHANGED
@@ -1,201 +1,35 @@
1
1
  /**
2
- * Slice Composition Compiler — Phase A: Typed contract DSL.
2
+ * Slice Composition Compiler — Phase A: typed contract DSL.
3
3
  *
4
- * This module exposes the {@link defineSliceContract} factory plus the
5
- * supporting type vocabulary. A slice contract is the typed, code-shaped
6
- * sibling of the legacy `slice.manifest.json`. Both coexist during the
7
- * migration; new slices should ship a contract while keeping the JSON
8
- * manifest for back-compat with the npm CLI manifest pipeline.
4
+ * Re-exports the type vocabulary from {@link ./contract-types} and the
5
+ * runtime-checked {@link defineSliceContract} factory. The actual invariant
6
+ * blocks live in {@link ./contract-validate} so each module stays ≤200 LOC.
9
7
  *
10
8
  * @module packages/cli/lib/contract
11
9
  */
12
10
 
13
- // ---------------------------------------------------------------------------
14
- // Primitive vocabulary
15
- // ---------------------------------------------------------------------------
16
-
17
- /**
18
- * Identity providers the kitab knows about.
19
- *
20
- * The kitab itself only ships `convex` (see CLAUDE.md "NO Clerk"). The other
21
- * literals exist so consumer projects forking the contract DSL can target
22
- * a different provider without losing type safety.
23
- */
24
- export type AuthProvider = "convex" | "clerk" | "next-auth" | "none";
25
-
26
- /**
27
- * RBAC permission string in `domain.action` shape — e.g. `"payment.refund"`.
28
- *
29
- * Enforced at compile time via a template-literal type, and at runtime by
30
- * {@link defineSliceContract}. The runtime check rejects empty segments and
31
- * any value with more than one dot.
32
- */
33
- export type RBACPermission = `${string}.${string}`;
34
-
35
- // ---------------------------------------------------------------------------
36
- // `requires` block
37
- // ---------------------------------------------------------------------------
38
-
39
- /**
40
- * Convex table-namespace declaration for a slice.
41
- *
42
- * Every Convex table the slice owns must start with {@link prefix}. Operator
43
- * decision 2026-05-12 mandates per-provider prefixes (e.g. `doku_`, `midtrans_`)
44
- * to prevent the historical `paymentOrders` collision between sibling
45
- * payment slices.
46
- */
47
- export interface ConvexNamespace {
48
- /** Required prefix — e.g. `"doku_"`. Must match `/^[a-z][a-z0-9_]*_$/`. */
49
- prefix: string;
50
- /** Tables this slice declares. Every entry must start with {@link prefix}. */
51
- tables: string[];
52
- }
53
-
54
- /**
55
- * Capabilities a slice REQUIRES the host application to satisfy before it
56
- * can be composed in.
57
- */
58
- export interface SliceContractRequires {
59
- /** Identity provider the slice expects. Omit when slice is auth-agnostic. */
60
- auth?: AuthProvider;
61
- /** RBAC permission strings the slice will call `requirePermission(...)` with. */
62
- rbac?: RBACPermission[];
63
- /** Env var names (server scope). Validators read this for `.env.example` drift checks. */
64
- env?: string[];
65
- /** Convex table namespace the slice owns. */
66
- convex?: ConvexNamespace;
67
- /** Other slice ids that must be installed first. */
68
- deps?: string[];
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // `provides` block
73
- // ---------------------------------------------------------------------------
74
-
75
- /**
76
- * Surface area a slice exposes to the host application + downstream slices.
77
- *
78
- * All arrays are optional. Empty / omitted means "nothing exposed in that
79
- * category". The keys must match the literal segment of a conflict path —
80
- * `tables`, `routes`, `hooks`, `events`, `components`.
81
- */
82
- export interface SliceContractProvides {
83
- /** Next.js route paths the slice mounts — e.g. `["/sign-in", "/sign-out"]`. */
84
- routes?: string[];
85
- /** Public hook export names — e.g. `["useDokuCheckout"]`. */
86
- hooks?: string[];
87
- /** Convex table names. Must match `requires.convex.prefix` if that is set. */
88
- tables?: string[];
89
- /** Event-bus event names the slice emits — e.g. `["payment.captured"]`. */
90
- events?: string[];
91
- /** Public component exports — e.g. `["DokuCheckoutButton"]`. */
92
- components?: string[];
93
- }
94
-
95
- // ---------------------------------------------------------------------------
96
- // `bidir` block — Wave N+3 (Bidirectional Sync Detection Layer)
97
- // ---------------------------------------------------------------------------
98
-
99
- /**
100
- * How the kitab treats sync between this slice and consumer copies.
101
- *
102
- * - `auto-pr`: when `rr scan-consumers` sees an `up-needed` verdict on a
103
- * consumer's `.kitab.json`, the operator workflow auto-opens a PR against
104
- * the kitab. Reserved for slices with strict generalisation gates.
105
- * - `notify`: surface in the scan report; no auto-action.
106
- * - `manual`: default — operator picks up via `/rr-prep` + `/rr-send`.
107
- * - `frozen`: kitab refuses both UP and DOWN sync. Lock for retired slices.
108
- */
109
- export type SliceSyncPolicy = "auto-pr" | "notify" | "manual" | "frozen";
110
-
111
- /**
112
- * Generalisation level a consumer-side `.kitab.json` MUST claim before
113
- * `rr-send` accepts the push back into the kitab.
114
- *
115
- * - `portable`: no consumer-specific business terms baked in. UP-sync allowed.
116
- * - `needs-adapter`: requires a thin adapter wired by the consumer; UP-sync
117
- * blocked until blockers are addressed (or the contract drops the slice
118
- * to `consumer-locked`).
119
- * - `consumer-locked`: contains business-specific logic that cannot be
120
- * generalised. Only DOWN-sync allowed.
121
- */
122
- export type GeneralizationLevel =
123
- | "portable"
124
- | "needs-adapter"
125
- | "consumer-locked";
126
-
127
- /**
128
- * Generalisation contract — what the audit-bp `forbiddenTerms` rule scans
129
- * for, and which props the consumer MUST inject.
130
- */
131
- export interface SliceGeneralization {
132
- level: GeneralizationLevel;
133
- /**
134
- * Identifiers / business terms that MUST NOT appear in the slice source
135
- * tree. Audit-bp scans .ts/.tsx files. Empty when the slice is generic.
136
- */
137
- forbiddenTerms?: string[];
138
- /**
139
- * Props the consumer must inject for the slice to remain portable —
140
- * e.g. `["basePath", "labels", "permission"]`.
141
- */
142
- requiredProps?: string[];
143
- }
144
-
145
- /**
146
- * Bidirectional sync block. Optional, additive — slices without it default to
147
- * `{ syncPolicy: "manual", generalization: { level: "portable" } }` for
148
- * legacy compatibility with Wave N+1 contracts.
149
- */
150
- export interface SliceBidirContract {
151
- syncPolicy: SliceSyncPolicy;
152
- generalization: SliceGeneralization;
153
- }
154
-
155
- // ---------------------------------------------------------------------------
156
- // Top-level contract
157
- // ---------------------------------------------------------------------------
158
-
159
- /**
160
- * The full Phase-A slice contract shape.
161
- *
162
- * @see {@link defineSliceContract} for the runtime-checked constructor.
163
- */
164
- export interface SliceContract {
165
- /** Slice slug — kebab-case, matches the folder name. */
166
- id: string;
167
- /** Semver — `MAJOR.MINOR.PATCH`, optional `-prerelease` and `+build`. */
168
- version: string;
169
- /** Host requirements. */
170
- requires: SliceContractRequires;
171
- /** Surface exposed to the host. */
172
- provides: SliceContractProvides;
173
- /**
174
- * Known incompatibilities — `"<slug>:<provides-key>.<value>"`.
175
- *
176
- * Example: `"midtrans-payment:tables.paymentOrders"` declares that this
177
- * slice collides with `midtrans-payment` over the `paymentOrders` table.
178
- * The validator surfaces this as a P0 finding when both slices are
179
- * composed into the same app.
180
- */
181
- conflicts?: string[];
182
- /** Map of previous-version → migration script id. */
183
- migrationFrom?: Record<string, string>;
184
- /** Wave N+3 — bidirectional sync policy + generalisation gate. */
185
- bidir?: SliceBidirContract;
186
- }
187
-
188
- // ---------------------------------------------------------------------------
189
- // Runtime regexes
190
- // ---------------------------------------------------------------------------
191
-
192
- const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
193
- // Plain semver — also tolerates pre-release + build metadata.
194
- const SEMVER =
195
- /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
196
- const PREFIX = /^[a-z][a-z0-9_]*_$/;
197
- const CONFLICT = /^[a-z][a-z0-9-]*:(routes|hooks|tables|events|components)\.[A-Za-z0-9_\-\/]+$/;
198
- const PERMISSION = /^[^.]+\.[^.]+$/;
11
+ import type { SliceContract } from "./contract-types";
12
+ import {
13
+ validateHeader,
14
+ validateRbac,
15
+ validateConvex,
16
+ validateBidir,
17
+ validateConflicts,
18
+ } from "./contract-validate";
19
+
20
+ // Re-export the full type surface so existing imports keep working.
21
+ export type {
22
+ AuthProvider,
23
+ RBACPermission,
24
+ ConvexNamespace,
25
+ SliceContractRequires,
26
+ SliceContractProvides,
27
+ SliceSyncPolicy,
28
+ GeneralizationLevel,
29
+ SliceGeneralization,
30
+ SliceBidirContract,
31
+ SliceContract,
32
+ } from "./contract-types";
199
33
 
200
34
  // ---------------------------------------------------------------------------
201
35
  // Factory
@@ -225,132 +59,10 @@ const PERMISSION = /^[^.]+\.[^.]+$/;
225
59
  * @returns The exact same object reference, narrowed to {@link SliceContract}.
226
60
  */
227
61
  export function defineSliceContract(c: SliceContract): SliceContract {
228
- if (!c || typeof c !== "object") {
229
- throw new Error("defineSliceContract: expected an object, got " + typeof c);
230
- }
231
- if (typeof c.id !== "string" || !KEBAB_CASE.test(c.id)) {
232
- throw new Error(`defineSliceContract: id "${String(c.id)}" must be kebab-case`);
233
- }
234
- if (typeof c.version !== "string" || !SEMVER.test(c.version)) {
235
- throw new Error(`defineSliceContract(${c.id}): version "${String(c.version)}" is not semver`);
236
- }
237
- if (!c.requires || typeof c.requires !== "object") {
238
- throw new Error(`defineSliceContract(${c.id}): requires must be an object`);
239
- }
240
- if (!c.provides || typeof c.provides !== "object") {
241
- throw new Error(`defineSliceContract(${c.id}): provides must be an object`);
242
- }
243
-
244
- // RBAC
245
- if (c.requires.rbac) {
246
- if (!Array.isArray(c.requires.rbac)) {
247
- throw new Error(`defineSliceContract(${c.id}): requires.rbac must be an array`);
248
- }
249
- for (const p of c.requires.rbac) {
250
- if (typeof p !== "string" || !PERMISSION.test(p)) {
251
- throw new Error(
252
- `defineSliceContract(${c.id}): rbac entry "${String(p)}" must be "<domain>.<action>"`,
253
- );
254
- }
255
- }
256
- }
257
-
258
- // Convex prefix invariants
259
- const cx = c.requires.convex;
260
- if (cx) {
261
- if (typeof cx.prefix !== "string" || !PREFIX.test(cx.prefix)) {
262
- throw new Error(
263
- `defineSliceContract(${c.id}): convex.prefix "${String(cx.prefix)}" must match /^[a-z][a-z0-9_]*_$/`,
264
- );
265
- }
266
- if (!Array.isArray(cx.tables)) {
267
- throw new Error(`defineSliceContract(${c.id}): convex.tables must be an array`);
268
- }
269
- for (const t of cx.tables) {
270
- if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
271
- throw new Error(
272
- `defineSliceContract(${c.id}): convex.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
273
- );
274
- }
275
- }
276
- if (Array.isArray(c.provides.tables)) {
277
- for (const t of c.provides.tables) {
278
- if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
279
- throw new Error(
280
- `defineSliceContract(${c.id}): provides.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
281
- );
282
- }
283
- }
284
- }
285
- }
286
-
287
- // bidir block — Wave N+3
288
- if (c.bidir !== undefined) {
289
- if (!c.bidir || typeof c.bidir !== "object") {
290
- throw new Error(`defineSliceContract(${c.id}): bidir must be an object`);
291
- }
292
- const policies = ["auto-pr", "notify", "manual", "frozen"];
293
- if (!policies.includes(c.bidir.syncPolicy)) {
294
- throw new Error(
295
- `defineSliceContract(${c.id}): bidir.syncPolicy "${String(c.bidir.syncPolicy)}" must be one of ${policies.join("|")}`,
296
- );
297
- }
298
- if (!c.bidir.generalization || typeof c.bidir.generalization !== "object") {
299
- throw new Error(
300
- `defineSliceContract(${c.id}): bidir.generalization must be an object`,
301
- );
302
- }
303
- const levels = ["portable", "needs-adapter", "consumer-locked"];
304
- if (!levels.includes(c.bidir.generalization.level)) {
305
- throw new Error(
306
- `defineSliceContract(${c.id}): bidir.generalization.level "${String(c.bidir.generalization.level)}" must be one of ${levels.join("|")}`,
307
- );
308
- }
309
- const ft = c.bidir.generalization.forbiddenTerms;
310
- if (ft !== undefined) {
311
- if (!Array.isArray(ft)) {
312
- throw new Error(
313
- `defineSliceContract(${c.id}): bidir.generalization.forbiddenTerms must be an array`,
314
- );
315
- }
316
- for (const t of ft) {
317
- if (typeof t !== "string" || t.length === 0) {
318
- throw new Error(
319
- `defineSliceContract(${c.id}): bidir.generalization.forbiddenTerms entries must be non-empty strings`,
320
- );
321
- }
322
- }
323
- }
324
- const rp = c.bidir.generalization.requiredProps;
325
- if (rp !== undefined) {
326
- if (!Array.isArray(rp)) {
327
- throw new Error(
328
- `defineSliceContract(${c.id}): bidir.generalization.requiredProps must be an array`,
329
- );
330
- }
331
- for (const p of rp) {
332
- if (typeof p !== "string" || p.length === 0) {
333
- throw new Error(
334
- `defineSliceContract(${c.id}): bidir.generalization.requiredProps entries must be non-empty strings`,
335
- );
336
- }
337
- }
338
- }
339
- }
340
-
341
- // Conflicts
342
- if (c.conflicts) {
343
- if (!Array.isArray(c.conflicts)) {
344
- throw new Error(`defineSliceContract(${c.id}): conflicts must be an array`);
345
- }
346
- for (const cf of c.conflicts) {
347
- if (typeof cf !== "string" || !CONFLICT.test(cf)) {
348
- throw new Error(
349
- `defineSliceContract(${c.id}): conflicts entry "${String(cf)}" must match "<slug>:<routes|hooks|tables|events|components>.<value>"`,
350
- );
351
- }
352
- }
353
- }
354
-
62
+ validateHeader(c);
63
+ validateRbac(c);
64
+ validateConvex(c);
65
+ validateBidir(c);
66
+ validateConflicts(c);
355
67
  return c;
356
68
  }
@@ -0,0 +1,53 @@
1
+ // dna-graph.mjs — lineage graph builder.
2
+ //
3
+ // Extracted from dna.mjs. Reads all DNA files via `listAllDNA` and produces
4
+ // a node/edge graph keyed by source / slice / consumer.
5
+
6
+ import { listAllDNA } from "./dna.mjs";
7
+
8
+ /** @returns {import("./dna").LineageGraph} */
9
+ export function buildLineageGraph() {
10
+ const all = listAllDNA();
11
+ /** @type {Map<string, import("./dna").LineageGraphNode>} */
12
+ const nodes = new Map();
13
+ /** @type {import("./dna").LineageGraphEdge[]} */
14
+ const edges = [];
15
+
16
+ function addNode(id, type) {
17
+ if (!nodes.has(id)) nodes.set(id, { id, type });
18
+ }
19
+
20
+ for (const dna of all) {
21
+ const sliceId = `slice:${dna.id}`;
22
+ addNode(sliceId, "slice");
23
+
24
+ // lineage edges: source → slice
25
+ for (const entry of dna.lineage ?? []) {
26
+ const sourceId = `source:${entry.from}`;
27
+ addNode(sourceId, "source");
28
+ // If `to` looks like a different slice / kitab tag, prefer the slice node;
29
+ // otherwise treat `to` as the kitab itself and target the slice node.
30
+ const targetId = entry.to && entry.to.startsWith("kitab:") ? sliceId : sliceId;
31
+ edges.push({
32
+ from: sourceId,
33
+ to: targetId,
34
+ transforms: entry.transforms,
35
+ at: entry.at,
36
+ });
37
+ }
38
+
39
+ // consumer edges: slice → consumer
40
+ for (const [consumerName, adoption] of Object.entries(dna.consumers ?? {})) {
41
+ const consumerId = `consumer:${consumerName}`;
42
+ addNode(consumerId, "consumer");
43
+ edges.push({
44
+ from: sliceId,
45
+ to: consumerId,
46
+ transforms: [`drift:${adoption.drift_score}`, `v${adoption.version}`],
47
+ at: adoption.adopted_at,
48
+ });
49
+ }
50
+ }
51
+
52
+ return { nodes: [...nodes.values()], edges };
53
+ }
package/lib/dna.mjs CHANGED
@@ -14,6 +14,10 @@
14
14
  // at fs.promises — the consumers (CLI + MCP) are both single-shot processes
15
15
  // where async I/O adds noise without buying parallelism. The d.ts mirrors
16
16
  // this signature exactly.
17
+ //
18
+ // `buildLineageGraph` lives in dna-graph.mjs and is re-exported below so
19
+ // existing imports (`import { buildLineageGraph } from "../lib/dna.mjs"`)
20
+ // keep working.
17
21
 
18
22
  import {
19
23
  existsSync,
@@ -136,52 +140,7 @@ export function listAllDNA() {
136
140
  return out;
137
141
  }
138
142
 
139
- /** @returns {import("./dna").LineageGraph} */
140
- export function buildLineageGraph() {
141
- const all = listAllDNA();
142
- /** @type {Map<string, import("./dna").LineageGraphNode>} */
143
- const nodes = new Map();
144
- /** @type {import("./dna").LineageGraphEdge[]} */
145
- const edges = [];
146
-
147
- function addNode(id, type) {
148
- if (!nodes.has(id)) nodes.set(id, { id, type });
149
- }
150
-
151
- for (const dna of all) {
152
- const sliceId = `slice:${dna.id}`;
153
- addNode(sliceId, "slice");
154
-
155
- // lineage edges: source → slice
156
- for (const entry of dna.lineage ?? []) {
157
- const sourceId = `source:${entry.from}`;
158
- addNode(sourceId, "source");
159
- // If `to` looks like a different slice / kitab tag, prefer the slice node;
160
- // otherwise treat `to` as the kitab itself and target the slice node.
161
- const targetId = entry.to && entry.to.startsWith("kitab:") ? sliceId : sliceId;
162
- edges.push({
163
- from: sourceId,
164
- to: targetId,
165
- transforms: entry.transforms,
166
- at: entry.at,
167
- });
168
- }
169
-
170
- // consumer edges: slice → consumer
171
- for (const [consumerName, adoption] of Object.entries(dna.consumers ?? {})) {
172
- const consumerId = `consumer:${consumerName}`;
173
- addNode(consumerId, "consumer");
174
- edges.push({
175
- from: sliceId,
176
- to: consumerId,
177
- transforms: [`drift:${adoption.drift_score}`, `v${adoption.version}`],
178
- at: adoption.adopted_at,
179
- });
180
- }
181
- }
182
-
183
- return { nodes: [...nodes.values()], edges };
184
- }
143
+ export { buildLineageGraph } from "./dna-graph.mjs";
185
144
 
186
145
  // ─── helpers ──────────────────────────────────────────────────────────────
187
146
 
@@ -0,0 +1,116 @@
1
+ // env-augment.mjs — append slice env requirements to consumer .env.example.
2
+ //
3
+ // Invoked from `npx rr add <slice>` after slice files land. Idempotent:
4
+ // re-running `add` does not duplicate entries. Never creates .env.example
5
+ // (consumer may not want one); warns and skips instead. Never touches
6
+ // .env.local (operator-only).
7
+ //
8
+ // Reads env requirements from the manifest entry passed in by the CLI
9
+ // (already normalised from slice.json `deps.env` + contract `requires.env`
10
+ // at sync time). Shape: `{ name, scope?, required?, description? }`.
11
+ //
12
+ // Contract: see CLAUDE.md "augmentConsumerEnv contract". Function ≤200 LOC.
13
+
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ import kleur from "kleur";
18
+
19
+ /**
20
+ * @param {{ slug: string, env?: Array<{ name: string, scope?: string, required?: boolean, description?: string }> }} slice
21
+ * @param {string} target absolute path to consumer project root
22
+ */
23
+ export function augmentConsumerEnv(slice, target) {
24
+ const envList = Array.isArray(slice?.env) ? slice.env : [];
25
+ if (envList.length === 0) {
26
+ return;
27
+ }
28
+
29
+ const envFile = path.join(target, ".env.example");
30
+ if (!existsSync(envFile)) {
31
+ console.log(
32
+ kleur.yellow(
33
+ ` ⚠ .env.example not found in ${target} — skipping env augment.`,
34
+ ),
35
+ );
36
+ console.log(
37
+ kleur.dim(
38
+ ` Required env for ${slice.slug}: ${envList
39
+ .map((e) => prefixName(e))
40
+ .join(", ")}`,
41
+ ),
42
+ );
43
+ return;
44
+ }
45
+
46
+ const existing = readFileSync(envFile, "utf8");
47
+ const present = parseExistingNames(existing);
48
+
49
+ const missing = envList.filter((e) => !present.has(prefixName(e)));
50
+ if (missing.length === 0) {
51
+ console.log(kleur.dim(` (no new env to add to .env.example)`));
52
+ return;
53
+ }
54
+
55
+ const block = renderBlock(slice.slug, missing);
56
+ const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
57
+ writeFileSync(envFile, existing + sep + block, "utf8");
58
+
59
+ console.log(
60
+ kleur.green(
61
+ ` ✓ appended ${missing.length} env var(s) to .env.example`,
62
+ ),
63
+ );
64
+ for (const e of missing) {
65
+ console.log(
66
+ kleur.dim(
67
+ ` + ${prefixName(e)}${e.required ? "" : " (optional)"}`,
68
+ ),
69
+ );
70
+ }
71
+ }
72
+
73
+ // ─── helpers ──────────────────────────────────────────────────────────────
74
+
75
+ function prefixName(e) {
76
+ // `scope: "next-public"` means the consumer reads it via `process.env` on
77
+ // the client, so it needs the NEXT_PUBLIC_ prefix to be exposed.
78
+ return e.scope === "next-public" && !e.name.startsWith("NEXT_PUBLIC_")
79
+ ? `NEXT_PUBLIC_${e.name}`
80
+ : e.name;
81
+ }
82
+
83
+ function parseExistingNames(text) {
84
+ const set = new Set();
85
+ for (const rawLine of text.split(/\r?\n/)) {
86
+ const line = rawLine.trim();
87
+ if (!line || line.startsWith("#")) continue;
88
+ const eq = line.indexOf("=");
89
+ if (eq <= 0) continue;
90
+ // Strip an optional `export ` prefix.
91
+ const lhs = line.slice(0, eq).replace(/^export\s+/, "").trim();
92
+ if (lhs) set.add(lhs);
93
+ }
94
+ return set;
95
+ }
96
+
97
+ function renderBlock(slug, entries) {
98
+ const lines = [];
99
+ lines.push("");
100
+ lines.push(`# ─── ${slug} ───`);
101
+ for (const e of entries) {
102
+ const name = prefixName(e);
103
+ const tag =
104
+ e.required === false ? "optional" : e.required ? "required" : null;
105
+ const meta = [e.scope, tag].filter(Boolean).join(", ");
106
+ if (e.description) {
107
+ lines.push(`# ${e.description}${meta ? ` (${meta})` : ""}`);
108
+ } else if (meta) {
109
+ lines.push(`# ${meta}`);
110
+ }
111
+ // Secrets get an empty placeholder — never a fake value.
112
+ lines.push(`${name}=`);
113
+ }
114
+ lines.push("");
115
+ return lines.join("\n");
116
+ }