create-cmp-cli 0.3.2 → 0.4.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/README.md CHANGED
@@ -18,6 +18,15 @@ machine-enforced definition of done.
18
18
 
19
19
  ---
20
20
 
21
+ ```bash
22
+ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --verify
23
+ ```
24
+
25
+ Deterministic (stamps a frozen, CI-verified template), fully non-interactive with flags, and
26
+ exits non-zero on failure. Every generated project ships its own verify lane — `node qa/verify.mjs`,
27
+ 8 gates, evidence receipts — with nothing installed. Agent-readable: [llms.txt](./llms.txt) ·
28
+ [options.schema.json](./options.schema.json).
29
+
21
30
  ## What is this, in plain words
22
31
 
23
32
  **Day one, it's a scaffolder.** One command gives you a working Compose Multiplatform app —
@@ -88,6 +97,9 @@ Everything except `create` works on **any** KMP project, not just ones this tool
88
97
  | `create-cmp clean` | Reclaims disk: stale Kotlin/Native toolchains, `build/` dirs. Shows sizes, asks before deleting. |
89
98
  | `create-cmp verify` | Runs the green-build gate against an existing project. Exit 0 = green. Useful in scripts and CI. |
90
99
 
100
+ Hit a KMP build error? [Common CMP/KMP build errors and fixes](docs/errors/README.md) — kotlin↔KSP
101
+ mismatch, the KSP2/iOS catch-22, `SDK location not found`, `No space left on device`, version drift.
102
+
91
103
  ## The Claude Code plugin (8 skills)
92
104
 
