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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +232 -0
  3. package/bin/create-cmp.mjs +91 -0
  4. package/options.schema.json +110 -0
  5. package/package.json +52 -0
  6. package/src/bootstrap/checks.mjs +283 -0
  7. package/src/bootstrap/exec.mjs +90 -0
  8. package/src/commands/clean.mjs +195 -0
  9. package/src/commands/create.mjs +223 -0
  10. package/src/commands/doctor.mjs +256 -0
  11. package/src/commands/upgrade.mjs +196 -0
  12. package/src/commands/verify.mjs +94 -0
  13. package/src/doctor.mjs +109 -0
  14. package/src/lib/args.mjs +38 -0
  15. package/src/lib/clean.mjs +72 -0
  16. package/src/lib/fsutil.mjs +90 -0
  17. package/src/lib/log.mjs +40 -0
  18. package/src/lib/project-doctor.mjs +329 -0
  19. package/src/lib/registry.mjs +102 -0
  20. package/src/lib/rename.mjs +116 -0
  21. package/src/lib/schema.mjs +123 -0
  22. package/src/lib/toggle.mjs +130 -0
  23. package/src/lib/tokens.mjs +96 -0
  24. package/src/lib/toml.mjs +137 -0
  25. package/src/lib/upgrade.mjs +159 -0
  26. package/src/lib/verify.mjs +97 -0
  27. package/src/scaffold.mjs +347 -0
  28. package/src/verify.mjs +4 -0
  29. package/src/versions/registry.json +42 -0
  30. package/template/.github/workflows/verify.yml +70 -0
  31. package/template/build.gradle.kts +17 -0
  32. package/template/composeApp/build.gradle.kts +257 -0
  33. package/template/composeApp/google-services.json +29 -0
  34. package/template/composeApp/proguard-rules.pro +34 -0
  35. package/template/composeApp/src/androidDebug/AndroidManifest.xml +13 -0
  36. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/ComposeRootRegistry.kt +40 -0
  37. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +80 -0
  38. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +322 -0
  39. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +16 -0
  40. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +102 -0
  41. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/RemoteControlPage.kt +106 -0
  42. package/template/composeApp/src/androidDebug/res/xml/debug_network_security_config.xml +9 -0
  43. package/template/composeApp/src/androidMain/AndroidManifest.xml +28 -0
  44. package/template/composeApp/src/androidMain/kotlin/com/example/app/AppApplication.kt +72 -0
  45. package/template/composeApp/src/androidMain/kotlin/com/example/app/MainActivity.kt +24 -0
  46. package/template/composeApp/src/androidMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +42 -0
  47. package/template/composeApp/src/androidMain/kotlin/com/example/app/data/local/DatabaseBuilder.android.kt +13 -0
  48. package/template/composeApp/src/androidMain/kotlin/com/example/app/di/AndroidModule.kt +9 -0
  49. package/template/composeApp/src/androidMain/kotlin/com/example/app/presentation/components/TestTagAutomation.android.kt +11 -0
  50. package/template/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +9 -0
  51. package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  52. package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  53. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png +0 -0
  54. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  55. package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  56. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png +0 -0
  57. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  58. package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  59. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png +0 -0
  60. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  61. package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  62. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  63. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  64. package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  65. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  66. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  67. package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  68. package/template/composeApp/src/androidMain/res/values/colors.xml +6 -0
  69. package/template/composeApp/src/androidMain/res/values/themes.xml +9 -0
  70. package/template/composeApp/src/androidMain/res/values-v31/themes.xml +8 -0
  71. package/template/composeApp/src/androidRelease/kotlin/com/example/app/inspector/InspectorInit.kt +13 -0
  72. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Bold.ttf +0 -0
  73. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Medium.ttf +0 -0
  74. package/template/composeApp/src/commonMain/composeResources/font/DMSans_Regular.ttf +0 -0
  75. package/template/composeApp/src/commonMain/composeResources/font/DMSans_SemiBold.ttf +0 -0
  76. package/template/composeApp/src/commonMain/kotlin/com/example/app/App.kt +7 -0
  77. package/template/composeApp/src/commonMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +7 -0
  78. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/AppDatabase.kt +28 -0
  79. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/DatabaseBuilder.kt +14 -0
  80. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/ItemDao.kt +18 -0
  81. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/FirebaseConfig.kt +5 -0
  82. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +22 -0
  83. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +23 -0
  84. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/Item.kt +8 -0
  85. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +8 -0
  86. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +12 -0
  87. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/App.kt +13 -0
  88. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +69 -0
  89. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +18 -0
  90. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +39 -0
  91. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +87 -0
  92. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +35 -0
  93. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +43 -0
  94. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +144 -0
  95. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppTab.kt +25 -0
  96. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +15 -0
  97. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +43 -0
  98. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/DesignToken.kt +25 -0
  99. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Shape.kt +12 -0
  100. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Theme.kt +64 -0
  101. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Tokens.kt +19 -0
  102. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +36 -0
  103. package/template/composeApp/src/desktopMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.desktop.kt +9 -0
  104. package/template/composeApp/src/desktopMain/kotlin/com/example/app/data/local/DatabaseBuilder.desktop.kt +13 -0
  105. package/template/composeApp/src/desktopMain/kotlin/com/example/app/di/DesktopModule.kt +32 -0
  106. package/template/composeApp/src/desktopMain/kotlin/com/example/app/main.kt +29 -0
  107. package/template/composeApp/src/desktopMain/kotlin/com/example/app/presentation/components/TestTagAutomation.desktop.kt +6 -0
  108. package/template/composeApp/src/iosMain/kotlin/com/example/app/KoinHelper.kt +50 -0
  109. package/template/composeApp/src/iosMain/kotlin/com/example/app/MainViewController.kt +10 -0
  110. package/template/composeApp/src/iosMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +32 -0
  111. package/template/composeApp/src/iosMain/kotlin/com/example/app/data/local/DatabaseBuilder.ios.kt +10 -0
  112. package/template/composeApp/src/iosMain/kotlin/com/example/app/presentation/components/TestTagAutomation.ios.kt +11 -0
  113. package/template/docs/dev-client.md +52 -0
  114. package/template/gitignore +21 -0
  115. package/template/gradle/libs.versions.toml +100 -0
  116. package/template/gradle/wrapper/gradle-wrapper.jar +0 -0
  117. package/template/gradle/wrapper/gradle-wrapper.properties +7 -0
  118. package/template/gradle.properties +9 -0
  119. package/template/gradlew +152 -0
  120. package/template/gradlew.bat +94 -0
  121. package/template/iosApp/Podfile +27 -0
  122. package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png +0 -0
  123. package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  124. package/template/iosApp/iosApp/Assets.xcassets/Contents.json +6 -0
  125. package/template/iosApp/iosApp/ContentView.swift +17 -0
  126. package/template/iosApp/iosApp/GoogleService-Info.plist +34 -0
  127. package/template/iosApp/iosApp/Info.plist +43 -0
  128. package/template/iosApp/iosApp/PrivacyInfo.xcprivacy +42 -0
  129. package/template/iosApp/iosApp/iOSApp.swift +33 -0
  130. package/template/iosApp/project.yml +56 -0
  131. package/template/local.properties.example +2 -0
  132. package/template/manifest.json +125 -0
  133. package/template/qa/appium/README.md +23 -0
  134. package/template/qa/appium/lib/appium-client.mjs +225 -0
  135. package/template/qa/appium/package.json +8 -0
  136. package/template/qa/appium/run-android-smoke.mjs +39 -0
  137. package/template/settings.gradle.kts +34 -0
  138. package/template/tests/appium/cmp/conftest.py +96 -0
  139. 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
+ }
@@ -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
+ }