create-cmp-cli 0.2.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/LICENSE +21 -0
- package/README.md +232 -0
- package/bin/create-cmp.mjs +91 -0
- package/options.schema.json +110 -0
- package/package.json +52 -0
- package/src/bootstrap/checks.mjs +283 -0
- package/src/bootstrap/exec.mjs +90 -0
- package/src/commands/clean.mjs +195 -0
- package/src/commands/create.mjs +223 -0
- package/src/commands/doctor.mjs +256 -0
- package/src/commands/upgrade.mjs +196 -0
- package/src/commands/verify.mjs +94 -0
- package/src/doctor.mjs +109 -0
- package/src/lib/args.mjs +38 -0
- package/src/lib/clean.mjs +72 -0
- package/src/lib/fsutil.mjs +90 -0
- package/src/lib/log.mjs +40 -0
- package/src/lib/project-doctor.mjs +329 -0
- package/src/lib/registry.mjs +102 -0
- package/src/lib/rename.mjs +116 -0
- package/src/lib/schema.mjs +123 -0
- package/src/lib/toggle.mjs +130 -0
- package/src/lib/tokens.mjs +96 -0
- package/src/lib/toml.mjs +137 -0
- package/src/lib/upgrade.mjs +159 -0
- package/src/lib/verify.mjs +97 -0
- package/src/scaffold.mjs +347 -0
- package/src/verify.mjs +4 -0
- package/src/versions/registry.json +42 -0
- package/template/.github/workflows/verify.yml +70 -0
- package/template/build.gradle.kts +17 -0
- package/template/composeApp/build.gradle.kts +257 -0
- package/template/composeApp/google-services.json +29 -0
- package/template/composeApp/proguard-rules.pro +34 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +13 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/ComposeRootRegistry.kt +40 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +80 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +322 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +16 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +102 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/RemoteControlPage.kt +106 -0
- package/template/composeApp/src/androidDebug/res/xml/debug_network_security_config.xml +9 -0
- package/template/composeApp/src/androidMain/AndroidManifest.xml +28 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/AppApplication.kt +72 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/MainActivity.kt +24 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +42 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/data/local/DatabaseBuilder.android.kt +13 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/di/AndroidModule.kt +9 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/presentation/components/TestTagAutomation.android.kt +11 -0
- package/template/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +9 -0
- package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
- package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/values/colors.xml +6 -0
- package/template/composeApp/src/androidMain/res/values/themes.xml +9 -0
- package/template/composeApp/src/androidMain/res/values-v31/themes.xml +8 -0
- package/template/composeApp/src/androidRelease/kotlin/com/example/app/inspector/InspectorInit.kt +13 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_Bold.ttf +0 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_Medium.ttf +0 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_Regular.ttf +0 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_SemiBold.ttf +0 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/App.kt +7 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +7 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/AppDatabase.kt +28 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/DatabaseBuilder.kt +14 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/ItemDao.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/FirebaseConfig.kt +5 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +22 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/Item.kt +8 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +8 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +12 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/App.kt +13 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +69 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +39 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +87 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +35 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +43 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +144 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppTab.kt +25 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +15 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +43 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/DesignToken.kt +25 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Shape.kt +12 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Theme.kt +64 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Tokens.kt +19 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +36 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.desktop.kt +9 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/data/local/DatabaseBuilder.desktop.kt +13 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/di/DesktopModule.kt +32 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/main.kt +29 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/presentation/components/TestTagAutomation.desktop.kt +6 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/KoinHelper.kt +50 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/MainViewController.kt +10 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +32 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/data/local/DatabaseBuilder.ios.kt +10 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/presentation/components/TestTagAutomation.ios.kt +11 -0
- package/template/docs/dev-client.md +52 -0
- package/template/gitignore +21 -0
- package/template/gradle/libs.versions.toml +100 -0
- package/template/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/template/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/template/gradle.properties +9 -0
- package/template/gradlew +152 -0
- package/template/gradlew.bat +94 -0
- package/template/iosApp/Podfile +27 -0
- package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png +0 -0
- package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/template/iosApp/iosApp/Assets.xcassets/Contents.json +6 -0
- package/template/iosApp/iosApp/ContentView.swift +17 -0
- package/template/iosApp/iosApp/GoogleService-Info.plist +34 -0
- package/template/iosApp/iosApp/Info.plist +43 -0
- package/template/iosApp/iosApp/PrivacyInfo.xcprivacy +42 -0
- package/template/iosApp/iosApp/iOSApp.swift +33 -0
- package/template/iosApp/project.yml +56 -0
- package/template/local.properties.example +2 -0
- package/template/manifest.json +125 -0
- package/template/qa/appium/README.md +23 -0
- package/template/qa/appium/lib/appium-client.mjs +225 -0
- package/template/qa/appium/package.json +8 -0
- package/template/qa/appium/run-android-smoke.mjs +39 -0
- package/template/settings.gradle.kts +34 -0
- package/template/tests/appium/cmp/conftest.py +96 -0
- package/template/tests/appium/cmp/test_smoke.py +17 -0
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Scaffold pipeline (CONTRACT §"Scaffold pipeline"):
|
|
2
|
+
// (a) validate config against options.schema.json
|
|
3
|
+
// (b) copy template/ → targetDir
|
|
4
|
+
// (b.2) delete disabled-feature paths (MUST precede the package rename —
|
|
5
|
+
// feature paths are declared against the literal com/example/app)
|
|
6
|
+
// (c) token-replace contents AND paths
|
|
7
|
+
// (d) rename package source dirs com/example/app → __PACKAGE_PATH__ (atomic)
|
|
8
|
+
// (e) toggle features (strip cmp:feature marker blocks)
|
|
9
|
+
// (f) run the verify gate
|
|
10
|
+
//
|
|
11
|
+
// No LLM in this hot path — pure deterministic Node.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { validate, formatErrors } from "./lib/schema.mjs";
|
|
19
|
+
import { buildTokenMap, replaceTokens, replacePathTokens, isBinaryPath, slugifyAppName } from "./lib/tokens.mjs";
|
|
20
|
+
import { renamePackageDirs } from "./lib/rename.mjs";
|
|
21
|
+
import {
|
|
22
|
+
stripFeatureBlocks,
|
|
23
|
+
disabledFeaturesFromConfig,
|
|
24
|
+
deleteDisabledFeaturePaths,
|
|
25
|
+
} from "./lib/toggle.mjs";
|
|
26
|
+
import { copyDir, listFiles, listDirsDeepestFirst } from "./lib/fsutil.mjs";
|
|
27
|
+
import { runVerify, printVerifyVerdict } from "./lib/verify.mjs";
|
|
28
|
+
import { colors, step, ok, warn } from "./lib/log.mjs";
|
|
29
|
+
|
|
30
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const REPO_ROOT = path.resolve(__dirname, "..");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} opts
|
|
35
|
+
* @param {string} [opts.schemaPath] override path to options.schema.json
|
|
36
|
+
* @param {string} [opts.templateDir] override path to template/
|
|
37
|
+
*/
|
|
38
|
+
export function loadSchema(opts = {}) {
|
|
39
|
+
const schemaPath = opts.schemaPath || path.join(REPO_ROOT, "options.schema.json");
|
|
40
|
+
return JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate the engine config. Throws a descriptive error on failure.
|
|
45
|
+
* @param {object} config
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
*/
|
|
48
|
+
export function validateConfig(config, opts = {}) {
|
|
49
|
+
const schema = loadSchema(opts);
|
|
50
|
+
const { valid, errors } = validate(config, schema);
|
|
51
|
+
if (!valid) {
|
|
52
|
+
const err = new Error(`Invalid config:\n${formatErrors(errors)}`);
|
|
53
|
+
err.validationErrors = errors;
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load template/manifest.json.
|
|
61
|
+
* @param {string} templateDir
|
|
62
|
+
*/
|
|
63
|
+
export function loadManifest(templateDir) {
|
|
64
|
+
const p = path.join(templateDir, "manifest.json");
|
|
65
|
+
if (!fs.existsSync(p)) {
|
|
66
|
+
throw new Error(`template manifest not found at ${p}`);
|
|
67
|
+
}
|
|
68
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply token replacement to every text file's CONTENT under projectDir.
|
|
73
|
+
*/
|
|
74
|
+
function replaceContents(projectDir, tokenMap, manifestRel) {
|
|
75
|
+
for (const file of listFiles(projectDir)) {
|
|
76
|
+
// Never rewrite the manifest we copied in (and we delete it at the end).
|
|
77
|
+
if (manifestRel && path.resolve(file) === path.resolve(path.join(projectDir, manifestRel))) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (isBinaryPath(file)) continue;
|
|
81
|
+
let content;
|
|
82
|
+
try {
|
|
83
|
+
content = fs.readFileSync(file, "utf8");
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const replaced = replaceTokens(content, tokenMap);
|
|
88
|
+
if (replaced !== content) {
|
|
89
|
+
fs.writeFileSync(file, replaced);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rename directories whose names contain tokens (PATH replacement), deepest
|
|
96
|
+
* first so nested renames don't invalidate parent paths. Note: the package
|
|
97
|
+
* source dirs are renamed separately/atomically by renamePackageDirs against
|
|
98
|
+
* the literal com/example/app segment — here we only handle token-bearing
|
|
99
|
+
* path segments like __APP_NAME__ in a folder name.
|
|
100
|
+
*/
|
|
101
|
+
function replaceDirPaths(projectDir, tokenMap) {
|
|
102
|
+
for (const dir of listDirsDeepestFirst(projectDir)) {
|
|
103
|
+
const base = path.basename(dir);
|
|
104
|
+
const renamedBase = replacePathTokens(base, tokenMap);
|
|
105
|
+
if (renamedBase !== base) {
|
|
106
|
+
const target = path.join(path.dirname(dir), renamedBase);
|
|
107
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
108
|
+
if (fs.existsSync(target)) {
|
|
109
|
+
// Merge into existing target.
|
|
110
|
+
for (const e of fs.readdirSync(dir)) {
|
|
111
|
+
fs.renameSync(path.join(dir, e), path.join(target, e));
|
|
112
|
+
}
|
|
113
|
+
fs.rmdirSync(dir);
|
|
114
|
+
} else {
|
|
115
|
+
fs.renameSync(dir, target);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Rename token-bearing FILE names (path replacement for files).
|
|
123
|
+
*/
|
|
124
|
+
function replaceFilePaths(projectDir, tokenMap) {
|
|
125
|
+
for (const file of listFiles(projectDir)) {
|
|
126
|
+
const base = path.basename(file);
|
|
127
|
+
const renamedBase = replacePathTokens(base, tokenMap);
|
|
128
|
+
if (renamedBase !== base) {
|
|
129
|
+
const target = path.join(path.dirname(file), renamedBase);
|
|
130
|
+
fs.renameSync(file, target);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Strip cmp:feature marker blocks for disabled features from every text file.
|
|
137
|
+
* Always runs (even with an empty disabled set) so that ENABLED features' marker
|
|
138
|
+
* comment lines are also removed — the shipped output must carry no marker noise.
|
|
139
|
+
*/
|
|
140
|
+
function stripDisabledBlocks(projectDir, disabled) {
|
|
141
|
+
for (const file of listFiles(projectDir)) {
|
|
142
|
+
if (isBinaryPath(file)) continue;
|
|
143
|
+
let content;
|
|
144
|
+
try {
|
|
145
|
+
content = fs.readFileSync(file, "utf8");
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!content.includes("cmp:feature")) continue;
|
|
150
|
+
const { content: out, changed } = stripFeatureBlocks(content, disabled);
|
|
151
|
+
if (changed) fs.writeFileSync(file, out);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve the Android SDK location from the environment or the conventional
|
|
157
|
+
* install path, then write `local.properties` (sdk.dir) into the project so
|
|
158
|
+
* Gradle can locate the SDK. No-op if the file already exists (respect a
|
|
159
|
+
* user-provided one) or if no SDK dir can be found and env vars are unset.
|
|
160
|
+
* @param {string} projectDir
|
|
161
|
+
*/
|
|
162
|
+
function writeLocalProperties(projectDir) {
|
|
163
|
+
const target = path.join(projectDir, "local.properties");
|
|
164
|
+
if (fs.existsSync(target)) return;
|
|
165
|
+
|
|
166
|
+
const envHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
167
|
+
const conventional = path.join(os.homedir(), "Library", "Android", "sdk");
|
|
168
|
+
let sdkDir = null;
|
|
169
|
+
if (envHome && fs.existsSync(envHome)) {
|
|
170
|
+
sdkDir = envHome;
|
|
171
|
+
} else if (fs.existsSync(conventional)) {
|
|
172
|
+
sdkDir = conventional;
|
|
173
|
+
} else if (fs.existsSync(path.join(os.homedir(), "Android", "Sdk"))) {
|
|
174
|
+
sdkDir = path.join(os.homedir(), "Android", "Sdk"); // Linux default
|
|
175
|
+
}
|
|
176
|
+
// If env vars are set, Gradle resolves the SDK on its own; only write the
|
|
177
|
+
// file when we actually located a directory.
|
|
178
|
+
if (!sdkDir) return;
|
|
179
|
+
fs.writeFileSync(target, `sdk.dir=${sdkDir}\n`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Replace the (already token-substituted) app display name with a slugified,
|
|
184
|
+
* identifier-safe form in the few files that demand it: Gradle's
|
|
185
|
+
* rootProject.name and the appium npm package name. Idempotent and a no-op when
|
|
186
|
+
* the name is already slug-safe.
|
|
187
|
+
* @param {string} projectDir
|
|
188
|
+
* @param {string} appName raw display name (may contain spaces)
|
|
189
|
+
*/
|
|
190
|
+
function applyAppNameSlug(projectDir, appName) {
|
|
191
|
+
const slug = slugifyAppName(appName);
|
|
192
|
+
if (slug === appName) return; // already safe — nothing to do
|
|
193
|
+
|
|
194
|
+
// settings.gradle.kts: rootProject.name = "Demo App" -> "Demo-App"
|
|
195
|
+
const settings = path.join(projectDir, "settings.gradle.kts");
|
|
196
|
+
if (fs.existsSync(settings)) {
|
|
197
|
+
const src = fs.readFileSync(settings, "utf8");
|
|
198
|
+
const out = src.replace(
|
|
199
|
+
/(rootProject\.name\s*=\s*")([^"]*)(")/,
|
|
200
|
+
(_m, a, _name, c) => `${a}${slug}${c}`
|
|
201
|
+
);
|
|
202
|
+
if (out !== src) fs.writeFileSync(settings, out);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// qa/appium/package.json: "name": "Demo App-appium" -> "demo-app-appium"
|
|
206
|
+
const pkg = path.join(projectDir, "qa", "appium", "package.json");
|
|
207
|
+
if (fs.existsSync(pkg)) {
|
|
208
|
+
try {
|
|
209
|
+
const json = JSON.parse(fs.readFileSync(pkg, "utf8"));
|
|
210
|
+
if (typeof json.name === "string" && json.name.includes(appName)) {
|
|
211
|
+
json.name = json.name.split(appName).join(slug).toLowerCase();
|
|
212
|
+
fs.writeFileSync(pkg, `${JSON.stringify(json, null, 2)}\n`);
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// leave as-is on parse failure
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run the full scaffold pipeline.
|
|
222
|
+
* @param {object} config engine config object (CONTRACT)
|
|
223
|
+
* @param {object} [opts]
|
|
224
|
+
* @param {string} [opts.templateDir]
|
|
225
|
+
* @param {string} [opts.schemaPath]
|
|
226
|
+
* @param {boolean} [opts.verify=true]
|
|
227
|
+
* @param {boolean} [opts.dryRunVerify=false]
|
|
228
|
+
* @param {boolean} [opts.force=false] allow non-empty targetDir
|
|
229
|
+
* @returns {Promise<{projectDir:string, verdict:(object|null), manifest:object}>}
|
|
230
|
+
*/
|
|
231
|
+
export async function scaffold(config, opts = {}) {
|
|
232
|
+
const templateDir = opts.templateDir || path.join(REPO_ROOT, "template");
|
|
233
|
+
const doVerify = opts.verify !== false;
|
|
234
|
+
|
|
235
|
+
// (a) validate
|
|
236
|
+
step("Validating config…");
|
|
237
|
+
validateConfig(config, { schemaPath: opts.schemaPath });
|
|
238
|
+
|
|
239
|
+
if (!fs.existsSync(templateDir)) {
|
|
240
|
+
throw new Error(`template directory not found at ${templateDir}`);
|
|
241
|
+
}
|
|
242
|
+
const manifest = loadManifest(templateDir);
|
|
243
|
+
|
|
244
|
+
const projectDir = path.resolve(config.targetDir);
|
|
245
|
+
if (fs.existsSync(projectDir)) {
|
|
246
|
+
const entries = fs.readdirSync(projectDir).filter((e) => e !== "." && e !== "..");
|
|
247
|
+
if (entries.length > 0 && !opts.force) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`target directory ${projectDir} is not empty (pass force to overwrite)`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// (b) copy template → targetDir
|
|
255
|
+
step(`Copying template → ${colors.cyan(projectDir)}`);
|
|
256
|
+
copyDir(templateDir, projectDir);
|
|
257
|
+
|
|
258
|
+
// (b.1) `npm pack` ALWAYS strips files named `.gitignore` from published
|
|
259
|
+
// tarballs, so the template ships it as `gitignore` (no dot) and the stamp
|
|
260
|
+
// restores the real name here (the standard scaffolder workaround).
|
|
261
|
+
const bareGitignore = path.join(projectDir, "gitignore");
|
|
262
|
+
if (fs.existsSync(bareGitignore)) {
|
|
263
|
+
fs.renameSync(bareGitignore, path.join(projectDir, ".gitignore"));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// (b.2) Delete disabled-feature PATHS now, BEFORE the package-dir rename.
|
|
267
|
+
// Manifest feature paths are declared with the literal `com/example/app`
|
|
268
|
+
// segment; deleting after renamePackageDirs silently missed every
|
|
269
|
+
// package-rooted path (the --no-room bug: Room deps/plugin stripped but the
|
|
270
|
+
// data/local sources left behind → unresolved androidx.room at build time).
|
|
271
|
+
const disabled = disabledFeaturesFromConfig(config);
|
|
272
|
+
if (disabled.size > 0) {
|
|
273
|
+
step(`Deleting disabled-feature paths: ${[...disabled].join(", ")}`);
|
|
274
|
+
deleteDisabledFeaturePaths(projectDir, manifest, disabled, (m) =>
|
|
275
|
+
process.stdout.write(`${m}\n`)
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const tokenMap = buildTokenMap(config);
|
|
280
|
+
|
|
281
|
+
// (c) token-replace contents AND paths
|
|
282
|
+
step("Replacing tokens (file contents)…");
|
|
283
|
+
replaceContents(projectDir, tokenMap, "manifest.json");
|
|
284
|
+
|
|
285
|
+
// (d) rename package source dirs (ATOMIC) — do this BEFORE generic path
|
|
286
|
+
// token replace so the literal com/example/app is intact.
|
|
287
|
+
step("Renaming package source directories…");
|
|
288
|
+
const packageRoots = manifest.packageSourceRoots || [];
|
|
289
|
+
const packagePath = config.package.replace(/\./g, "/");
|
|
290
|
+
renamePackageDirs(projectDir, packageRoots, packagePath, {
|
|
291
|
+
log: (m) => process.stdout.write(`${m}\n`),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// (c, paths) token-replace remaining token-bearing dir + file names
|
|
295
|
+
step("Replacing tokens (paths)…");
|
|
296
|
+
replaceDirPaths(projectDir, tokenMap);
|
|
297
|
+
replaceFilePaths(projectDir, tokenMap);
|
|
298
|
+
|
|
299
|
+
// (c.1) Slugify the app name where a space-free identifier is required.
|
|
300
|
+
// The display name (__APP_NAME__) may contain spaces (Android label, iOS
|
|
301
|
+
// CFBundleDisplayName), but Gradle rootProject.name and the npm package name
|
|
302
|
+
// must not. See manifest stampPipeline step 5.
|
|
303
|
+
applyAppNameSlug(projectDir, config.appName);
|
|
304
|
+
|
|
305
|
+
// (e) toggle feature MARKER BLOCKS. Always strip marker comments (even for
|
|
306
|
+
// enabled features) so no marker noise ships. (Disabled-feature paths were
|
|
307
|
+
// already deleted in (b.2), before the package rename — see above.)
|
|
308
|
+
step(
|
|
309
|
+
disabled.size > 0
|
|
310
|
+
? `Toggling features off: ${[...disabled].join(", ")}`
|
|
311
|
+
: "Cleaning feature markers…"
|
|
312
|
+
);
|
|
313
|
+
stripDisabledBlocks(projectDir, disabled);
|
|
314
|
+
|
|
315
|
+
// Write local.properties (sdk.dir) so the Gradle build can find the Android
|
|
316
|
+
// SDK even when ANDROID_HOME/ANDROID_SDK_ROOT aren't exported (manifest
|
|
317
|
+
// stampPipeline step 7). Skip silently if no SDK is found and env vars are
|
|
318
|
+
// unset — the verify step will surface the real error.
|
|
319
|
+
writeLocalProperties(projectDir);
|
|
320
|
+
|
|
321
|
+
// Drop engine-only artifacts from the stamped output (the manifest and any
|
|
322
|
+
// template extraction notes — engine-only handoff files that are not part of
|
|
323
|
+
// a generated app).
|
|
324
|
+
for (const artifact of ["manifest.json", "EXTRACTION-NOTES.md"]) {
|
|
325
|
+
const abs = path.join(projectDir, artifact);
|
|
326
|
+
if (fs.existsSync(abs)) fs.rmSync(abs);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
ok("Scaffold complete.");
|
|
330
|
+
|
|
331
|
+
// (f) verify gate
|
|
332
|
+
let verdict = null;
|
|
333
|
+
if (doVerify) {
|
|
334
|
+
step("Running verify gate (north-star: prove GREEN)…");
|
|
335
|
+
verdict = await runVerify({
|
|
336
|
+
projectDir,
|
|
337
|
+
manifest,
|
|
338
|
+
config,
|
|
339
|
+
dryRun: !!opts.dryRunVerify,
|
|
340
|
+
});
|
|
341
|
+
printVerifyVerdict(verdict);
|
|
342
|
+
} else {
|
|
343
|
+
warn("Verify skipped (--no-verify): GREEN build is NOT proven.");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { projectDir, verdict, manifest };
|
|
347
|
+
}
|
package/src/verify.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Ordered registry of PROVEN-GREEN CMP version sets. Entries are ordered oldest → newest; the LAST entry is the default `create-cmp upgrade` target. A set is only added after it builds green on Android + iOS in CI (the canary feeds this file). Keys under `versions` mirror the [versions] table of the golden template's gradle/libs.versions.toml.",
|
|
3
|
+
"sets": [
|
|
4
|
+
{
|
|
5
|
+
"id": "2026.06",
|
|
6
|
+
"label": "Frozen, CI-verified set shipped with create-cmp 0.1.x",
|
|
7
|
+
"status": "proven-green",
|
|
8
|
+
"versions": {
|
|
9
|
+
"kotlin": "2.2.20",
|
|
10
|
+
"agp": "8.7.3",
|
|
11
|
+
"google-services": "4.4.2",
|
|
12
|
+
"compose-multiplatform": "1.10.3",
|
|
13
|
+
"koin": "4.1.1",
|
|
14
|
+
"ktor": "3.1.0",
|
|
15
|
+
"room": "2.8.4",
|
|
16
|
+
"sqlite": "2.6.2",
|
|
17
|
+
"ksp": "2.2.20-2.0.4",
|
|
18
|
+
"kotlinx-serialization": "1.7.3",
|
|
19
|
+
"kotlinx-datetime": "0.7.0",
|
|
20
|
+
"lifecycle": "2.10.0",
|
|
21
|
+
"navigation": "2.9.2",
|
|
22
|
+
"firebase-gitlive": "2.1.0",
|
|
23
|
+
"coil": "3.1.0",
|
|
24
|
+
"androidx-core": "1.15.0",
|
|
25
|
+
"androidx-activity": "1.10.1"
|
|
26
|
+
},
|
|
27
|
+
"gradleProperties": {
|
|
28
|
+
"ksp.useKSP2": "true"
|
|
29
|
+
},
|
|
30
|
+
"gradleWrapper": {
|
|
31
|
+
"version": "8.11.1",
|
|
32
|
+
"distributionUrl": "https://services.gradle.org/distributions/gradle-8.11.1-bin.zip"
|
|
33
|
+
},
|
|
34
|
+
"notes": [
|
|
35
|
+
"KSP must be `<kotlin>-<kspVersion>`: kotlin 2.2.20 pairs with ksp 2.2.20-2.0.4. Never bump one without the other — Kotlin / KSP / Compose / Room / AGP move as ONE set or the build dies.",
|
|
36
|
+
"ksp.useKSP2=true is REQUIRED when Room's KSP processor runs on Kotlin/Native (iOS): KSP1 fails with `ClassNotFoundException: org.jetbrains.kotlin.cli.utilities.MainKt` (the KSP2/iOS catch-22).",
|
|
37
|
+
"Room 2.8.4 + sqlite-bundled 2.6.2 is the KMP-native pairing; Room < 2.7 has no Kotlin/Native support at all.",
|
|
38
|
+
"Gradle wrapper 8.11.1 matches AGP 8.7.3 (AGP 8.7 requires Gradle >= 8.9)."
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# CI for your Compose Multiplatform app — stamped in by create-cmp.
|
|
2
|
+
#
|
|
3
|
+
# What runs on every push/PR: a toolchain report (advisory) and the Android
|
|
4
|
+
# debug build — the same "verify gate" create-cmp ran when it generated this
|
|
5
|
+
# project. Green here = your frozen version set still builds.
|
|
6
|
+
#
|
|
7
|
+
# iOS: a ready-to-enable macOS job is included (commented out) at the bottom.
|
|
8
|
+
# macOS runners cost ~10x Linux minutes, so it's opt-in.
|
|
9
|
+
|
|
10
|
+
name: Verify
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches: [main]
|
|
15
|
+
pull_request:
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
android:
|
|
19
|
+
runs-on: ubuntu-latest # ships with JDK + Android SDK preinstalled
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-java@v4
|
|
24
|
+
with:
|
|
25
|
+
distribution: temurin
|
|
26
|
+
java-version: 17 # the version set is pinned against JDK 17
|
|
27
|
+
|
|
28
|
+
# Caches Gradle + the configuration cache between runs (big speedup).
|
|
29
|
+
- uses: gradle/actions/setup-gradle@v4
|
|
30
|
+
|
|
31
|
+
# Advisory toolchain report from create-cmp's doctor. Non-blocking:
|
|
32
|
+
# doctor also checks emulator/AVD bits that assembleDebug doesn't need,
|
|
33
|
+
# so a red doctor on a CI runner is informative, not fatal.
|
|
34
|
+
- name: Toolchain doctor (advisory)
|
|
35
|
+
continue-on-error: true
|
|
36
|
+
run: npx --yes create-cmp-cli@latest doctor --yes --no-install --no-ios
|
|
37
|
+
|
|
38
|
+
# The verify gate. If this fails after a dependency bump, remember:
|
|
39
|
+
# Kotlin / KSP / Compose / Room / AGP move as ONE set (see the comments
|
|
40
|
+
# in gradle/libs.versions.toml).
|
|
41
|
+
- name: Build Android (debug)
|
|
42
|
+
run: ./gradlew :composeApp:assembleDebug --stacktrace
|
|
43
|
+
|
|
44
|
+
# ── iOS (opt-in) ──────────────────────────────────────────────────────────
|
|
45
|
+
# Uncomment to build the iOS app on every push to main. Uses the exact
|
|
46
|
+
# command set create-cmp verified this template with: XcodeGen + CocoaPods
|
|
47
|
+
# (LANG must be UTF-8) + an arm64-only simulator build (the Kotlin framework
|
|
48
|
+
# embed phase emits only the active arch — a generic destination fails).
|
|
49
|
+
#
|
|
50
|
+
# ios:
|
|
51
|
+
# runs-on: macos-14
|
|
52
|
+
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
53
|
+
# steps:
|
|
54
|
+
# - uses: actions/checkout@v4
|
|
55
|
+
# - uses: actions/setup-java@v4
|
|
56
|
+
# with:
|
|
57
|
+
# distribution: temurin
|
|
58
|
+
# java-version: 17
|
|
59
|
+
# - uses: gradle/actions/setup-gradle@v4
|
|
60
|
+
# - run: brew install xcodegen # CocoaPods is preinstalled on macos-14
|
|
61
|
+
# - name: Build iOS (simulator)
|
|
62
|
+
# run: |
|
|
63
|
+
# cd iosApp
|
|
64
|
+
# export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
|
|
65
|
+
# xcodegen generate
|
|
66
|
+
# pod install
|
|
67
|
+
# SIM_UDID=$(xcrun simctl list devices available | grep -Eo '[0-9A-F-]{36}' | head -1)
|
|
68
|
+
# xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -sdk iphonesimulator \
|
|
69
|
+
# -configuration Debug ARCHS=arm64 ONLY_ACTIVE_ARCH=YES EXCLUDED_ARCHS=x86_64 \
|
|
70
|
+
# -destination "id=$SIM_UDID" build
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
alias(libs.plugins.kotlin.multiplatform) apply false
|
|
3
|
+
alias(libs.plugins.kotlin.serialization) apply false
|
|
4
|
+
alias(libs.plugins.compose.multiplatform) apply false
|
|
5
|
+
alias(libs.plugins.compose.compiler) apply false
|
|
6
|
+
alias(libs.plugins.android.application) apply false
|
|
7
|
+
// >>> cmp:feature firebase
|
|
8
|
+
alias(libs.plugins.google.services) apply false
|
|
9
|
+
// <<< cmp:feature firebase
|
|
10
|
+
// >>> cmp:feature room
|
|
11
|
+
alias(libs.plugins.ksp) apply false
|
|
12
|
+
alias(libs.plugins.room) apply false
|
|
13
|
+
// <<< cmp:feature room
|
|
14
|
+
// >>> cmp:feature dev-client
|
|
15
|
+
alias(libs.plugins.compose.hot.reload) apply false
|
|
16
|
+
// <<< cmp:feature dev-client
|
|
17
|
+
}
|