93
105
  ```text
package/llms.txt ADDED
@@ -0,0 +1,27 @@
1
+ # create-cmp
2
+
3
+ > The AI delivery harness for Kotlin/Compose Multiplatform (Android + iOS): scaffolds a deterministic, green-building app in which every project carries a spec-driven verify lane, evidence receipts, and mechanical enforcement of "done".
4
+
5
+ create-cmp (npm package: `create-cmp-cli`) stamps a frozen, CI-verified template — it never asks an LLM to freehand project code, so every scaffold is identical and every scaffold builds. It runs fully non-interactively with flags, validates them against a machine-readable schema (`options.schema.json`), and exits non-zero on failure. The `--verify` flag builds the generated app before reporting success.
6
+
7
+ Non-interactive one-liner (flags per `options.schema.json` and `create-cmp --help`):
8
+
9
+ ```bash
10
+ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --verify
11
+ ```
12
+
13
+ Other flags: `--bundle-id`, `--region`, `--theme-prefix`, `--ios/--no-ios`, `--firebase/--no-firebase`, `--auth <email|phone|both|none>`, `--room/--no-room`, `--e2e/--no-e2e`, `--inspector/--no-inspector`, `--dev-client/--no-dev-client`, `--tabs Home:home,Profile:person`, `--target-dir`, `--force`. Subcommands `doctor`, `upgrade`, `clean`, and `verify` work on any KMP project.
14
+
15
+ create-cmp is also invokable as a Claude Code plugin (`/plugin marketplace add kvdm-co-pilot/create-cmp`, then `/plugin install create-cmp`) with eight skills and the `cmp-inspector` MCP server. Generated projects self-verify without the plugin installed: `node qa/verify.mjs` runs 8 gates (spec coverage, build, unit tests, conformance, golden trees, token drift, a11y, on-device E2E) and writes a content-hash-bound evidence receipt; a Stop hook and CI both refuse "done" without a fresh PASS receipt.
16
+
17
+ ## Docs
18
+
19
+ - [README](https://github.com/kvdm-co-pilot/create-cmp/blob/main/README.md): full feature tour — CLI commands, plugin skills, and the harness every generated project carries
20
+ - [Usage guide](https://github.com/kvdm-co-pilot/create-cmp/blob/main/docs/USAGE.md): every command, skill, MCP tool, and workflow in detail
21
+ - [Options schema](https://github.com/kvdm-co-pilot/create-cmp/blob/main/options.schema.json): machine-readable JSON Schema for the engine config the flags build
22
+ - [Changelog](https://github.com/kvdm-co-pilot/create-cmp/blob/main/CHANGELOG.md): release history, Keep-a-Changelog format
23
+
24
+ ## Examples
25
+
26
+ - [create-cmp-showcase](https://github.com/kvdm-co-pilot/create-cmp-showcase): public repo built entirely by this tool, every commit carrying its evidence receipt
27
+ - [Refusal PR #1](https://github.com/kvdm-co-pilot/create-cmp-showcase/pull/1): live proof of the harness blocking a bad change and naming the violated rule
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.3.2",
4
- "description": "The AI delivery harness for Kotlin/Compose Multiplatform — scaffolds a green-building app (Android + iOS) in minutes and holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Installs the `create-cmp` command.",
3
+ "version": "0.4.0",
4
+ "description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-cmp": "bin/create-cmp.mjs",
@@ -14,7 +14,8 @@
14
14
  "bin",
15
15
  "src",
16
16
  "template",
17
- "options.schema.json"
17
+ "options.schema.json",
18
+ "llms.txt"
18
19
  ],
19
20
  "scripts": {
20
21
  "test": "node --test",
@@ -24,13 +25,23 @@
24
25
  "keywords": [
25
26
  "create-cmp",
26
27
  "create-cmp-cli",
28
+ "compose multiplatform",
27
29
  "compose-multiplatform",
30
+ "kotlin multiplatform",
28
31
  "kotlin-multiplatform",
32
+ "kotlin",
33
+ "compose",
29
34
  "kmp",
30
35
  "cmp",
31
36
  "scaffold",
37
+ "project generator",
38
+ "cli",
39
+ "claude code",
40
+ "ai agent",
32
41
  "android",
33
- "ios"
42
+ "ios",
43
+ "verify",
44
+ "e2e"
34
45
  ],
35
46
  "license": "MIT",
36
47
  "author": "Karel van der Merwe",
@@ -0,0 +1,370 @@
1
+ // Tab-driven surface regeneration (scaffold pipeline step b.3).
2
+ //
3
+ // The template ships the DEFAULT tabs (Home:home, Profile:person) baked into
4
+ // three surfaces; this module rewrites them from `config.tabs` at stamp time
5
+ // so a non-default tabs config can never go stale:
6
+ // - presentation/navigation/AppTab.kt (the appTabs() list — one entry per tab)
7
+ // - presentation/navigation/AppNavHost.kt (the appTabs(...) call-site wiring)
8
+ // - qa/e2e/smoke.yaml (Maestro taps/asserts per tab)
9
+ // Tabs without a shipped feature screen (anything that doesn't slug to
10
+ // `home`/`profile`) get a generated PlaceholderScreen stub carrying the
11
+ // `<slug>_title` testTag the smoke flow asserts.
12
+ //
13
+ // Everything is written in TEMPLATE-TOKEN form (__PACKAGE__ contents, literal
14
+ // com/example/app paths) BEFORE token replacement / package rename, so the
15
+ // normal pipeline stamps these files like any other template file. For the
16
+ // default tabs config the output is byte-identical to the static template
17
+ // files — golden/deterministic scaffolding is a core product property.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ // --- slug + naming ------------------------------------------------------------
23
+
24
+ /**
25
+ * Nav-item tag slug for a tab label.
26
+ * MUST MIRROR `navItemTag` in
27
+ * template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt:
28
+ * lowercase, every run of chars outside [a-z0-9] collapsed to a single "_",
29
+ * leading/trailing "_" trimmed. ("My Stuff!" → "my_stuff")
30
+ * @param {string} label
31
+ * @returns {string}
32
+ */
33
+ export function navSlug(label) {
34
+ return String(label)
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, "_")
37
+ .replace(/^_+|_+$/g, "");
38
+ }
39
+
40
+ // Kotlin hard keywords — a derived parameter name colliding with one must be backticked.
41
+ const KOTLIN_HARD_KEYWORDS = new Set([
42
+ "as", "break", "class", "continue", "do", "else", "false", "for", "fun",
43
+ "if", "in", "interface", "is", "null", "object", "package", "return",
44
+ "super", "this", "throw", "true", "try", "typealias", "typeof", "val",
45
+ "var", "when", "while",
46
+ ]);
47
+
48
+ /**
49
+ * Kotlin parameter name for a tab: camelCase of the slug ("my_stuff" → "myStuff"),
50
+ * backticked when it collides with a hard keyword or starts with a digit.
51
+ * @param {string} slug
52
+ * @returns {string}
53
+ */
54
+ export function kotlinParamName(slug) {
55
+ const parts = slug.split("_").filter(Boolean);
56
+ const name = parts
57
+ .map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1)))
58
+ .join("");
59
+ if (KOTLIN_HARD_KEYWORDS.has(name) || /^[0-9]/.test(name)) return `\`${name}\``;
60
+ return name;
61
+ }
62
+
63
+ /**
64
+ * Material icon symbol for a CLI icon key: PascalCase the key's alphanumeric
65
+ * parts ("home" → "Home", "shopping_cart" → "ShoppingCart", "ShoppingCart"
66
+ * stays as-is). The template depends on compose.materialIconsExtended, so any
67
+ * `Icons.Filled.*` symbol from the extended set resolves.
68
+ * @param {string} key
69
+ * @returns {string}
70
+ */
71
+ export function iconSymbol(key) {
72
+ return String(key)
73
+ .split(/[^A-Za-z0-9]+/)
74
+ .filter(Boolean)
75
+ .map((p) => p[0].toUpperCase() + p.slice(1))
76
+ .join("");
77
+ }
78
+
79
+ /** Escape a label for use inside a Kotlin double-quoted string literal. */
80
+ function kotlinString(s) {
81
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$");
82
+ }
83
+
84
+ /**
85
+ * Derive per-tab info (slug, param, icon) and validate the set: every label
86
+ * must produce a non-empty, unique slug (the slug is the nav testTag AND the
87
+ * Kotlin parameter name — collisions or empties cannot compile/automate).
88
+ * @param {Array<{label:string, icon:string}>} tabs
89
+ */
90
+ export function tabInfos(tabs) {
91
+ const seen = new Map();
92
+ return tabs.map((tab) => {
93
+ const slug = navSlug(tab.label);
94
+ if (!slug) {
95
+ throw new Error(
96
+ `tab label ${JSON.stringify(tab.label)} produces an empty nav slug — labels need at least one [a-z0-9] character`
97
+ );
98
+ }
99
+ if (seen.has(slug)) {
100
+ throw new Error(
101
+ `tab labels ${JSON.stringify(seen.get(slug))} and ${JSON.stringify(tab.label)} both slug to "${slug}" — nav testTags must be unique`
102
+ );
103
+ }
104
+ seen.set(slug, tab.label);
105
+ return { label: tab.label, slug, param: kotlinParamName(slug), icon: iconSymbol(tab.icon) };
106
+ });
107
+ }
108
+
109
+ // --- generators ----------------------------------------------------------------
110
+
111
+ /**
112
+ * Render AppTab.kt (template-token form). For the default tabs this reproduces
113
+ * the static template file byte-for-byte.
114
+ * @param {ReturnType<typeof tabInfos>} infos
115
+ */
116
+ export function renderAppTabsKt(infos) {
117
+ const iconImports = [...new Set(infos.map((t) => t.icon))]
118
+ .sort()
119
+ .map((i) => `import androidx.compose.material.icons.filled.${i}`)
120
+ .join("\n");
121
+ const params = infos.map((t) => ` ${t.param}: @Composable () -> Unit,`).join("\n");
122
+ const entries = infos
123
+ .map((t) => ` AppTab("${kotlinString(t.label)}", Icons.Filled.${t.icon}, ${t.param}),`)
124
+ .join("\n");
125
+
126
+ return `package __PACKAGE__.presentation.navigation
127
+
128
+ import androidx.compose.material.icons.Icons
129
+ ${iconImports}
130
+ import androidx.compose.runtime.Composable
131
+ import androidx.compose.ui.graphics.vector.ImageVector
132
+
133
+ // A single bottom-nav tab: its label, icon, and the screen it renders.
134
+ data class AppTab(
135
+ val label: String,
136
+ val icon: ImageVector,
137
+ val content: @Composable () -> Unit,
138
+ )
139
+
140
+ // The tab list drives AppShell + AppBottomNav generically (no role-hardcoded shells).
141
+ // The scaffolder regenerates this list from the configured \`tabs\`.
142
+ @Composable
143
+ fun appTabs(
144
+ ${params}
145
+ ): List<AppTab> = listOf(
146
+ ${entries}
147
+ )
148
+ `;
149
+ }
150
+
151
+ /**
152
+ * Render qa/e2e/smoke.yaml (template-token form): launch, wait for the first
153
+ * tab's content, then for each subsequent tab tap nav_<slug> and assert its
154
+ * <slug>_title + the shell, then return to the first tab. Byte-identical to
155
+ * the static template file for the default tabs.
156
+ * @param {ReturnType<typeof tabInfos>} infos
157
+ */
158
+ export function renderSmokeYaml(infos) {
159
+ const [first, ...rest] = infos;
160
+ const lines = [];
161
+ lines.push(`# E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02.
162
+ #
163
+ # Proves the real app boots on a device/emulator and the bottom-nav shell works.
164
+ # Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
165
+ # never by display text. Nav-item ids are nav_<label-slug> — the slug rule lives in
166
+ # AppShell.kt's navItemTag (lowercase, non-[a-z0-9] runs collapsed to "_", trimmed);
167
+ # keep these ids in sync with it if the configured tabs change.
168
+ #
169
+ # Run: maestro test qa/e2e/smoke.yaml (device/emulator attached)
170
+ # The verify lane's e2eSmoke step runs this automatically when maestro + a device are present.
171
+ appId: __PACKAGE__
172
+ ---
173
+ - launchApp:
174
+ clearState: true
175
+
176
+ # SPEC: SHELL-01 — the app boots and the first tab renders inside the shell, bottom nav visible.
177
+ # Cold-start after clearState can take longer than a bare assert's default window on a slow/CI
178
+ # emulator (first Compose frame under load). Waiting for the first frame IS the SHELL-01 boot
179
+ # proof, so wait explicitly with a generous budget rather than asserting immediately.
180
+ - extendedWaitUntil:
181
+ visible:
182
+ id: "${first.slug}_title"
183
+ timeout: 60000
184
+ - assertVisible:
185
+ id: "app_bottom_nav"`);
186
+
187
+ rest.forEach((tab, i) => {
188
+ lines.push("");
189
+ if (i === 0) lines.push("# SPEC: SHELL-02 — switching tabs keeps the shell");
190
+ lines.push(`- tapOn:
191
+ id: "nav_${tab.slug}"
192
+ - assertVisible:
193
+ id: "${tab.slug}_title"
194
+ - assertVisible:
195
+ id: "app_bottom_nav"`);
196
+ });
197
+
198
+ if (rest.length > 0) {
199
+ lines.push("");
200
+ lines.push(`# and back
201
+ - tapOn:
202
+ id: "nav_${first.slug}"
203
+ - assertVisible:
204
+ id: "${first.slug}_title"`);
205
+ }
206
+
207
+ return `${lines.join("\n")}\n`;
208
+ }
209
+
210
+ /** Call-site content lambda for one tab in AppNavHost's appTabs(...) call. */
211
+ function navHostTabArg(tab) {
212
+ if (tab.slug === "home") {
213
+ return ` ${tab.param} = {
214
+ HomeScreen(
215
+ onItemClick = { itemId -> navController.navigate(Routes.detail(itemId)) },
216
+ )
217
+ },`;
218
+ }
219
+ if (tab.slug === "profile") {
220
+ return ` ${tab.param} = { ProfileScreen() },`;
221
+ }
222
+ return ` ${tab.param} = { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") },`;
223
+ }
224
+
225
+ // The exact template blocks AppNavHost rewriting anchors on. If the template
226
+ // drifts, rewriteNavHost throws instead of silently shipping stale wiring.
227
+ const NAVHOST_IMPORTS_ANCHOR = `import __PACKAGE__.presentation.home.HomeScreen
228
+ import __PACKAGE__.presentation.profile.ProfileScreen`;
229
+ const NAVHOST_TABS_OPEN = " val tabs = appTabs(\n";
230
+ const NAVHOST_TABS_CLOSE = "\n )\n";
231
+
232
+ /**
233
+ * Rewrite AppNavHost.kt content: the screen imports and the appTabs(...) call
234
+ * site, one argument per configured tab. Home/Profile slugs wire the shipped
235
+ * feature screens; anything else wires a PlaceholderScreen stub.
236
+ * @param {string} content template-token AppNavHost.kt content
237
+ * @param {ReturnType<typeof tabInfos>} infos
238
+ * @returns {string}
239
+ */
240
+ export function rewriteNavHost(content, infos) {
241
+ if (!content.includes(NAVHOST_IMPORTS_ANCHOR)) {
242
+ throw new Error("AppNavHost.kt: screen-imports block not found — template drifted from the tab rewriter");
243
+ }
244
+ const start = content.indexOf(NAVHOST_TABS_OPEN);
245
+ const end = start === -1 ? -1 : content.indexOf(NAVHOST_TABS_CLOSE, start);
246
+ if (start === -1 || end === -1) {
247
+ throw new Error("AppNavHost.kt: `val tabs = appTabs(` block not found — template drifted from the tab rewriter");
248
+ }
249
+
250
+ const imports = [];
251
+ if (infos.some((t) => t.slug !== "home" && t.slug !== "profile")) {
252
+ imports.push("import __PACKAGE__.presentation.components.PlaceholderScreen");
253
+ }
254
+ if (infos.some((t) => t.slug === "home")) {
255
+ imports.push("import __PACKAGE__.presentation.home.HomeScreen");
256
+ }
257
+ if (infos.some((t) => t.slug === "profile")) {
258
+ imports.push("import __PACKAGE__.presentation.profile.ProfileScreen");
259
+ }
260
+
261
+ const tabsBlock =
262
+ NAVHOST_TABS_OPEN + infos.map(navHostTabArg).join("\n") + NAVHOST_TABS_CLOSE;
263
+
264
+ return (
265
+ content.slice(0, start) + tabsBlock + content.slice(end + NAVHOST_TABS_CLOSE.length)
266
+ ).replace(NAVHOST_IMPORTS_ANCHOR, () => imports.join("\n"));
267
+ }
268
+
269
+ /**
270
+ * Render the PlaceholderScreen stub (template-token form) — written only when
271
+ * a configured tab has no shipped feature screen, so the default scaffold's
272
+ * output is unchanged. Mirrors ProfileScreen's stub shape; the title testTag
273
+ * is what qa/e2e/smoke.yaml asserts for the tab.
274
+ */
275
+ export function renderPlaceholderScreenKt() {
276
+ return `package __PACKAGE__.presentation.components
277
+
278
+ import androidx.compose.foundation.layout.Arrangement
279
+ import androidx.compose.foundation.layout.Column
280
+ import androidx.compose.foundation.layout.fillMaxSize
281
+ import androidx.compose.foundation.layout.padding
282
+ import androidx.compose.material3.MaterialTheme
283
+ import androidx.compose.material3.Text
284
+ import androidx.compose.runtime.Composable
285
+ import androidx.compose.ui.Alignment
286
+ import androidx.compose.ui.Modifier
287
+ import androidx.compose.ui.semantics.semantics
288
+ import androidx.compose.ui.semantics.testTag
289
+ import androidx.compose.ui.unit.dp
290
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
291
+
292
+ // Generated stub for a configured bottom-nav tab that has no feature yet.
293
+ // Build the real feature with the add-feature skill (qa/scaffold-feature.mjs),
294
+ // then swap this out in AppNavHost. The title testTag (\`<slug>_title\`) is what
295
+ // qa/e2e/smoke.yaml asserts for this tab — keep it when you replace the stub.
296
+ @Composable
297
+ fun PlaceholderScreen(title: String, titleTag: String) {
298
+ Column(
299
+ modifier = Modifier
300
+ .fillMaxSize()
301
+ .padding(__THEME_PREFIX__Tokens.PaddingPage),
302
+ verticalArrangement = Arrangement.spacedBy(8.dp),
303
+ horizontalAlignment = Alignment.Start,
304
+ ) {
305
+ Text(
306
+ text = title,
307
+ style = MaterialTheme.typography.headlineMedium,
308
+ modifier = Modifier.semantics { testTag = titleTag },
309
+ )
310
+ Text(
311
+ text = "This is a generated stub tab. Wire it up like the Home feature.",
312
+ style = MaterialTheme.typography.bodyMedium,
313
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
314
+ )
315
+ }
316
+ }
317
+ `;
318
+ }
319
+
320
+ // --- pipeline entry --------------------------------------------------------------
321
+
322
+ const APPTAB_REL =
323
+ "composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppTab.kt";
324
+ const NAVHOST_REL =
325
+ "composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt";
326
+ const PLACEHOLDER_REL =
327
+ "composeApp/src/commonMain/kotlin/com/example/app/presentation/components/PlaceholderScreen.kt";
328
+ const SMOKE_REL = "qa/e2e/smoke.yaml";
329
+
330
+ /**
331
+ * Rewrite every tab-driven surface present in the copied project dir. Runs
332
+ * BEFORE token replacement and the package rename (files are addressed at
333
+ * their literal com/example/app template paths and written in token form).
334
+ * Surfaces missing from the tree (feature-stripped qa/e2e, synthetic test
335
+ * templates) are skipped; a PRESENT AppNavHost that no longer contains the
336
+ * expected blocks throws.
337
+ * @param {string} projectDir
338
+ * @param {Array<{label:string, icon:string}>} tabs
339
+ * @param {(msg:string)=>void} [log]
340
+ */
341
+ export function rewriteTabSurfaces(projectDir, tabs, log = () => {}) {
342
+ const infos = tabInfos(tabs);
343
+
344
+ const appTabPath = path.join(projectDir, APPTAB_REL);
345
+ if (fs.existsSync(appTabPath)) {
346
+ fs.writeFileSync(appTabPath, renderAppTabsKt(infos));
347
+ log(` tabs → ${APPTAB_REL}`);
348
+ }
349
+
350
+ const navHostPath = path.join(projectDir, NAVHOST_REL);
351
+ if (fs.existsSync(navHostPath)) {
352
+ const rewritten = rewriteNavHost(fs.readFileSync(navHostPath, "utf8"), infos);
353
+ fs.writeFileSync(navHostPath, rewritten);
354
+ log(` tabs → ${NAVHOST_REL}`);
355
+ }
356
+
357
+ if (infos.some((t) => t.slug !== "home" && t.slug !== "profile")) {
358
+ const placeholderPath = path.join(projectDir, PLACEHOLDER_REL);
359
+ if (fs.existsSync(path.dirname(placeholderPath))) {
360
+ fs.writeFileSync(placeholderPath, renderPlaceholderScreenKt());
361
+ log(` tabs → ${PLACEHOLDER_REL}`);
362
+ }
363
+ }
364
+
365
+ const smokePath = path.join(projectDir, SMOKE_REL);
366
+ if (fs.existsSync(smokePath)) {
367
+ fs.writeFileSync(smokePath, renderSmokeYaml(infos));
368
+ log(` tabs → ${SMOKE_REL}`);
369
+ }
370
+ }
package/src/scaffold.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  // (b) copy template/ → targetDir
4
4
  // (b.2) delete disabled-feature paths (MUST precede the package rename —
