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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Feature toggling: strip `cmp:feature <name>` marker blocks for DISABLED
|
|
2
|
+
// features from file contents, and delete the feature's manifest `paths`.
|
|
3
|
+
//
|
|
4
|
+
// Marker syntax (CONTRACT.md):
|
|
5
|
+
// // >>> cmp:feature ios
|
|
6
|
+
// ...lines to remove when `ios` is disabled...
|
|
7
|
+
// // <<< cmp:feature ios
|
|
8
|
+
//
|
|
9
|
+
// The comment leader is language-agnostic — we match the `>>> cmp:feature` /
|
|
10
|
+
// `<<< cmp:feature` tokens anywhere on the line, so `//`, `#`, `<!--`, `;` etc.
|
|
11
|
+
// all work. Blocks may nest for different features; we strip per-feature.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
// The captured name may carry a leading `!` (negation): a `!foo` block is kept
|
|
17
|
+
// ONLY when feature `foo` is DISABLED (and removed when enabled) — the inverse
|
|
18
|
+
// of a plain `foo` block. The `!` is part of the capture so both open and close
|
|
19
|
+
// markers agree on the same token.
|
|
20
|
+
const OPEN_RE = />>>\s*cmp:feature\s+(!?[A-Za-z0-9_-]+)/;
|
|
21
|
+
const CLOSE_RE = /<<<\s*cmp:feature\s+(!?[A-Za-z0-9_-]+)/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip marker blocks for the given disabled feature names from a single
|
|
25
|
+
* text body. Lines containing the open/close markers for those features are
|
|
26
|
+
* removed along with their enclosed content. Markers for STILL-ENABLED
|
|
27
|
+
* features are left untouched (their marker comment lines are also removed,
|
|
28
|
+
* so the shipped output has no leftover marker noise).
|
|
29
|
+
*
|
|
30
|
+
* @param {string} content
|
|
31
|
+
* @param {Set<string>|string[]} disabledFeatures features to strip
|
|
32
|
+
* @returns {{content: string, changed: boolean}}
|
|
33
|
+
*/
|
|
34
|
+
export function stripFeatureBlocks(content, disabledFeatures) {
|
|
35
|
+
const disabled = disabledFeatures instanceof Set
|
|
36
|
+
? disabledFeatures
|
|
37
|
+
: new Set(disabledFeatures);
|
|
38
|
+
|
|
39
|
+
const lines = content.split("\n");
|
|
40
|
+
const out = [];
|
|
41
|
+
// Stack of {feature, dropping} for currently-open blocks.
|
|
42
|
+
const stack = [];
|
|
43
|
+
let changed = false;
|
|
44
|
+
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const open = line.match(OPEN_RE);
|
|
47
|
+
const close = line.match(CLOSE_RE);
|
|
48
|
+
|
|
49
|
+
if (open) {
|
|
50
|
+
const token = open[1];
|
|
51
|
+
const negated = token.startsWith("!");
|
|
52
|
+
const feature = negated ? token.slice(1) : token;
|
|
53
|
+
const parentDropping = stack.some((f) => f.dropping);
|
|
54
|
+
// Plain block: drop when the feature is disabled.
|
|
55
|
+
// Negated (!feature) block: drop when the feature is ENABLED.
|
|
56
|
+
const selfDropping = negated ? !disabled.has(feature) : disabled.has(feature);
|
|
57
|
+
const dropping = parentDropping || selfDropping;
|
|
58
|
+
stack.push({ token, dropping });
|
|
59
|
+
changed = true; // marker comment line itself is always removed
|
|
60
|
+
continue; // never emit the marker line
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (close) {
|
|
64
|
+
const token = close[1];
|
|
65
|
+
// Pop the matching frame (search from top for safety).
|
|
66
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
67
|
+
if (stack[i].token === token) {
|
|
68
|
+
stack.splice(i, 1);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
changed = true; // marker comment line removed
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const dropping = stack.some((f) => f.dropping);
|
|
77
|
+
if (dropping) {
|
|
78
|
+
changed = true;
|
|
79
|
+
continue; // drop enclosed line
|
|
80
|
+
}
|
|
81
|
+
out.push(line);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { content: out.join("\n"), changed };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Map an engine config object to the set of DISABLED feature names that the
|
|
89
|
+
* manifest understands: ios, firebase, room, appium, inspector, dev-client.
|
|
90
|
+
* @param {object} config
|
|
91
|
+
* @returns {Set<string>}
|
|
92
|
+
*/
|
|
93
|
+
export function disabledFeaturesFromConfig(config) {
|
|
94
|
+
const disabled = new Set();
|
|
95
|
+
if (!config.platforms?.ios) disabled.add("ios");
|
|
96
|
+
if (!config.firebase?.enabled) disabled.add("firebase");
|
|
97
|
+
if (!config.room) disabled.add("room");
|
|
98
|
+
if (!config.appium) disabled.add("appium");
|
|
99
|
+
if (!config.inspector) disabled.add("inspector");
|
|
100
|
+
if (!config.devClient) disabled.add("dev-client");
|
|
101
|
+
return disabled;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Delete the filesystem paths declared for each disabled feature in the
|
|
106
|
+
* manifest. Paths are relative to projectDir; missing paths are ignored.
|
|
107
|
+
* @param {string} projectDir
|
|
108
|
+
* @param {object} manifest
|
|
109
|
+
* @param {Set<string>} disabledFeatures
|
|
110
|
+
* @param {(msg:string)=>void} [log]
|
|
111
|
+
* @returns {string[]} deleted relative paths
|
|
112
|
+
*/
|
|
113
|
+
export function deleteDisabledFeaturePaths(projectDir, manifest, disabledFeatures, log = () => {}) {
|
|
114
|
+
const deleted = [];
|
|
115
|
+
const features = manifest.features || {};
|
|
116
|
+
for (const name of disabledFeatures) {
|
|
117
|
+
const feature = features[name];
|
|
118
|
+
if (!feature || !Array.isArray(feature.paths)) continue;
|
|
119
|
+
for (const rel of feature.paths) {
|
|
120
|
+
if (rel === "..." || !rel) continue; // contract placeholder, skip
|
|
121
|
+
const abs = path.join(projectDir, rel);
|
|
122
|
+
if (fs.existsSync(abs)) {
|
|
123
|
+
fs.rmSync(abs, { recursive: true, force: true });
|
|
124
|
+
deleted.push(rel);
|
|
125
|
+
log(` removed ${rel} (feature '${name}' disabled)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return deleted;
|
|
130
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Token derivation + replacement for file CONTENTS and PATHS.
|
|
2
|
+
//
|
|
3
|
+
// Per CONTRACT.md, the engine replaces these placeholder tokens:
|
|
4
|
+
// __APP_NAME__ · __PACKAGE__ · __PACKAGE_PATH__ · __IOS_BUNDLE_ID__ · __REGION__ · __THEME_PREFIX__
|
|
5
|
+
//
|
|
6
|
+
// The replacement is applied to both the bytes of every text file and to path
|
|
7
|
+
// segments. Path replacement deliberately handles __PACKAGE_PATH__ even though
|
|
8
|
+
// the actual package-source directory rename (com/example/app → com/acme/app) is
|
|
9
|
+
// done separately by lib/rename.mjs against manifest.packageSourceRoots — see
|
|
10
|
+
// CONTRACT note that source dirs use the literal `com/example/app` segment.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the ordered token map from an engine config object.
|
|
14
|
+
* @param {object} config
|
|
15
|
+
* @returns {Array<[string,string]>} ordered [token, value] pairs
|
|
16
|
+
*/
|
|
17
|
+
export function buildTokenMap(config) {
|
|
18
|
+
const packagePath = config.package.replace(/\./g, "/");
|
|
19
|
+
return [
|
|
20
|
+
["__APP_NAME__", config.appName],
|
|
21
|
+
["__PACKAGE_PATH__", packagePath],
|
|
22
|
+
["__PACKAGE__", config.package],
|
|
23
|
+
["__IOS_BUNDLE_ID__", config.iosBundleId],
|
|
24
|
+
["__REGION__", config.region],
|
|
25
|
+
["__THEME_PREFIX__", config.themePrefix],
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Slugify an app display name into a Gradle/npm-safe identifier.
|
|
31
|
+
* Gradle's `rootProject.name` must match [a-zA-Z]([A-Za-z0-9\-_])* (no spaces);
|
|
32
|
+
* npm package names must be lowercase with no spaces. We collapse runs of
|
|
33
|
+
* non-[A-Za-z0-9] to a single dash, trim leading/trailing dashes, and guarantee
|
|
34
|
+
* a leading letter. e.g. "Demo App" -> "Demo-App", "42 Things!" -> "x42-Things".
|
|
35
|
+
* @param {string} name
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function slugifyAppName(name) {
|
|
39
|
+
let slug = String(name)
|
|
40
|
+
.trim()
|
|
41
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
42
|
+
.replace(/^-+|-+$/g, "");
|
|
43
|
+
if (slug === "") slug = "app";
|
|
44
|
+
if (!/^[A-Za-z]/.test(slug)) slug = `x${slug}`;
|
|
45
|
+
return slug;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Replace every token occurrence in a string.
|
|
50
|
+
* Tokens are literal (`__FOO__`) so a global string split/join is safe and
|
|
51
|
+
* avoids regex-escaping pitfalls. Order matters only when one token is a
|
|
52
|
+
* substring of another; our tokens are disjoint, but we still apply in the
|
|
53
|
+
* given order for determinism.
|
|
54
|
+
* @param {string} input
|
|
55
|
+
* @param {Array<[string,string]>} tokenMap
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function replaceTokens(input, tokenMap) {
|
|
59
|
+
let out = input;
|
|
60
|
+
for (const [token, value] of tokenMap) {
|
|
61
|
+
if (out.includes(token)) {
|
|
62
|
+
out = out.split(token).join(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replace tokens within a single path string. Identical to replaceTokens but
|
|
70
|
+
* named for intent + future divergence (e.g. OS path separators).
|
|
71
|
+
* @param {string} p
|
|
72
|
+
* @param {Array<[string,string]>} tokenMap
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function replacePathTokens(p, tokenMap) {
|
|
76
|
+
return replaceTokens(p, tokenMap);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Heuristic: skip binary files when doing content replacement.
|
|
80
|
+
const BINARY_EXTENSIONS = new Set([
|
|
81
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".icns",
|
|
82
|
+
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
|
83
|
+
".jar", ".keystore", ".jks", ".so", ".dylib", ".a", ".zip",
|
|
84
|
+
".class", ".bin", ".pdf", ".aar",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} filename
|
|
89
|
+
* @returns {boolean} true if the file should be treated as binary (skip content replace)
|
|
90
|
+
*/
|
|
91
|
+
export function isBinaryPath(filename) {
|
|
92
|
+
const lower = filename.toLowerCase();
|
|
93
|
+
const dot = lower.lastIndexOf(".");
|
|
94
|
+
if (dot === -1) return false;
|
|
95
|
+
return BINARY_EXTENSIONS.has(lower.slice(dot));
|
|
96
|
+
}
|
package/src/lib/toml.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Tolerant, line-based reader/writer for TOML *sections* as used by Gradle
|
|
2
|
+
// version catalogs (gradle/libs.versions.toml).
|
|
3
|
+
//
|
|
4
|
+
// This is deliberately NOT a general TOML parser — the dependency ethos of this
|
|
5
|
+
// CLI is "prompts + picocolors, nothing else". It handles exactly what a
|
|
6
|
+
// version catalog's `[versions]` table contains: `key = "value"` string entries
|
|
7
|
+
// with comments and blank lines, and it can rewrite values SURGICALLY (only the
|
|
8
|
+
// quoted value on the matched line changes; every other byte of the file —
|
|
9
|
+
// comments, spacing, ordering, line endings — is preserved).
|
|
10
|
+
|
|
11
|
+
/** Matches `key = "value"` (or 'value') with optional whitespace. */
|
|
12
|
+
const ENTRY_RE = /^(\s*)([A-Za-z0-9_][A-Za-z0-9_.-]*)(\s*=\s*)(["'])([^"']*)\4/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Split content on "\n" ONLY, so any "\r" stays attached to its line and a
|
|
16
|
+
* join("\n") reproduces the input byte-for-byte.
|
|
17
|
+
*/
|
|
18
|
+
function splitLines(content) {
|
|
19
|
+
return content.split("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse one `[section]` table out of a TOML document.
|
|
24
|
+
* @param {string} content
|
|
25
|
+
* @param {string} section e.g. "versions"
|
|
26
|
+
* @returns {Map<string, {value:string, line:number, quote:string}>}
|
|
27
|
+
* Ordered map of key → { value, line index (0-based), quote char }.
|
|
28
|
+
*/
|
|
29
|
+
export function parseTomlSection(content, section) {
|
|
30
|
+
const lines = splitLines(content);
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
let inSection = false;
|
|
33
|
+
for (let i = 0; i < lines.length; i++) {
|
|
34
|
+
const trimmed = lines[i].trim();
|
|
35
|
+
if (trimmed.startsWith("[")) {
|
|
36
|
+
inSection = trimmed.replace(/\r$/, "") === `[${section}]`;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!inSection || !trimmed || trimmed.startsWith("#")) continue;
|
|
40
|
+
const m = lines[i].match(ENTRY_RE);
|
|
41
|
+
if (!m) continue; // tolerate inline tables / anything exotic: skip, don't throw
|
|
42
|
+
entries.set(m[2], { value: m[5], line: i, quote: m[4] });
|
|
43
|
+
}
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse the `[versions]` table of a libs.versions.toml.
|
|
49
|
+
* @param {string} content
|
|
50
|
+
* @returns {Map<string, {value:string, line:number, quote:string}>}
|
|
51
|
+
*/
|
|
52
|
+
export function parseVersions(content) {
|
|
53
|
+
return parseTomlSection(content, "versions");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Surgically rewrite values in one section. Only the quoted value on each
|
|
58
|
+
* matched line is replaced; all other bytes (including trailing comments on
|
|
59
|
+
* the same line) are untouched.
|
|
60
|
+
* @param {string} content
|
|
61
|
+
* @param {string} section
|
|
62
|
+
* @param {Record<string,string>} changes key → new value
|
|
63
|
+
* @returns {{content:string, applied:string[], missing:string[]}}
|
|
64
|
+
*/
|
|
65
|
+
export function updateTomlValues(content, section, changes) {
|
|
66
|
+
const entries = parseTomlSection(content, section);
|
|
67
|
+
const lines = splitLines(content);
|
|
68
|
+
const applied = [];
|
|
69
|
+
const missing = [];
|
|
70
|
+
for (const [key, newValue] of Object.entries(changes)) {
|
|
71
|
+
const e = entries.get(key);
|
|
72
|
+
if (!e) {
|
|
73
|
+
missing.push(key);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const line = lines[e.line];
|
|
77
|
+
const m = line.match(ENTRY_RE);
|
|
78
|
+
if (!m) {
|
|
79
|
+
missing.push(key);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const before = m[1] + m[2] + m[3] + m[4];
|
|
83
|
+
const rest = line.slice(m[0].length); // closing-quote onward is inside m[0]; rest = after value's closing quote
|
|
84
|
+
lines[e.line] = `${before}${newValue}${m[4]}${rest}`;
|
|
85
|
+
applied.push(key);
|
|
86
|
+
}
|
|
87
|
+
return { content: lines.join("\n"), applied, missing };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read a `key=value` java-properties-style file into an ordered map (used for
|
|
92
|
+
* gradle.properties / gradle-wrapper.properties / local.properties).
|
|
93
|
+
* Escaped chars in values are left as-is (raw text).
|
|
94
|
+
* @param {string} content
|
|
95
|
+
* @returns {Map<string, {value:string, line:number}>}
|
|
96
|
+
*/
|
|
97
|
+
export function parseProperties(content) {
|
|
98
|
+
const lines = splitLines(content);
|
|
99
|
+
const entries = new Map();
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
const trimmed = lines[i].trim();
|
|
102
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("!")) continue;
|
|
103
|
+
const eq = trimmed.indexOf("=");
|
|
104
|
+
if (eq <= 0) continue;
|
|
105
|
+
const key = trimmed.slice(0, eq).trim();
|
|
106
|
+
const value = trimmed.slice(eq + 1).trim().replace(/\r$/, "");
|
|
107
|
+
entries.set(key, { value, line: i });
|
|
108
|
+
}
|
|
109
|
+
return entries;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Set (replace or append) one key in a properties file, preserving every other
|
|
114
|
+
* line byte-for-byte.
|
|
115
|
+
* @param {string} content
|
|
116
|
+
* @param {string} key
|
|
117
|
+
* @param {string} value raw value text (caller escapes if needed)
|
|
118
|
+
* @returns {{content:string, changed:boolean, previous:string|null}}
|
|
119
|
+
*/
|
|
120
|
+
export function upsertProperty(content, key, value) {
|
|
121
|
+
const entries = parseProperties(content);
|
|
122
|
+
const lines = splitLines(content);
|
|
123
|
+
const existing = entries.get(key);
|
|
124
|
+
if (existing) {
|
|
125
|
+
if (existing.value === value) {
|
|
126
|
+
return { content, changed: false, previous: existing.value };
|
|
127
|
+
}
|
|
128
|
+
const hadCr = lines[existing.line].endsWith("\r");
|
|
129
|
+
lines[existing.line] = `${key}=${value}${hadCr ? "\r" : ""}`;
|
|
130
|
+
return { content: lines.join("\n"), changed: true, previous: existing.value };
|
|
131
|
+
}
|
|
132
|
+
// Append, keeping a single trailing newline.
|
|
133
|
+
let out = content;
|
|
134
|
+
if (out.length > 0 && !out.endsWith("\n")) out += "\n";
|
|
135
|
+
out += `${key}=${value}\n`;
|
|
136
|
+
return { content: out, changed: true, previous: null };
|
|
137
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Core logic for `create-cmp upgrade`: diff a project's [versions] table
|
|
2
|
+
// against a proven-green registry set, guard the kotlin↔ksp lockstep, and
|
|
3
|
+
// apply the changes SURGICALLY (line edits via lib/toml.mjs — formatting,
|
|
4
|
+
// comments, and unrelated lines are preserved byte-for-byte). Pure functions
|
|
5
|
+
// here; filesystem orchestration lives in src/commands/upgrade.mjs.
|
|
6
|
+
|
|
7
|
+
import { parseVersions, updateTomlValues, upsertProperty, parseProperties } from "./toml.mjs";
|
|
8
|
+
|
|
9
|
+
export const BACKUP_SUFFIX = ".bak-upgrade";
|
|
10
|
+
|
|
11
|
+
/** Marker comment the golden template ships in libs.versions.toml. */
|
|
12
|
+
const TEMPLATE_MARKER = "Frozen, CI-verified version set";
|
|
13
|
+
|
|
14
|
+
/** Was this catalog stamped by create-cmp? (Messaging only — never a refusal.) */
|
|
15
|
+
export function looksLikeOurTemplate(tomlContent) {
|
|
16
|
+
return tomlContent.includes(TEMPLATE_MARKER);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Diff a parsed [versions] table against a registry set.
|
|
21
|
+
* @param {Map<string,{value:string}>} projectVersions from parseVersions()
|
|
22
|
+
* @param {object} set registry set ({versions: {...}})
|
|
23
|
+
* @returns {{changes:Array<{key,from,to}>, same:string[],
|
|
24
|
+
* unmanaged:Array<{key,value}>, notInProject:string[]}}
|
|
25
|
+
* - changes: keys the set pins to a different value (would be rewritten)
|
|
26
|
+
* - same: keys already at the set's value
|
|
27
|
+
* - unmanaged: keys in the project the set doesn't know — LEFT UNTOUCHED, warned
|
|
28
|
+
* - notInProject: set keys the project doesn't declare — nothing is added
|
|
29
|
+
*/
|
|
30
|
+
export function diffAgainstSet(projectVersions, set) {
|
|
31
|
+
const changes = [];
|
|
32
|
+
const same = [];
|
|
33
|
+
const unmanaged = [];
|
|
34
|
+
const notInProject = [];
|
|
35
|
+
for (const [key, entry] of projectVersions) {
|
|
36
|
+
if (Object.prototype.hasOwnProperty.call(set.versions, key)) {
|
|
37
|
+
if (set.versions[key] !== entry.value) {
|
|
38
|
+
changes.push({ key, from: entry.value, to: set.versions[key] });
|
|
39
|
+
} else {
|
|
40
|
+
same.push(key);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
unmanaged.push({ key, value: entry.value });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const key of Object.keys(set.versions)) {
|
|
47
|
+
if (!projectVersions.has(key)) notInProject.push(key);
|
|
48
|
+
}
|
|
49
|
+
return { changes, same, unmanaged, notInProject };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The [versions] values the file WOULD contain after applying `changes`.
|
|
54
|
+
* @param {Map<string,{value:string}>} projectVersions
|
|
55
|
+
* @param {Array<{key,to}>} changes
|
|
56
|
+
* @returns {Record<string,string>}
|
|
57
|
+
*/
|
|
58
|
+
export function resultingVersions(projectVersions, changes) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [key, entry] of projectVersions) out[key] = entry.value;
|
|
61
|
+
for (const c of changes) out[c.key] = c.to;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lockstep guardrail: ksp must be `<kotlin>-<kspVersion>`. Returns a
|
|
67
|
+
* human-readable violation string, or null when consistent (or when either
|
|
68
|
+
* key is absent — nothing to check).
|
|
69
|
+
* @param {Record<string,string>} versions
|
|
70
|
+
* @returns {string|null}
|
|
71
|
+
*/
|
|
72
|
+
export function lockstepViolation(versions) {
|
|
73
|
+
const kotlin = versions.kotlin;
|
|
74
|
+
const ksp = versions.ksp;
|
|
75
|
+
if (!kotlin || !ksp) return null;
|
|
76
|
+
if (!ksp.startsWith(`${kotlin}-`)) {
|
|
77
|
+
return (
|
|
78
|
+
`kotlin ${kotlin} and ksp ${ksp} are OUT OF LOCKSTEP — ksp must be ` +
|
|
79
|
+
`"${kotlin}-<kspVersion>" (e.g. "${kotlin}-2.0.4"). Refusing to write a broken pairing.`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compute the full upgrade plan for one catalog + optional gradle.properties +
|
|
87
|
+
* optional wrapper properties. Pure — no filesystem.
|
|
88
|
+
* @param {object} params
|
|
89
|
+
* @param {string} params.tomlContent gradle/libs.versions.toml text
|
|
90
|
+
* @param {string|null} params.gradlePropertiesContent gradle.properties text (null = absent)
|
|
91
|
+
* @param {string|null} params.wrapperPropertiesContent gradle-wrapper.properties text (null = absent)
|
|
92
|
+
* @param {object} params.set registry set
|
|
93
|
+
* @returns {{
|
|
94
|
+
* diff: ReturnType<typeof diffAgainstSet>,
|
|
95
|
+
* lockstepError: string|null,
|
|
96
|
+
* newTomlContent: string|null,
|
|
97
|
+
* propertyChanges: Array<{key,from,to}>,
|
|
98
|
+
* newGradlePropertiesContent: string|null,
|
|
99
|
+
* wrapperChange: {from:string,to:string}|null,
|
|
100
|
+
* newWrapperPropertiesContent: string|null,
|
|
101
|
+
* fromOurTemplate: boolean
|
|
102
|
+
* }}
|
|
103
|
+
*/
|
|
104
|
+
export function planUpgrade({ tomlContent, gradlePropertiesContent, wrapperPropertiesContent, set }) {
|
|
105
|
+
const projectVersions = parseVersions(tomlContent);
|
|
106
|
+
const diff = diffAgainstSet(projectVersions, set);
|
|
107
|
+
const resulting = resultingVersions(projectVersions, diff.changes);
|
|
108
|
+
const lockstepError = lockstepViolation(resulting);
|
|
109
|
+
|
|
110
|
+
let newTomlContent = null;
|
|
111
|
+
if (!lockstepError && diff.changes.length > 0) {
|
|
112
|
+
const changeMap = Object.fromEntries(diff.changes.map((c) => [c.key, c.to]));
|
|
113
|
+
newTomlContent = updateTomlValues(tomlContent, "versions", changeMap).content;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// gradle.properties flags the set requires (e.g. ksp.useKSP2=true).
|
|
117
|
+
const propertyChanges = [];
|
|
118
|
+
let newGradlePropertiesContent = null;
|
|
119
|
+
if (!lockstepError && set.gradleProperties && gradlePropertiesContent !== null) {
|
|
120
|
+
let content = gradlePropertiesContent;
|
|
121
|
+
const existing = parseProperties(gradlePropertiesContent);
|
|
122
|
+
for (const [key, value] of Object.entries(set.gradleProperties)) {
|
|
123
|
+
const cur = existing.get(key);
|
|
124
|
+
if (!cur || cur.value !== value) {
|
|
125
|
+
const r = upsertProperty(content, key, value);
|
|
126
|
+
content = r.content;
|
|
127
|
+
propertyChanges.push({ key, from: cur ? cur.value : null, to: value });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (propertyChanges.length > 0) newGradlePropertiesContent = content;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Gradle wrapper distributionUrl, when the set pins one.
|
|
134
|
+
let wrapperChange = null;
|
|
135
|
+
let newWrapperPropertiesContent = null;
|
|
136
|
+
if (!lockstepError && set.gradleWrapper?.distributionUrl && wrapperPropertiesContent !== null) {
|
|
137
|
+
const props = parseProperties(wrapperPropertiesContent);
|
|
138
|
+
const cur = props.get("distributionUrl");
|
|
139
|
+
const targetRaw = set.gradleWrapper.distributionUrl;
|
|
140
|
+
const targetEscaped = targetRaw.replace(/:/g, "\\:");
|
|
141
|
+
const curUnescaped = cur ? cur.value.replace(/\\:/g, ":") : null;
|
|
142
|
+
if (cur && curUnescaped !== targetRaw) {
|
|
143
|
+
const r = upsertProperty(wrapperPropertiesContent, "distributionUrl", targetEscaped);
|
|
144
|
+
newWrapperPropertiesContent = r.content;
|
|
145
|
+
wrapperChange = { from: curUnescaped, to: targetRaw };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
diff,
|
|
151
|
+
lockstepError,
|
|
152
|
+
newTomlContent,
|
|
153
|
+
propertyChanges,
|
|
154
|
+
newGradlePropertiesContent,
|
|
155
|
+
wrapperChange,
|
|
156
|
+
newWrapperPropertiesContent,
|
|
157
|
+
fromOurTemplate: looksLikeOurTemplate(tomlContent),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// North-star gate: run manifest.verify commands and return GREEN/FAIL.
|
|
2
|
+
//
|
|
3
|
+
// verify.android runs always; verify.ios runs only on macOS when iOS is
|
|
4
|
+
// enabled. Success is PROVEN by a build, not assumed — the CLI refuses to
|
|
5
|
+
// claim success without this (unless --no-verify).
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { isMacOS } from "./fsutil.mjs";
|
|
9
|
+
import { colors } from "./log.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run a single shell command in `cwd`, streaming output. Resolves with the
|
|
13
|
+
* exit code.
|
|
14
|
+
* @param {string} command
|
|
15
|
+
* @param {string} cwd
|
|
16
|
+
* @returns {Promise<{code:number, command:string}>}
|
|
17
|
+
*/
|
|
18
|
+
function runCommand(command, cwd) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const child = spawn(command, {
|
|
21
|
+
cwd,
|
|
22
|
+
shell: true,
|
|
23
|
+
stdio: "inherit",
|
|
24
|
+
env: process.env,
|
|
25
|
+
});
|
|
26
|
+
child.on("close", (code) => resolve({ code: code ?? 1, command }));
|
|
27
|
+
child.on("error", () => resolve({ code: 127, command }));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run the verify gate for the stamped project.
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @param {string} params.projectDir
|
|
35
|
+
* @param {object} params.manifest manifest.json (must have .verify)
|
|
36
|
+
* @param {object} params.config engine config (uses platforms.ios)
|
|
37
|
+
* @param {boolean} [params.dryRun] when true, print commands but don't run
|
|
38
|
+
* @returns {Promise<{green:boolean, results:Array<{platform:string,command:string,code:number,ran:boolean}>}>}
|
|
39
|
+
*/
|
|
40
|
+
export async function runVerify({ projectDir, manifest, config, dryRun = false }) {
|
|
41
|
+
const verify = (manifest && manifest.verify) || {};
|
|
42
|
+
const results = [];
|
|
43
|
+
|
|
44
|
+
const plan = [];
|
|
45
|
+
if (verify.android) plan.push({ platform: "android", command: verify.android, eligible: true });
|
|
46
|
+
if (verify.ios) {
|
|
47
|
+
const eligible = isMacOS() && !!config?.platforms?.ios;
|
|
48
|
+
plan.push({ platform: "ios", command: verify.ios, eligible });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (plan.length === 0) {
|
|
52
|
+
return { green: false, results, reason: "no verify commands in manifest" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let green = true;
|
|
56
|
+
for (const item of plan) {
|
|
57
|
+
if (!item.eligible) {
|
|
58
|
+
results.push({ platform: item.platform, command: item.command, code: 0, ran: false });
|
|
59
|
+
process.stdout.write(
|
|
60
|
+
`${colors.yellow("skip")} verify.${item.platform} (not eligible on this host/config)\n`
|
|
61
|
+
);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
process.stdout.write(`${colors.cyan("›")} verify.${item.platform}: ${item.command}\n`);
|
|
65
|
+
if (dryRun) {
|
|
66
|
+
results.push({ platform: item.platform, command: item.command, code: 0, ran: false });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const { code } = await runCommand(item.command, projectDir);
|
|
70
|
+
results.push({ platform: item.platform, command: item.command, code, ran: true });
|
|
71
|
+
if (code !== 0) green = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { green, results };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pretty-print the verify verdict table.
|
|
79
|
+
* @param {{green:boolean, results:Array}} verdict
|
|
80
|
+
*/
|
|
81
|
+
export function printVerifyVerdict(verdict) {
|
|
82
|
+
process.stdout.write("\n");
|
|
83
|
+
for (const r of verdict.results) {
|
|
84
|
+
if (!r.ran) {
|
|
85
|
+
process.stdout.write(` ${colors.dim("·")} ${r.platform.padEnd(8)} ${colors.dim("skipped")}\n`);
|
|
86
|
+
} else if (r.code === 0) {
|
|
87
|
+
process.stdout.write(` ${colors.green("GREEN")} ${r.platform}\n`);
|
|
88
|
+
} else {
|
|
89
|
+
process.stdout.write(` ${colors.red("FAIL ")} ${r.platform} (exit ${r.code})\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
verdict.green
|
|
94
|
+
? `\n${colors.green("GREEN — build proven.")}\n`
|
|
95
|
+
: `\n${colors.red("FAIL — build did not go green.")}\n`
|
|
96
|
+
);
|
|
97
|
+
}
|