@cmpose/core 0.0.0-stage → 0.1.1
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 +2 -2
- package/package.json +38 -4
- package/src/ProjectValidator.js +368 -0
- package/src/ciOption.js +31 -0
- package/src/contract.js +109 -0
- package/src/featureModules.js +244 -0
- package/src/featuresConfig.js +72 -0
- package/src/fileService.js +2174 -0
- package/src/generate.js +113 -0
- package/src/generationRecord.js +57 -0
- package/src/generationVerifier.js +613 -0
- package/src/index.js +37 -0
- package/src/limits.js +9 -0
- package/src/log.js +17 -0
- package/src/pins.js +5 -0
- package/src/privacyManifest.js +41 -0
- package/src/projectFiles.js +21 -0
- package/src/readmeService.js +106 -0
- package/src/requestParser.js +81 -0
- package/src/templateCache.js +100 -0
- package/src/templateIdentity.js +6 -0
- package/src/templateVersions.js +38 -0
- package/src/validate.js +76 -0
- package/src/warnings.js +11 -0
- package/src/xcodePackages.js +117 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cmpose contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cmpose/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The cmpose generator library: it turns the pinned Compose Multiplatform template into a new project (validation, package and name rewrites, optional features and modules). It is the same code cmpose.dev runs. See the README at the root of the cmpose repository for usage and development.
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmpose/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "cmpose project generator core (Compose Multiplatform)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Keremturker/cmpose.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Keremturker/cmpose/tree/main/packages/core#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Keremturker/cmpose/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"compose-multiplatform",
|
|
17
|
+
"kotlin-multiplatform",
|
|
18
|
+
"kmp",
|
|
19
|
+
"generator",
|
|
20
|
+
"scaffold",
|
|
21
|
+
"android",
|
|
22
|
+
"ios"
|
|
23
|
+
],
|
|
24
|
+
"main": "src/index.js",
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.js"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"fs-extra": "^11.3.2"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectValidator - Shared validation logic for both frontend and backend
|
|
3
|
+
* UMD (Universal Module Definition) format for browser and Node.js compatibility
|
|
4
|
+
*/
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
7
|
+
// Node.js / CommonJS
|
|
8
|
+
module.exports = factory();
|
|
9
|
+
} else {
|
|
10
|
+
// Browser global
|
|
11
|
+
root.ProjectValidator = factory();
|
|
12
|
+
}
|
|
13
|
+
}(typeof self !== 'undefined' ? self : this, function () {
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
// Default Constants (can be overridden via configure())
|
|
17
|
+
let MAX_MODULES = 3;
|
|
18
|
+
let MIN_SDK_MIN = 24;
|
|
19
|
+
let MIN_SDK_MAX = 36;
|
|
20
|
+
let IOS_VERSION_MIN = '15.0';
|
|
21
|
+
let IOS_VERSION_MAX = '26.2';
|
|
22
|
+
// Package reserved by the template itself (e.g. its own package); none by default
|
|
23
|
+
let RESERVED_PACKAGE_PREFIX = null;
|
|
24
|
+
|
|
25
|
+
// Max lengths for input fields
|
|
26
|
+
const MAX_LENGTH_PROJECT_NAME = 30;
|
|
27
|
+
const MAX_LENGTH_APP_NAME = 30;
|
|
28
|
+
const MAX_LENGTH_PACKAGE_NAME = 50;
|
|
29
|
+
const MAX_LENGTH_MODULE_NAME = 20;
|
|
30
|
+
const MAX_LENGTH_BASE_URL = 100;
|
|
31
|
+
|
|
32
|
+
// Format regexes: the one place they live (validators below use them; contract.js publishes their sources)
|
|
33
|
+
const PATTERNS = {
|
|
34
|
+
projectName: /^[a-zA-Z]+$/,
|
|
35
|
+
packageName: /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*){2,}$/,
|
|
36
|
+
moduleName: /^[a-z][a-z0-9]*$/,
|
|
37
|
+
iosVersionFormat: /^\d+(\.\d+){1,2}$/
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Kotlin hard keywords - cannot be used as package segments or module names
|
|
41
|
+
const KOTLIN_HARD_KEYWORDS = ['as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun', 'if', 'in', 'interface', 'is', 'null', 'object', 'package', 'return', 'super', 'this', 'throw', 'true', 'try', 'typealias', 'typeof', 'val', 'var', 'when', 'while'];
|
|
42
|
+
|
|
43
|
+
function isKotlinHardKeyword(word) {
|
|
44
|
+
return KOTLIN_HARD_KEYWORDS.indexOf(word) !== -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Module names the generator cannot use: file processing skips every directory named build
|
|
48
|
+
// (Gradle output), so feature/build would keep the template package; monetization is the
|
|
49
|
+
// template's own feature/monetization module (purchases/ads flags); notes is blank's Room demo
|
|
50
|
+
// (feature/notes)
|
|
51
|
+
const RESERVED_MODULE_NAMES = ['build', 'monetization', 'notes'];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare two version strings (e.g., '15.0' vs '15.0.1')
|
|
55
|
+
* @returns {number} - -1 if a < b, 0 if a == b, 1 if a > b
|
|
56
|
+
*/
|
|
57
|
+
function compareVersions(a, b) {
|
|
58
|
+
const partsA = a.split('.').map(Number);
|
|
59
|
+
const partsB = b.split('.').map(Number);
|
|
60
|
+
const len = Math.max(partsA.length, partsB.length);
|
|
61
|
+
for (let i = 0; i < len; i++) {
|
|
62
|
+
const numA = partsA[i] || 0;
|
|
63
|
+
const numB = partsB[i] || 0;
|
|
64
|
+
if (numA < numB) return -1;
|
|
65
|
+
if (numA > numB) return 1;
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ProjectValidator = {
|
|
71
|
+
/**
|
|
72
|
+
* Maximum number of feature modules allowed
|
|
73
|
+
*/
|
|
74
|
+
MAX_MODULES: MAX_MODULES,
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Configure validation limits (call this on server startup or page load)
|
|
78
|
+
* @param {Object} config - Configuration object
|
|
79
|
+
*/
|
|
80
|
+
configure: function (config) {
|
|
81
|
+
if (config.maxModules !== undefined) MAX_MODULES = config.maxModules;
|
|
82
|
+
if (config.minSdkMin !== undefined) MIN_SDK_MIN = config.minSdkMin;
|
|
83
|
+
if (config.minSdkMax !== undefined) MIN_SDK_MAX = config.minSdkMax;
|
|
84
|
+
if (config.iosVersionMin !== undefined) IOS_VERSION_MIN = config.iosVersionMin;
|
|
85
|
+
if (config.iosVersionMax !== undefined) IOS_VERSION_MAX = config.iosVersionMax;
|
|
86
|
+
if (config.reservedPackagePrefix !== undefined) RESERVED_PACKAGE_PREFIX = config.reservedPackagePrefix;
|
|
87
|
+
// Update exposed constants
|
|
88
|
+
this.MAX_MODULES = MAX_MODULES;
|
|
89
|
+
this.MIN_SDK_MIN = MIN_SDK_MIN;
|
|
90
|
+
this.MIN_SDK_MAX = MIN_SDK_MAX;
|
|
91
|
+
this.IOS_VERSION_MIN = IOS_VERSION_MIN;
|
|
92
|
+
this.IOS_VERSION_MAX = IOS_VERSION_MAX;
|
|
93
|
+
// Update dynamic messages
|
|
94
|
+
this.messages.maxModulesReached = 'Maximum ' + MAX_MODULES + ' feature modules allowed';
|
|
95
|
+
this.messages.minSdkInvalid = 'Min SDK must be a number between ' + MIN_SDK_MIN + ' and ' + MIN_SDK_MAX;
|
|
96
|
+
this.messages.iosVersionInvalid = 'iOS Version must be between ' + IOS_VERSION_MIN + ' and ' + IOS_VERSION_MAX;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get current configuration values
|
|
101
|
+
* @returns {Object} - Current config values
|
|
102
|
+
*/
|
|
103
|
+
getConfig: function () {
|
|
104
|
+
return {
|
|
105
|
+
maxModules: MAX_MODULES,
|
|
106
|
+
minSdkMin: MIN_SDK_MIN,
|
|
107
|
+
minSdkMax: MIN_SDK_MAX,
|
|
108
|
+
iosVersionMin: IOS_VERSION_MIN,
|
|
109
|
+
iosVersionMax: IOS_VERSION_MAX,
|
|
110
|
+
reservedPackagePrefix: RESERVED_PACKAGE_PREFIX
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Exposed config values
|
|
116
|
+
*/
|
|
117
|
+
MIN_SDK_MIN: MIN_SDK_MIN,
|
|
118
|
+
MIN_SDK_MAX: MIN_SDK_MAX,
|
|
119
|
+
IOS_VERSION_MIN: IOS_VERSION_MIN,
|
|
120
|
+
IOS_VERSION_MAX: IOS_VERSION_MAX,
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Max lengths for input fields
|
|
124
|
+
*/
|
|
125
|
+
MAX_LENGTH_PROJECT_NAME: MAX_LENGTH_PROJECT_NAME, // Project name
|
|
126
|
+
MAX_LENGTH_APP_NAME: MAX_LENGTH_APP_NAME, // App name (user visible)
|
|
127
|
+
MAX_LENGTH_PACKAGE_NAME: MAX_LENGTH_PACKAGE_NAME, // Package name (e.g., com.company.app.module)
|
|
128
|
+
MAX_LENGTH_MODULE_NAME: MAX_LENGTH_MODULE_NAME, // Feature module name
|
|
129
|
+
MAX_LENGTH_MIN_SDK: 2, // Max 2 digits (e.g., 36)
|
|
130
|
+
MAX_LENGTH_IOS_VERSION: 8, // Max format: xx.xx.xx (e.g., 26.2.1)
|
|
131
|
+
MAX_LENGTH_BASE_URL: MAX_LENGTH_BASE_URL, // Network base URL
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Format regexes and name lists (read-only; contract.js publishes them)
|
|
135
|
+
*/
|
|
136
|
+
patterns: PATTERNS,
|
|
137
|
+
KOTLIN_HARD_KEYWORDS: KOTLIN_HARD_KEYWORDS,
|
|
138
|
+
RESERVED_MODULE_NAMES: RESERVED_MODULE_NAMES,
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validates that the value is a string no longer than max characters
|
|
142
|
+
* @param {string} value - The value to validate
|
|
143
|
+
* @param {number} max - Maximum allowed length (inclusive)
|
|
144
|
+
* @returns {boolean} - True if valid
|
|
145
|
+
*/
|
|
146
|
+
validateMaxLength: function (value, max) {
|
|
147
|
+
return typeof value === 'string' && value.length <= max;
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validates that the value contains no spaces
|
|
152
|
+
* @param {string} value - The value to validate
|
|
153
|
+
* @returns {boolean} - True if valid (no spaces)
|
|
154
|
+
*/
|
|
155
|
+
validateNoSpaces: function (value) {
|
|
156
|
+
return !value.includes(' ');
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validates App Name (call with the trimmed value) - letters (any script, including
|
|
161
|
+
* combining marks), digits, spaces and . _ ' - only, no leading/trailing whitespace,
|
|
162
|
+
* at most MAX_LENGTH_APP_NAME
|
|
163
|
+
* @param {string} value - The app name to validate
|
|
164
|
+
* @returns {boolean} - True if valid
|
|
165
|
+
*/
|
|
166
|
+
validateAppName: function (value) {
|
|
167
|
+
if (typeof value !== 'string') return false;
|
|
168
|
+
if (value.length === 0 || value.length > MAX_LENGTH_APP_NAME) return false;
|
|
169
|
+
if (value.trim() !== value) return false;
|
|
170
|
+
return /^[\p{L}\p{M}\p{N} ._'-]+$/u.test(value);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validates Package Name format
|
|
175
|
+
* Must start with a letter, contain at least 3 segments separated by dots,
|
|
176
|
+
* and use only lowercase letters and numbers. No segment may be a Kotlin hard
|
|
177
|
+
* keyword, and the reserved package (see configure) and packages under it are rejected.
|
|
178
|
+
* Example: com.example.app
|
|
179
|
+
* @param {string} value - The package name to validate
|
|
180
|
+
* @returns {boolean} - True if valid
|
|
181
|
+
*/
|
|
182
|
+
validatePackageName: function (value) {
|
|
183
|
+
if (!PATTERNS.packageName.test(value)) return false;
|
|
184
|
+
if (value.split('.').some(isKotlinHardKeyword)) return false;
|
|
185
|
+
if (RESERVED_PACKAGE_PREFIX &&
|
|
186
|
+
(value === RESERVED_PACKAGE_PREFIX || value.indexOf(RESERVED_PACKAGE_PREFIX + '.') === 0)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validates URL - http(s) only, at most MAX_LENGTH_BASE_URL characters, and free of
|
|
194
|
+
* characters that would break the generated Kotlin string (" \ $, whitespace, controls)
|
|
195
|
+
* @param {string} value - The URL to validate
|
|
196
|
+
* @returns {boolean} - True if valid URL
|
|
197
|
+
*/
|
|
198
|
+
validateUrl: function (value) {
|
|
199
|
+
if (typeof value !== 'string') return false;
|
|
200
|
+
if (value.length > MAX_LENGTH_BASE_URL) return false;
|
|
201
|
+
if (/["\\$\s\u0000-\u001F\u007F]/.test(value)) return false;
|
|
202
|
+
try {
|
|
203
|
+
const url = new URL(value);
|
|
204
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
205
|
+
} catch (_) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Validates module count against maximum limit
|
|
212
|
+
* @param {number} count - Number of modules
|
|
213
|
+
* @returns {boolean} - True if count is valid (at or below limit)
|
|
214
|
+
*/
|
|
215
|
+
validateModuleCount: function (count) {
|
|
216
|
+
return count <= MAX_MODULES;
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Checks if more modules can be added
|
|
221
|
+
* @param {number} currentCount - Current number of modules
|
|
222
|
+
* @returns {boolean} - True if can add more modules
|
|
223
|
+
*/
|
|
224
|
+
canAddModule: function (currentCount) {
|
|
225
|
+
return currentCount < MAX_MODULES;
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Validates Project Name - only letters allowed (a-zA-Z)
|
|
230
|
+
* @param {string} value - The project name to validate
|
|
231
|
+
* @returns {boolean} - True if valid
|
|
232
|
+
*/
|
|
233
|
+
validateProjectName: function (value) {
|
|
234
|
+
return PATTERNS.projectName.test(value);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Sanitizes Project Name - removes all non-letter characters
|
|
239
|
+
* @param {string} value - The input value
|
|
240
|
+
* @returns {string} - Sanitized value
|
|
241
|
+
*/
|
|
242
|
+
sanitizeProjectName: function (value) {
|
|
243
|
+
return value.replace(/[^a-zA-Z]/g, '');
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Sanitizes Package Name - lowercase, only a-z, 0-9 and . allowed, no consecutive dots
|
|
248
|
+
* (same character set as validatePackageName, so typing cannot produce a rejected char)
|
|
249
|
+
* @param {string} value - The input value
|
|
250
|
+
* @returns {string} - Sanitized value
|
|
251
|
+
*/
|
|
252
|
+
sanitizePackageName: function (value) {
|
|
253
|
+
return value.toLowerCase().replace(/[^a-z0-9.]/g, '').replace(/\.{2,}/g, '.');
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Sanitizes App Name - no leading spaces, no consecutive spaces
|
|
258
|
+
* @param {string} value - The input value
|
|
259
|
+
* @returns {string} - Sanitized value
|
|
260
|
+
*/
|
|
261
|
+
sanitizeAppName: function (value) {
|
|
262
|
+
return value.replace(/^\s+/, '').replace(/\s{2,}/g, ' ');
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Sanitizes Module Name - lowercase, a-z and 0-9 allowed, removes leading digits
|
|
267
|
+
* @param {string} value - The input value
|
|
268
|
+
* @returns {string} - Sanitized value
|
|
269
|
+
*/
|
|
270
|
+
sanitizeModuleName: function (value) {
|
|
271
|
+
let sanitized = value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
272
|
+
// Remove leading digits (module name must start with a letter)
|
|
273
|
+
while (sanitized.length > 0 && /^[0-9]/.test(sanitized)) {
|
|
274
|
+
sanitized = sanitized.substring(1);
|
|
275
|
+
}
|
|
276
|
+
return sanitized;
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Validates Module Name - lowercase letters and numbers allowed, must start with letter,
|
|
281
|
+
* must not be a Kotlin hard keyword (it becomes a package segment) or a reserved name
|
|
282
|
+
* (RESERVED_MODULE_NAMES)
|
|
283
|
+
* @param {string} value - The module name to validate
|
|
284
|
+
* @returns {boolean} - True if valid
|
|
285
|
+
*/
|
|
286
|
+
validateModuleName: function (value) {
|
|
287
|
+
return PATTERNS.moduleName.test(value) && value.length > 0 && !isKotlinHardKeyword(value) &&
|
|
288
|
+
RESERVED_MODULE_NAMES.indexOf(value) === -1;
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Sanitizes Android Min SDK - only digits allowed
|
|
293
|
+
* @param {string} value - The input value
|
|
294
|
+
* @returns {string} - Sanitized value (digits only)
|
|
295
|
+
*/
|
|
296
|
+
sanitizeMinSdk: function (value) {
|
|
297
|
+
return value.replace(/[^0-9]/g, '');
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Validates Android Min SDK - must be a valid number within range
|
|
302
|
+
* @param {number|string} value - The min SDK value
|
|
303
|
+
* @returns {boolean} - True if valid (number between MIN_SDK_MIN and MIN_SDK_MAX)
|
|
304
|
+
*/
|
|
305
|
+
validateMinSdk: function (value) {
|
|
306
|
+
if (!/^\d{1,2}$/.test(value)) return false;
|
|
307
|
+
const num = parseInt(value, 10);
|
|
308
|
+
return num >= MIN_SDK_MIN && num <= MIN_SDK_MAX;
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Sanitizes iOS Version - only digits and dots, max 2 dots, no consecutive dots
|
|
313
|
+
* @param {string} value - The input value
|
|
314
|
+
* @returns {string} - Sanitized value
|
|
315
|
+
*/
|
|
316
|
+
sanitizeIosVersion: function (value) {
|
|
317
|
+
// Remove non-digit and non-dot characters
|
|
318
|
+
let sanitized = value.replace(/[^0-9.]/g, '');
|
|
319
|
+
// Remove consecutive dots
|
|
320
|
+
sanitized = sanitized.replace(/\.{2,}/g, '.');
|
|
321
|
+
// Remove leading dot
|
|
322
|
+
if (sanitized.startsWith('.')) {
|
|
323
|
+
sanitized = sanitized.substring(1);
|
|
324
|
+
}
|
|
325
|
+
// Limit to max 2 dots (major.minor.patch)
|
|
326
|
+
const parts = sanitized.split('.');
|
|
327
|
+
if (parts.length > 3) {
|
|
328
|
+
sanitized = parts.slice(0, 3).join('.');
|
|
329
|
+
}
|
|
330
|
+
return sanitized;
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Validates iOS Version format and range
|
|
335
|
+
* @param {string} value - The iOS version string
|
|
336
|
+
* @returns {boolean} - True if valid format and within range
|
|
337
|
+
*/
|
|
338
|
+
validateIosVersion: function (value) {
|
|
339
|
+
// Check format first
|
|
340
|
+
if (!PATTERNS.iosVersionFormat.test(value)) return false;
|
|
341
|
+
// Check range
|
|
342
|
+
return compareVersions(value, IOS_VERSION_MIN) >= 0 &&
|
|
343
|
+
compareVersions(value, IOS_VERSION_MAX) <= 0;
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Error messages for validation failures
|
|
348
|
+
*/
|
|
349
|
+
messages: {
|
|
350
|
+
projectNameSpaces: 'Project Name cannot contain spaces',
|
|
351
|
+
projectNameInvalid: 'Project Name must contain only letters (a-z, A-Z)',
|
|
352
|
+
projectNameTooLong: 'Project Name must be at most ' + MAX_LENGTH_PROJECT_NAME + ' characters',
|
|
353
|
+
packageNameSpaces: 'Package Name cannot contain spaces',
|
|
354
|
+
packageNameInvalid: 'Invalid Package Name. Must start with a letter, contain at least two dots (3 segments), and use only lowercase letters and numbers (e.g., com.example.app). Segments cannot be Kotlin keywords (e.g., in, is, object), and the template\'s reserved package (or any package under it) cannot be used.',
|
|
355
|
+
packageNameTooLong: 'Package Name must be at most ' + MAX_LENGTH_PACKAGE_NAME + ' characters',
|
|
356
|
+
appNameInvalid: 'App Name must be 1-' + MAX_LENGTH_APP_NAME + ' characters using only letters, numbers, spaces and . _ \' - (no leading or trailing spaces)',
|
|
357
|
+
urlInvalid: 'Invalid Base URL. Please enter a valid http(s) URL of at most ' + MAX_LENGTH_BASE_URL + ' characters without spaces, quotes, backslashes or $ (e.g., https://api.example.com)',
|
|
358
|
+
maxModulesReached: 'Maximum ' + MAX_MODULES + ' feature modules allowed',
|
|
359
|
+
moduleNameInvalid: 'Module name must start with a letter, contain only lowercase letters and numbers (a-z, 0-9), and not be a Kotlin keyword (e.g., in, is, object) or one of the reserved names (' + RESERVED_MODULE_NAMES.join(', ') + ')',
|
|
360
|
+
moduleNameTooLong: 'Module name must be at most ' + MAX_LENGTH_MODULE_NAME + ' characters',
|
|
361
|
+
moduleNameDuplicate: 'Each module name can be used only once',
|
|
362
|
+
minSdkInvalid: 'Min SDK must be a number between ' + MIN_SDK_MIN + ' and ' + MIN_SDK_MAX + '',
|
|
363
|
+
iosVersionInvalid: 'iOS Version must be between ' + IOS_VERSION_MIN + ' and ' + IOS_VERSION_MAX
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
return ProjectValidator;
|
|
368
|
+
}));
|
package/src/ciOption.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { log } = require('./log');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { removeMarkedBlock } = require('./readmeService');
|
|
5
|
+
|
|
6
|
+
// ci-optin: GitHub Actions CI is an opt-in for both templates. The template ships
|
|
7
|
+
// .github/workflows/ci.yml and marks its README line <!-- cmpose:ci -->; without the option both go.
|
|
8
|
+
const CI_WORKFLOW = path.join('.github', 'workflows', 'ci.yml');
|
|
9
|
+
|
|
10
|
+
async function removeIfEmpty(dir) {
|
|
11
|
+
if (await fs.pathExists(dir) && (await fs.readdir(dir)).length === 0) await fs.remove(dir);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function applyCiOption(projectPath, ci) {
|
|
15
|
+
if (ci === true) return;
|
|
16
|
+
const workflow = path.join(projectPath, CI_WORKFLOW);
|
|
17
|
+
if (await fs.pathExists(workflow)) {
|
|
18
|
+
await fs.remove(workflow);
|
|
19
|
+
await removeIfEmpty(path.dirname(workflow));
|
|
20
|
+
await removeIfEmpty(path.join(projectPath, '.github'));
|
|
21
|
+
log.info('✅ Removed the CI workflow (ci option off)');
|
|
22
|
+
}
|
|
23
|
+
const readmePath = path.join(projectPath, 'README.md');
|
|
24
|
+
if (await fs.pathExists(readmePath)) {
|
|
25
|
+
const readme = await fs.readFile(readmePath, 'utf8');
|
|
26
|
+
const updated = removeMarkedBlock(readme, 'cmpose:ci');
|
|
27
|
+
if (updated !== readme) await fs.writeFile(readmePath, updated);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { CI_WORKFLOW, applyCiOption };
|
package/src/contract.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const PV = require('./ProjectValidator');
|
|
4
|
+
const LIMITS = require('./limits');
|
|
5
|
+
const { OLD_PACKAGE_NAME } = require('./templateIdentity');
|
|
6
|
+
|
|
7
|
+
// The one place the skill's contract comes from. Schema values moved here from the skill's
|
|
8
|
+
// sync-contract.sh; featuresConfig.js and the generator read the same objects.
|
|
9
|
+
const SCHEMA = {
|
|
10
|
+
templateTypes: ['blank', 'showcase'],
|
|
11
|
+
featuresConfigKeys: ['network', 'networkInspector', 'networkBaseUrl', 'theming', 'multiLang', 'dataStore', 'detekt', 'detektYamlContent', 'purchases', 'ads', 'room'],
|
|
12
|
+
dependencyRules: {
|
|
13
|
+
alwaysOn: ['koin', 'navigation'],
|
|
14
|
+
implies: { networkInspector: ['network'] }, // inspector => network
|
|
15
|
+
auto: { dataStore: ['theming', 'multiLang'] } // theming || multiLang => dataStore
|
|
16
|
+
},
|
|
17
|
+
// Top-level request options that apply to every template (featuresConfig is blank-only)
|
|
18
|
+
projectOptions: {
|
|
19
|
+
ci: { default: false, templates: ['blank', 'showcase'] }
|
|
20
|
+
},
|
|
21
|
+
// featuresConfig flag -> core modules it adds (for the anatomy preview tree).
|
|
22
|
+
// `requires` = all must be true; `requiresAny` = at least one true.
|
|
23
|
+
anatomy: {
|
|
24
|
+
always: ['androidApp', 'iosApp', 'shared', 'core/about', 'core/domain', 'core/image', 'core/logging', 'core/presentation', 'core/navigation', 'core/review', 'core/update', 'build-logic'],
|
|
25
|
+
alwaysNote: 'shared wires Koin (DI) + Navigation, which are always included; core/logging sets up Kermit logging, core/review asks for in-app reviews and core/about shows the version, legal links and licences in every project, core/update checks for app updates, core/image loads images with Coil.',
|
|
26
|
+
coreModules: {
|
|
27
|
+
'core/network': { requires: ['network'], note: 'Ktor HTTP client; Coil network images' },
|
|
28
|
+
'core/designsystem': { requiresAny: ['theming', 'multiLang'], note: 'theme (palette + KtTheme) and/or LocalStringResources' },
|
|
29
|
+
'core/multilang': { requires: ['multiLang'], note: 'i18n / localization' },
|
|
30
|
+
'core/database': { requires: ['dataStore'], note: 'DataStore persistence; with theming also the saved Light/Dark/System choice (DarkModeManager)' },
|
|
31
|
+
'core/purchases': { requires: ['purchases'], note: 'offer / purchase / restore API, RevenueCat KMP (Test Store key: replace before you ship)' },
|
|
32
|
+
'core/ads': { requires: ['ads'], note: 'AdMob banner/interstitial/rewarded + UMP/ATT consent (test IDs)' },
|
|
33
|
+
'core/room': { requires: ['room'], note: 'Room KMP database (bundled SQLite): AppDatabase, DAOs, schemas/' }
|
|
34
|
+
},
|
|
35
|
+
// featuresConfig flag -> feature modules kept in a blank project (same requires/requiresAny rules).
|
|
36
|
+
featureModules: {
|
|
37
|
+
'feature/monetization': { requiresAny: ['purchases', 'ads'], note: 'custom paywall (Compose) and/or ads demo' },
|
|
38
|
+
'feature/notes': { requires: ['room'], note: 'Notes demo screen on Room (blank only; showcase uses Room for favorites)' }
|
|
39
|
+
},
|
|
40
|
+
detektDir: { requires: ['detekt'], path: 'detekt/' },
|
|
41
|
+
showcaseFeatures: ['feature/home', 'feature/monetization', 'feature/onboarding'],
|
|
42
|
+
customFeaturePath: 'feature/<name>',
|
|
43
|
+
customFeatureLayers: ['contract', 'data', 'domain', 'presentation']
|
|
44
|
+
},
|
|
45
|
+
// Deep links (<scheme>://<path>): the generator writes the scheme; '' opens the start screen and
|
|
46
|
+
// '<module>' each generated feature module
|
|
47
|
+
deepLinks: {
|
|
48
|
+
scheme: 'projectName, lower case',
|
|
49
|
+
showcase: ['user/{username}', 'settings'],
|
|
50
|
+
blank: ['', '<module>']
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function configureValidator(limits = LIMITS) {
|
|
55
|
+
// The template's own package (and below) cannot be generated: the rename would hit itself
|
|
56
|
+
PV.configure({
|
|
57
|
+
minSdkMin: limits.minSdkMin, minSdkMax: limits.minSdkMax,
|
|
58
|
+
iosVersionMin: limits.iosVersionMin, iosVersionMax: limits.iosVersionMax,
|
|
59
|
+
maxModules: limits.maxModules,
|
|
60
|
+
reservedPackagePrefix: OLD_PACKAGE_NAME
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
configureValidator();
|
|
65
|
+
|
|
66
|
+
// serverLimits: the hosted generator's own facts (rate limit, body size); local generation has none
|
|
67
|
+
function contractFacts({ limits = LIMITS, serverLimits = null } = {}) {
|
|
68
|
+
configureValidator(limits);
|
|
69
|
+
return {
|
|
70
|
+
limits: {
|
|
71
|
+
minSdkMin: limits.minSdkMin,
|
|
72
|
+
minSdkMax: limits.minSdkMax,
|
|
73
|
+
iosVersionMin: limits.iosVersionMin,
|
|
74
|
+
iosVersionMax: limits.iosVersionMax,
|
|
75
|
+
maxModules: limits.maxModules,
|
|
76
|
+
...(serverLimits || {})
|
|
77
|
+
},
|
|
78
|
+
maxLengths: {
|
|
79
|
+
projectName: PV.MAX_LENGTH_PROJECT_NAME,
|
|
80
|
+
appName: PV.MAX_LENGTH_APP_NAME,
|
|
81
|
+
packageName: PV.MAX_LENGTH_PACKAGE_NAME,
|
|
82
|
+
moduleName: PV.MAX_LENGTH_MODULE_NAME,
|
|
83
|
+
minSdk: PV.MAX_LENGTH_MIN_SDK,
|
|
84
|
+
iosVersion: PV.MAX_LENGTH_IOS_VERSION,
|
|
85
|
+
networkBaseUrl: PV.MAX_LENGTH_BASE_URL
|
|
86
|
+
},
|
|
87
|
+
regex: Object.fromEntries(Object.entries(PV.patterns).map(([k, re]) => [k, re.source])),
|
|
88
|
+
kotlinHardKeywords: [...PV.KOTLIN_HARD_KEYWORDS],
|
|
89
|
+
reservedModuleNames: [...PV.RESERVED_MODULE_NAMES],
|
|
90
|
+
reservedPackagePrefix: OLD_PACKAGE_NAME,
|
|
91
|
+
messages: { ...PV.messages },
|
|
92
|
+
...SCHEMA
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stable(value) {
|
|
97
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
98
|
+
if (value && typeof value === 'object') {
|
|
99
|
+
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]));
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Any product's contract: sha256 of the key-sorted JSON, first 12 hex characters
|
|
105
|
+
function hashContract(facts) {
|
|
106
|
+
return crypto.createHash('sha256').update(JSON.stringify(stable(facts))).digest('hex').slice(0, 12);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { SCHEMA, contractFacts, hashContract, configureValidator };
|