5
5
  // feature paths are declared against the literal com/example/app)
6
+ // (b.3) regenerate tab-driven surfaces (AppTab.kt, AppNavHost wiring,
7
+ // qa/e2e/smoke.yaml) from config.tabs, in template-token form
6
8
  // (c) token-replace contents AND paths
7
9
  // (d) rename package source dirs com/example/app → __PACKAGE_PATH__ (atomic)
8
10
  // (e) toggle features (strip cmp:feature marker blocks)
@@ -23,6 +25,7 @@ import {
23
25
  disabledFeaturesFromConfig,
24
26
  deleteDisabledFeaturePaths,
25
27
  } from "./lib/toggle.mjs";
28
+ import { rewriteTabSurfaces } from "./lib/tabs.mjs";
26
29
  import { copyDir, listFiles, listDirsDeepestFirst } from "./lib/fsutil.mjs";
27
30
  import { runVerify, printVerifyVerdict } from "./lib/verify.mjs";
28
31
  import { colors, step, ok, warn } from "./lib/log.mjs";
@@ -261,6 +264,14 @@ export async function scaffold(config, opts = {}) {
261
264
  );
262
265
  }
263
266
 
267
+ // (b.3) Regenerate the tab-driven surfaces from config.tabs BEFORE token
268
+ // replacement and the package rename: the rewriter addresses files at their
269
+ // literal com/example/app template paths and writes template-token contents,
270
+ // so the pipeline below stamps them like any other template file. For the
271
+ // default tabs this reproduces the static template files byte-for-byte.
272
+ step("Regenerating tab surfaces from configured tabs…");
273
+ rewriteTabSurfaces(projectDir, config.tabs, (m) => process.stdout.write(`${m}\n`));
274
+
264
275
  const tokenMap = buildTokenMap(config);
265
276
 
266
277
  // (c) token-replace contents AND paths
@@ -101,3 +101,7 @@ remember to run it.
101
101
  locally. Note that CI independently enforces the same "receipt attests HEAD" check on every push
102
102
  (see `.github/workflows/verify.yml`), so disabling the local hook only trades an immediate local
103
103
  signal for a later one in CI.
104
+
105
+ ---
106
+
107
+ Built with [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) — the AI delivery harness for Compose Multiplatform.
@@ -114,7 +114,7 @@ private fun AppBottomNav(
114
114
  }
115
115
  }
116
116
 
117
- /** Deterministic automation tag for a nav item: `nav_` + the label lowercased with every non-[a-z0-9] run collapsed to `_` and trimmed (e.g. "My Stuff!" → `nav_my_stuff`). Mirrored by the id selectors in `qa/e2e/smoke.yaml` — keep the two in sync. */
117
+ /** Deterministic automation tag for a nav item: `nav_` + the label lowercased with every non-[a-z0-9] run collapsed to `_` and trimmed (e.g. "My Stuff!" → `nav_my_stuff`). Must mirror `navSlug` in create-cmp's engine (src/lib/tabs.mjs), which generates `qa/e2e/smoke.yaml`'s id selectors from the configured tabs — keep the two in sync. */
118
118
  private fun navItemTag(label: String): String =
119
119
  "nav_" + label.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
120
120