@cmpose/core 0.2.4 → 0.2.6
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/package.json +4 -4
- package/src/ProjectValidator.js +2 -2
- package/src/backendOption.js +284 -0
- package/src/contract.js +13 -6
- package/src/featureModules.js +13 -10
- package/src/featuresConfig.js +3 -2
- package/src/fileService.js +123 -15
- package/src/generate.js +8 -4
- package/src/generationVerifier.js +71 -3
- package/src/index.js +1 -0
- package/src/pins.js +2 -2
- package/src/privacyManifest.js +1 -1
- package/src/readmeService.js +6 -1
- package/src/warnings.js +4 -0
- package/src/xcodePackages.js +1 -1
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmpose/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "cmpose project generator core (Compose Multiplatform)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/
|
|
8
|
+
"url": "git+https://github.com/whinstudio/cmpose.git",
|
|
9
9
|
"directory": "packages/core"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://github.com/
|
|
11
|
+
"homepage": "https://github.com/whinstudio/cmpose/tree/main/packages/core#readme",
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/
|
|
13
|
+
"url": "https://github.com/whinstudio/cmpose/issues"
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"compose-multiplatform",
|
package/src/ProjectValidator.js
CHANGED
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
// Module names the generator cannot use: file processing skips every directory named build
|
|
48
48
|
// (Gradle output), so feature/build would keep the template package; monetization is the
|
|
49
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'];
|
|
50
|
+
// (feature/notes); account and sharing are the backend flag's feature/account and feature/sharing
|
|
51
|
+
const RESERVED_MODULE_NAMES = ['build', 'monetization', 'notes', 'account', 'sharing'];
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Compare two version strings (e.g., '15.0' vs '15.0.1')
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { removeSwiftPackage, parseObjects, isa } = require('./xcodePackages');
|
|
5
|
+
const { PRIVACY_MANIFEST } = require('./privacyManifest');
|
|
6
|
+
|
|
7
|
+
// featuresConfig.backend: the template marks everything the backend adds (MAINTAINING.md → Backend markers).
|
|
8
|
+
// Off strips it, on keeps it and drops only the markers. Runs on the template package, before the rename.
|
|
9
|
+
|
|
10
|
+
const MARKERS = {
|
|
11
|
+
slash: { open: '// cmpose:backend', close: '// /cmpose:backend' },
|
|
12
|
+
hash: { open: '# cmpose:backend', close: '# /cmpose:backend' },
|
|
13
|
+
html: { open: '<!-- cmpose:backend -->', close: '<!-- /cmpose:backend -->' }
|
|
14
|
+
};
|
|
15
|
+
const SWAP = '// cmpose:backend-off: ';
|
|
16
|
+
|
|
17
|
+
const KIND_BY_EXTENSION = {
|
|
18
|
+
'.kt': 'slash', '.kts': 'slash', '.swift': 'slash', '.xcconfig': 'slash',
|
|
19
|
+
'.toml': 'hash', '.gitignore': 'hash', '.properties': 'hash',
|
|
20
|
+
'.md': 'html', '.xml': 'html', '.plist': 'html', '.xcprivacy': 'html'
|
|
21
|
+
};
|
|
22
|
+
const SKIP_DIRS = new Set(['.git', '.gradle', 'build', 'node_modules']);
|
|
23
|
+
|
|
24
|
+
// Template-relative paths the backend adds outside the files it marks; deleted when backend is off
|
|
25
|
+
const TEMPLATE_PACKAGE_PATH = 'com/kturker/multiplatform';
|
|
26
|
+
const BACKEND_PATHS = [
|
|
27
|
+
'server',
|
|
28
|
+
'core/api',
|
|
29
|
+
'core/auth',
|
|
30
|
+
'feature/account',
|
|
31
|
+
'feature/sharing',
|
|
32
|
+
'androidApp/src/debug',
|
|
33
|
+
'iosApp/iosApp/SignInBridge.swift',
|
|
34
|
+
'iosApp/iosApp/iosApp.entitlements',
|
|
35
|
+
`shared/src/iosMain/kotlin/${TEMPLATE_PACKAGE_PATH}/SignInRegistration.kt`,
|
|
36
|
+
`shared/src/commonMain/kotlin/${TEMPLATE_PACKAGE_PATH}/SessionGate.kt`,
|
|
37
|
+
`shared/src/androidHostTest/kotlin/${TEMPLATE_PACKAGE_PATH}/MainScreenGateTest.kt`,
|
|
38
|
+
`shared/src/androidHostTest/kotlin/${TEMPLATE_PACKAGE_PATH}/di/BackendBindingsTest.kt`,
|
|
39
|
+
...['BackendClient', 'BackendConfig', 'EmulatorDetection', 'LocalHost', 'TokenSource']
|
|
40
|
+
.map((name) => `core/network/src/commonMain/kotlin/${TEMPLATE_PACKAGE_PATH}/core/network/${name}.kt`),
|
|
41
|
+
`core/network/src/androidMain/kotlin/${TEMPLATE_PACKAGE_PATH}/core/network/LocalHost.android.kt`,
|
|
42
|
+
`core/network/src/iosMain/kotlin/${TEMPLATE_PACKAGE_PATH}/core/network/LocalHost.ios.kt`,
|
|
43
|
+
...['AuthenticatedClientTest', 'BackendConfigTest', 'EmulatorDetectionTest']
|
|
44
|
+
.map((name) => `core/network/src/commonTest/kotlin/${TEMPLATE_PACKAGE_PATH}/core/network/${name}.kt`),
|
|
45
|
+
`core/image/src/commonMain/kotlin/${TEMPLATE_PACKAGE_PATH}/core/image/ImageNormalization.kt`,
|
|
46
|
+
`core/image/src/androidMain/kotlin/${TEMPLATE_PACKAGE_PATH}/core/image/ImageNormalization.android.kt`,
|
|
47
|
+
`core/image/src/iosMain/kotlin/${TEMPLATE_PACKAGE_PATH}/core/image/ImageNormalization.ios.kt`,
|
|
48
|
+
`core/image/src/commonTest/kotlin/${TEMPLATE_PACKAGE_PATH}/core/image/ImageNormalizationTest.kt`,
|
|
49
|
+
`core/image/src/androidDeviceTest/kotlin/${TEMPLATE_PACKAGE_PATH}/core/image/ImageNormalizationDeviceTest.kt`,
|
|
50
|
+
...['core/multilang', 'blank/layers/multilang/core/multilang'].flatMap((module) => ['AccountStrings', 'SharingStrings']
|
|
51
|
+
.map((name) => `${module}/src/commonMain/kotlin/${TEMPLATE_PACKAGE_PATH}/core/multilang/resource/${name}.kt`)),
|
|
52
|
+
...['SharedSpacesWarningTest', 'SharingStringsTest']
|
|
53
|
+
.map((name) => `core/multilang/src/commonTest/kotlin/${TEMPLATE_PACKAGE_PATH}/core/multilang/resource/${name}.kt`)
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Imports the template leaves outside the markers (import ordering) that only marked code uses
|
|
57
|
+
const BACKEND_ONLY_IMPORTS = [
|
|
58
|
+
/^import [\w.]+\.core\.auth\./,
|
|
59
|
+
/^import [\w.]+\.feature\.account\./,
|
|
60
|
+
/^import [\w.]+\.feature\.sharing\./
|
|
61
|
+
];
|
|
62
|
+
const IMPORT_FILE_KINDS = new Set(['.kt', '.kts', '.swift']);
|
|
63
|
+
|
|
64
|
+
const PBXPROJ = ['iosApp', 'iosApp.xcodeproj', 'project.pbxproj'];
|
|
65
|
+
const PACKAGE_RESOLVED = ['iosApp', 'iosApp.xcodeproj', 'project.xcworkspace', 'xcshareddata', 'swiftpm', 'Package.resolved'];
|
|
66
|
+
const BACKEND_PACKAGE_URLS = ['https://github.com/firebase/firebase-ios-sdk', 'https://github.com/google/GoogleSignIn-iOS'];
|
|
67
|
+
const COPY_GOOGLE_SERVICE_PHASE = 'Copy GoogleService-Info if present';
|
|
68
|
+
const BACKEND_MEMBERSHIP_EXCEPTION = /^\t+(?:iosApp\.entitlements|"GoogleService-Info\.plist"),$/;
|
|
69
|
+
const BACKEND_BUILD_SETTING = /^\t+(?:CODE_SIGN_ENTITLEMENTS|INFOPLIST_PREPROCESS|INFOPLIST_PREPROCESSOR_DEFINITIONS) = [^\n]*;$/;
|
|
70
|
+
|
|
71
|
+
// Firebase, GoogleSignIn and what they pull in; the template's other pins (GMA, UMP) stay
|
|
72
|
+
const BACKEND_PIN_IDENTITIES = new Set([
|
|
73
|
+
'abseil-cpp-binary', 'app-check', 'appauth-ios', 'firebase-ios-sdk', 'google-ads-on-device-conversion-ios-sdk',
|
|
74
|
+
'googleappmeasurement', 'googledatatransport', 'googlesignin-ios', 'googleutilities', 'grpc-binary',
|
|
75
|
+
'gtm-session-fetcher', 'gtmappauth', 'interop-ios-for-google-sdks', 'leveldb', 'nanopb', 'promises', 'swift-protobuf'
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Off: drops each marked block with its marker lines; on: drops only the marker lines. Either way a blank
|
|
79
|
+
// line on both sides of a dropped run becomes one (blank runs elsewhere stay).
|
|
80
|
+
function removeMarkedBlocks(content, kind, keep) {
|
|
81
|
+
const { open, close } = MARKERS[kind];
|
|
82
|
+
const out = [];
|
|
83
|
+
const joins = [];
|
|
84
|
+
let inside = false;
|
|
85
|
+
let dropped = false;
|
|
86
|
+
for (const line of content.split('\n')) {
|
|
87
|
+
const t = line.trim();
|
|
88
|
+
if (t === open) {
|
|
89
|
+
if (inside) throw new Error(`nested ${open}`);
|
|
90
|
+
inside = true;
|
|
91
|
+
dropped = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (t === close) {
|
|
95
|
+
if (!inside) throw new Error(`stray ${close}`);
|
|
96
|
+
inside = false;
|
|
97
|
+
dropped = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (inside && !keep) continue;
|
|
101
|
+
if (dropped) joins.push(out.length);
|
|
102
|
+
dropped = false;
|
|
103
|
+
out.push(line);
|
|
104
|
+
}
|
|
105
|
+
if (inside) throw new Error('unclosed cmpose:backend block');
|
|
106
|
+
if (dropped) joins.push(out.length);
|
|
107
|
+
return collapseBlankRuns(out, joins).join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// At each join (the index of the first line after a dropped run), blank lines that follow a blank line go
|
|
111
|
+
function collapseBlankRuns(lines, joins) {
|
|
112
|
+
const drop = new Set();
|
|
113
|
+
for (const join of joins) {
|
|
114
|
+
if (join === 0 || lines[join - 1] !== '') continue;
|
|
115
|
+
for (let i = join; i < lines.length && lines[i] === ''; i++) drop.add(i);
|
|
116
|
+
}
|
|
117
|
+
return lines.filter((_, i) => !drop.has(i));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Off: the line after a swap marker becomes the marker's text at that line's indentation; on: the marker goes
|
|
121
|
+
function applySwapMarkers(content, keep) {
|
|
122
|
+
const lines = content.split('\n');
|
|
123
|
+
const out = [];
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
const t = lines[i].trim();
|
|
126
|
+
if (!t.startsWith(SWAP)) { out.push(lines[i]); continue; }
|
|
127
|
+
const next = lines[i + 1];
|
|
128
|
+
if (next === undefined) throw new Error('cmpose:backend-off marker on the last line');
|
|
129
|
+
if (keep) { out.push(next); } else { out.push(next.match(/^\s*/)[0] + t.slice(SWAP.length)); }
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
return out.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Off: the Firebase and GoogleSignIn packages, the build phase copying GoogleService-Info.plist, the
|
|
136
|
+
// membership exceptions of the entitlements and GoogleService-Info.plist, and the entitlements and
|
|
137
|
+
// Info.plist preprocessing (LOCAL_SERVER) build settings. Text edits only.
|
|
138
|
+
function stripBackendPbxproj(content) {
|
|
139
|
+
const crlf = content.includes('\r\n');
|
|
140
|
+
let result = crlf ? content.replace(/\r\n/g, '\n') : content;
|
|
141
|
+
for (const url of BACKEND_PACKAGE_URLS) result = removeSwiftPackage(result, url);
|
|
142
|
+
result = removeCopyGoogleServicePhase(result);
|
|
143
|
+
result = result.split('\n')
|
|
144
|
+
.filter((line) => !BACKEND_MEMBERSHIP_EXCEPTION.test(line) && !BACKEND_BUILD_SETTING.test(line))
|
|
145
|
+
.join('\n');
|
|
146
|
+
if (result.includes(COPY_GOOGLE_SERVICE_PHASE)) {
|
|
147
|
+
throw new Error(`stripBackendPbxproj: "${COPY_GOOGLE_SERVICE_PHASE}" is in project.pbxproj but could not be removed (unexpected formatting)`);
|
|
148
|
+
}
|
|
149
|
+
return crlf ? result.replace(/\n/g, '\r\n') : result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function removeCopyGoogleServicePhase(content) {
|
|
153
|
+
const lines = content.split('\n');
|
|
154
|
+
const phases = parseObjects(lines).filter((object) => isa(object) === 'PBXShellScriptBuildPhase'
|
|
155
|
+
&& object.text.includes(`name = "${COPY_GOOGLE_SERVICE_PHASE}";`));
|
|
156
|
+
if (phases.length === 0) return content;
|
|
157
|
+
// One object per isa: a phase whose closing line is missing would swallow the next object
|
|
158
|
+
if (phases.some((object) => (object.text.match(/\bisa = /g) || []).length !== 1)) {
|
|
159
|
+
throw new Error(`stripBackendPbxproj: "${COPY_GOOGLE_SERVICE_PHASE}" could not be parsed (unexpected formatting)`);
|
|
160
|
+
}
|
|
161
|
+
const ids = new Set(phases.map((object) => object.id));
|
|
162
|
+
const drop = new Set();
|
|
163
|
+
for (const object of phases) {
|
|
164
|
+
for (let i = object.start; i <= object.end; i++) drop.add(i);
|
|
165
|
+
}
|
|
166
|
+
const listEntry = /^\t+([0-9A-F]{24})(?: \/\*.*?\*\/)?,$/;
|
|
167
|
+
lines.forEach((line, i) => {
|
|
168
|
+
const match = line.match(listEntry);
|
|
169
|
+
if (match && ids.has(match[1])) drop.add(i);
|
|
170
|
+
});
|
|
171
|
+
return lines.filter((_, i) => !drop.has(i)).join('\n')
|
|
172
|
+
.replace(/\n\n\/\* Begin (\w+) section \*\/\n\/\* End \1 section \*\/(?=\n)/g, '');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Package.resolved without the pins whose identity is in `identities`, in Xcode's layout ("key" : value).
|
|
176
|
+
// originHash goes (Xcode recomputes it); null when no pin is left, so the caller deletes the file.
|
|
177
|
+
function stripPins(json, identities) {
|
|
178
|
+
const resolved = JSON.parse(json);
|
|
179
|
+
const pins = (resolved.pins || []).filter((pin) => !identities.has(pin.identity));
|
|
180
|
+
if (pins.length === 0) return null;
|
|
181
|
+
// Nothing to drop: the file stays as Xcode wrote it (originHash included)
|
|
182
|
+
if (pins.length === (resolved.pins || []).length) return json;
|
|
183
|
+
const { originHash, ...rest } = resolved;
|
|
184
|
+
const text = JSON.stringify({ ...rest, pins }, null, 2).replace(/^(\s*"(?:[^"\\]|\\.)*"): /gm, '$1 : ');
|
|
185
|
+
return `${text}\n`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Off: Package.resolved without the backend's pins
|
|
189
|
+
function stripBackendPins(json) {
|
|
190
|
+
return stripPins(json, BACKEND_PIN_IDENTITIES);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function kindOf(file) {
|
|
194
|
+
const name = path.basename(file);
|
|
195
|
+
return KIND_BY_EXTENSION[name === '.gitignore' ? name : path.extname(name)];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function listMarkableFiles(dir) {
|
|
199
|
+
const files = [];
|
|
200
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
201
|
+
const full = path.join(dir, entry.name);
|
|
202
|
+
if (entry.isDirectory()) {
|
|
203
|
+
if (!SKIP_DIRS.has(entry.name)) files.push(...await listMarkableFiles(full));
|
|
204
|
+
} else if (kindOf(entry.name)) {
|
|
205
|
+
files.push(full);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return files;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Removes a path, then the directories it leaves empty (never projectDir itself)
|
|
212
|
+
async function removeAndPrune(projectDir, target) {
|
|
213
|
+
await fs.remove(target);
|
|
214
|
+
let dir = path.dirname(target);
|
|
215
|
+
while (dir.startsWith(projectDir + path.sep) && (await fs.pathExists(dir)) && (await fs.readdir(dir)).length === 0) {
|
|
216
|
+
await fs.remove(dir);
|
|
217
|
+
dir = path.dirname(dir);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderFile(content, file, enabled) {
|
|
222
|
+
const kind = kindOf(file);
|
|
223
|
+
let out = content;
|
|
224
|
+
if (kind === 'slash') out = applySwapMarkers(out, enabled);
|
|
225
|
+
out = removeMarkedBlocks(out, kind, enabled);
|
|
226
|
+
if (!enabled && IMPORT_FILE_KINDS.has(path.extname(file))) {
|
|
227
|
+
out = out.split('\n').filter((line) => !BACKEND_ONLY_IMPORTS.some((re) => re.test(line))).join('\n');
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The privacy manifest's cmpose:backend block belongs to writePrivacyManifest, so it is left alone here
|
|
233
|
+
async function applyBackendOption(projectDir, enabled) {
|
|
234
|
+
const root = path.resolve(projectDir);
|
|
235
|
+
if (!enabled) {
|
|
236
|
+
for (const relative of BACKEND_PATHS) {
|
|
237
|
+
const target = path.join(root, ...relative.split('/'));
|
|
238
|
+
if (await fs.pathExists(target)) await removeAndPrune(root, target);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const privacyManifest = path.join(root, ...PRIVACY_MANIFEST);
|
|
243
|
+
for (const file of await listMarkableFiles(root)) {
|
|
244
|
+
if (file === privacyManifest) continue;
|
|
245
|
+
const content = await fs.readFile(file, 'utf8');
|
|
246
|
+
if (!content.includes('cmpose:backend') && (enabled || !IMPORT_FILE_KINDS.has(path.extname(file)))) continue;
|
|
247
|
+
let updated;
|
|
248
|
+
try {
|
|
249
|
+
updated = renderFile(content, file, enabled);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw new Error(`${path.relative(root, file)}: ${error.message}`);
|
|
252
|
+
}
|
|
253
|
+
if (updated !== content) await fs.writeFile(file, updated);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (enabled) return;
|
|
257
|
+
|
|
258
|
+
const pbxproj = path.join(root, ...PBXPROJ);
|
|
259
|
+
if (await fs.pathExists(pbxproj)) {
|
|
260
|
+
const content = await fs.readFile(pbxproj, 'utf8');
|
|
261
|
+
const updated = stripBackendPbxproj(content);
|
|
262
|
+
if (updated !== content) await fs.writeFile(pbxproj, updated);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const resolved = path.join(root, ...PACKAGE_RESOLVED);
|
|
266
|
+
if (await fs.pathExists(resolved)) {
|
|
267
|
+
const content = await fs.readFile(resolved, 'utf8');
|
|
268
|
+
const updated = stripBackendPins(content);
|
|
269
|
+
if (updated === null) await removeAndPrune(root, resolved);
|
|
270
|
+
else if (updated !== content) await fs.writeFile(resolved, updated);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
removeMarkedBlocks,
|
|
276
|
+
applySwapMarkers,
|
|
277
|
+
stripBackendPbxproj,
|
|
278
|
+
stripBackendPins,
|
|
279
|
+
stripPins,
|
|
280
|
+
applyBackendOption,
|
|
281
|
+
BACKEND_PATHS,
|
|
282
|
+
BACKEND_ONLY_IMPORTS,
|
|
283
|
+
BACKEND_PIN_IDENTITIES
|
|
284
|
+
};
|
package/src/contract.js
CHANGED
|
@@ -8,10 +8,10 @@ const { OLD_PACKAGE_NAME } = require('./templateIdentity');
|
|
|
8
8
|
// sync-contract.sh; featuresConfig.js and the generator read the same objects.
|
|
9
9
|
const SCHEMA = {
|
|
10
10
|
templateTypes: ['blank', 'showcase'],
|
|
11
|
-
featuresConfigKeys: ['network', 'networkInspector', 'networkBaseUrl', 'theming', 'multiLang', 'dataStore', 'detekt', 'detektYamlContent', 'purchases', 'ads', 'room'],
|
|
11
|
+
featuresConfigKeys: ['network', 'networkInspector', 'networkBaseUrl', 'theming', 'multiLang', 'dataStore', 'detekt', 'detektYamlContent', 'purchases', 'ads', 'room', 'backend'],
|
|
12
12
|
dependencyRules: {
|
|
13
13
|
alwaysOn: ['koin', 'navigation'],
|
|
14
|
-
implies: { networkInspector: ['network'] },
|
|
14
|
+
implies: { networkInspector: ['network'], backend: ['multiLang', 'network'] }, // inspector => network; backend => multiLang + network
|
|
15
15
|
auto: { dataStore: ['theming', 'multiLang'] } // theming || multiLang => dataStore
|
|
16
16
|
},
|
|
17
17
|
// Top-level request options that apply to every template (featuresConfig is blank-only)
|
|
@@ -30,24 +30,31 @@ const SCHEMA = {
|
|
|
30
30
|
'core/database': { requires: ['dataStore'], note: 'DataStore persistence; with theming also the saved Light/Dark/System choice (DarkModeManager)' },
|
|
31
31
|
'core/purchases': { requires: ['purchases'], note: 'offer / purchase / restore API, RevenueCat KMP (Test Store key: replace before you ship)' },
|
|
32
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/' }
|
|
33
|
+
'core/room': { requires: ['room'], note: 'Room KMP database (bundled SQLite): AppDatabase, DAOs, schemas/' },
|
|
34
|
+
'core/api': { requires: ['backend'], note: 'models and routes shared by the app and the server' },
|
|
35
|
+
'core/auth': { requires: ['backend'], note: 'Firebase sign-in (email, Google, Apple); the server client\'s token' }
|
|
34
36
|
},
|
|
35
37
|
// featuresConfig flag -> feature modules kept in a blank project (same requires/requiresAny rules).
|
|
36
38
|
featureModules: {
|
|
37
39
|
'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)' }
|
|
40
|
+
'feature/notes': { requires: ['room'], note: 'Notes demo screen on Room (blank only; showcase uses Room for favorites)' },
|
|
41
|
+
'feature/account': { requires: ['backend'], note: 'sign-in screens and the account (blank only)' },
|
|
42
|
+
'feature/sharing': { requires: ['backend'], note: 'shared spaces and invites (blank only)' }
|
|
39
43
|
},
|
|
40
44
|
detektDir: { requires: ['detekt'], path: 'detekt/' },
|
|
45
|
+
// Ktor server (blank only): `./gradlew :server:runLocal` runs it locally
|
|
46
|
+
serverDir: { path: 'server/', requires: ['backend'] },
|
|
41
47
|
showcaseFeatures: ['feature/home', 'feature/monetization', 'feature/onboarding'],
|
|
42
48
|
customFeaturePath: 'feature/<name>',
|
|
43
49
|
customFeatureLayers: ['contract', 'data', 'domain', 'presentation']
|
|
44
50
|
},
|
|
45
51
|
// Deep links (<scheme>://<path>): the generator writes the scheme; '' opens the start screen and
|
|
46
|
-
// '<module>' each generated feature module
|
|
52
|
+
// '<module>' each generated feature module; backend adds the sharing invite (blank only)
|
|
47
53
|
deepLinks: {
|
|
48
54
|
scheme: 'projectName, lower case',
|
|
49
55
|
showcase: ['user/{username}', 'settings'],
|
|
50
|
-
blank: ['', '<module>']
|
|
56
|
+
blank: ['', '<module>'],
|
|
57
|
+
backend: { links: ['invite/<token>'], requires: ['backend'] }
|
|
51
58
|
}
|
|
52
59
|
};
|
|
53
60
|
|
package/src/featureModules.js
CHANGED
|
@@ -147,11 +147,10 @@ async function applyBlankShell(projectPath, templateType, featuresConfig = {}) {
|
|
|
147
147
|
await fs.remove(overlay);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
// What a blank project's
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// cache as stale.
|
|
150
|
+
// What a blank project's on flags need from the template. A template pinned before the release that
|
|
151
|
+
// brought them has none of it; generation then stops before the pipeline runs with a 500 that names the
|
|
152
|
+
// missing directories (not the generic verifier 500 after it; not 503, which the contract reserves for the
|
|
153
|
+
// concurrency limit that clients retry).
|
|
155
154
|
const MONETIZATION_LAYER = BLANK_LAYERS.find((layer) => layer.name === 'ads-without-purchases');
|
|
156
155
|
const MONETIZATION_CAPABILITIES = [
|
|
157
156
|
{ dir: 'core/purchases', needed: (cfg) => cfg.purchases === true },
|
|
@@ -159,6 +158,10 @@ const MONETIZATION_CAPABILITIES = [
|
|
|
159
158
|
{ dir: 'feature/monetization', needed: (cfg) => cfg.purchases === true || cfg.ads === true },
|
|
160
159
|
{ dir: `${BLANK_OVERLAY_DIR}/${LAYERS_DIR}/${MONETIZATION_LAYER.name}`, needed: MONETIZATION_LAYER.applies }
|
|
161
160
|
];
|
|
161
|
+
// Checked per request only: a readiness check (no config) does not need them, so a template pinned
|
|
162
|
+
// before the backend release still serves every other option
|
|
163
|
+
const BACKEND_CAPABILITIES = ['server', 'core/api', 'core/auth', 'feature/account', 'feature/sharing']
|
|
164
|
+
.map((dir) => ({ dir, needed: (cfg) => cfg.backend === true }));
|
|
162
165
|
|
|
163
166
|
class TemplateCapabilityError extends Error {
|
|
164
167
|
constructor(missing) {
|
|
@@ -170,15 +173,15 @@ class TemplateCapabilityError extends Error {
|
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
function templateLacksMessage(missing) {
|
|
173
|
-
return `Template lacks
|
|
176
|
+
return `Template lacks ${missing.join(', ')} (older than the release that added them); update templateRef`;
|
|
174
177
|
}
|
|
175
178
|
|
|
176
|
-
// Every
|
|
177
|
-
// means the cache is stale)
|
|
179
|
+
// Every directory the template at `templatePath` lacks for `cfg`'s on flags; without a config (a readiness
|
|
180
|
+
// check: any of them means the cache is stale), every monetization directory
|
|
178
181
|
async function missingTemplateCapabilities(templatePath, cfg = null) {
|
|
182
|
+
const capabilities = cfg ? [...MONETIZATION_CAPABILITIES, ...BACKEND_CAPABILITIES].filter((c) => c.needed(cfg)) : MONETIZATION_CAPABILITIES;
|
|
179
183
|
const missing = [];
|
|
180
|
-
for (const capability of
|
|
181
|
-
if (cfg && !capability.needed(cfg)) continue;
|
|
184
|
+
for (const capability of capabilities) {
|
|
182
185
|
if (!(await fs.pathExists(path.join(templatePath, capability.dir)))) missing.push(capability.dir);
|
|
183
186
|
}
|
|
184
187
|
return missing;
|
package/src/featuresConfig.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Pure function: no HTTP or file system, does not mutate its input.
|
|
4
4
|
*
|
|
5
5
|
* The web wizard applied the dependency rules (networkInspector ⇒ network,
|
|
6
|
-
* theming || multiLang ⇒ dataStore) only on the client; this function applies
|
|
6
|
+
* backend ⇒ multiLang + network, theming || multiLang ⇒ dataStore) only on the client; this function applies
|
|
7
7
|
* the same rules here too (as auto-true, never as an error) and fills missing
|
|
8
8
|
* keys with safe defaults, so `fileService.js` always gets a complete,
|
|
9
9
|
* consistent object.
|
|
@@ -20,7 +20,8 @@ const DEFAULTS = Object.freeze({
|
|
|
20
20
|
detekt: false,
|
|
21
21
|
purchases: false,
|
|
22
22
|
ads: false,
|
|
23
|
-
room: false
|
|
23
|
+
room: false,
|
|
24
|
+
backend: false
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
// Boolean flags = the contract's featuresConfig keys that have a default (networkBaseUrl and
|
package/src/fileService.js
CHANGED
|
@@ -2,6 +2,7 @@ const fs = require('fs-extra');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { listTextFiles } = require('./projectFiles');
|
|
4
4
|
const { removeSwiftPackage } = require('./xcodePackages');
|
|
5
|
+
const { stripPins } = require('./backendOption');
|
|
5
6
|
const { capitalize, TEST_SOURCE_SET_REGEX } = require('./featureModules');
|
|
6
7
|
const { removeMarkedBlock } = require('./readmeService');
|
|
7
8
|
const { log } = require('./log');
|
|
@@ -76,7 +77,10 @@ const NOTES_MODULE = 'notes';
|
|
|
76
77
|
const NOTES_LAYERS = ['contract', 'presentation'];
|
|
77
78
|
const TEMPLATE_FEATURE_MODULES = {
|
|
78
79
|
[MONETIZATION_MODULE]: (cfg) => cfg.purchases === true || cfg.ads === true,
|
|
79
|
-
[NOTES_MODULE]: (cfg) => cfg.room === true
|
|
80
|
+
[NOTES_MODULE]: (cfg) => cfg.room === true,
|
|
81
|
+
// The backend's sign-in gate (feature/account) and shared spaces (feature/sharing)
|
|
82
|
+
account: (cfg) => cfg.backend === true,
|
|
83
|
+
sharing: (cfg) => cfg.backend === true
|
|
80
84
|
};
|
|
81
85
|
|
|
82
86
|
// Deletes every test source set (src/*Test) of a Gradle module; a missing src/ is fine
|
|
@@ -232,6 +236,13 @@ function replaceInContent(content, oldPackage, newPackage, oldProject, newProjec
|
|
|
232
236
|
new RegExp(`applicationId\\s*=\\s*"${oldPackageEscaped}"`, 'g'),
|
|
233
237
|
() => `applicationId = "${newPackage}"`
|
|
234
238
|
);
|
|
239
|
+
|
|
240
|
+
// Any other mention of the package as a whole name (the server's main class and logger names, a
|
|
241
|
+
// plugin id applied with id("..."), a KDoc link), bounded like the verifier's stale identifier rule
|
|
242
|
+
updated = updated.replace(
|
|
243
|
+
new RegExp(`(?<![A-Za-z0-9_.])${oldPackageEscaped}(?![A-Za-z0-9_])`, 'g'),
|
|
244
|
+
() => newPackage
|
|
245
|
+
);
|
|
235
246
|
}
|
|
236
247
|
|
|
237
248
|
if (oldProject && newProject) {
|
|
@@ -1047,7 +1058,8 @@ function monitorResidueRules(packagePath) {
|
|
|
1047
1058
|
// substring would be a false positive for a valid appName such as "Tools -lsqlite3" (see
|
|
1048
1059
|
// above). Only an OTHER_LDFLAGS line that still links sqlite is residue.
|
|
1049
1060
|
{ file: ['iosApp', 'Configuration', 'Config.xcconfig'], tokens: [], patterns: [{ regex: /^\s*OTHER_LDFLAGS\s*=.*-lsqlite3/m, label: '-lsqlite3' }] },
|
|
1050
|
-
{ file: ['core', 'network', '
|
|
1061
|
+
{ file: ['core', 'network', 'src', 'commonMain', 'kotlin', packagePath, 'core', 'network', 'BackendClient.kt'], tokens: ['installNetworkMonitor'] },
|
|
1062
|
+
{ file: ['core', 'network', 'build.gradle.kts'], tokens: [...gradleTokens, '-lsqlite3'] },
|
|
1051
1063
|
{ file: ['androidApp', 'build.gradle.kts'], tokens: gradleTokens }
|
|
1052
1064
|
];
|
|
1053
1065
|
}
|
|
@@ -1162,6 +1174,8 @@ const ROOM_PLUGIN_ALIAS = /\balias\(libs\.plugins\.room\)/;
|
|
|
1162
1174
|
// .git, removeSwiftPackage matches it either way
|
|
1163
1175
|
const GMA_PACKAGE_URL = 'https://github.com/googleads/swift-package-manager-google-mobile-ads';
|
|
1164
1176
|
const PACKAGE_RESOLVED = ['iosApp', 'iosApp.xcodeproj', 'project.xcworkspace', 'xcshareddata', 'swiftpm', 'Package.resolved'];
|
|
1177
|
+
// The pins the GMA package brings into Package.resolved (the Google Mobile Ads SDK and UMP)
|
|
1178
|
+
const ADS_PIN_IDENTITIES = new Set(['swift-package-manager-google-mobile-ads', 'swift-package-manager-google-user-messaging-platform']);
|
|
1165
1179
|
|
|
1166
1180
|
const PLIST = new Set(['.plist']);
|
|
1167
1181
|
const XML = new Set(['.xml']);
|
|
@@ -1234,8 +1248,7 @@ const MONETIZATION_RESIDUE_FILES = {
|
|
|
1234
1248
|
purchases: () => [],
|
|
1235
1249
|
ads: (packagePath) => [
|
|
1236
1250
|
path.join('shared', 'src', 'iosMain', 'kotlin', packagePath, 'AdsBridgeRegistration.kt'),
|
|
1237
|
-
path.join('iosApp', 'iosApp', 'AdsBridge.swift')
|
|
1238
|
-
path.join(...PACKAGE_RESOLVED)
|
|
1251
|
+
path.join('iosApp', 'iosApp', 'AdsBridge.swift')
|
|
1239
1252
|
],
|
|
1240
1253
|
monetization: () => []
|
|
1241
1254
|
};
|
|
@@ -1257,6 +1270,34 @@ const ROOM_RESIDUE_TOKENS = [
|
|
|
1257
1270
|
];
|
|
1258
1271
|
const ROOM_RESIDUE_FILES = () => [path.join('core', 'room'), path.join('feature', 'notes')];
|
|
1259
1272
|
|
|
1273
|
+
// Backend off (applyBackendOption): the tokens the backend's parts must leave nowhere. Bounded like the
|
|
1274
|
+
// module references above, so a custom module (api, auth, account2, ...) never matches; the user's
|
|
1275
|
+
// package and a custom module's own class names are masked by scanForResidue. Markup and the Xcode
|
|
1276
|
+
// project are looked at only where user input cannot spell the token.
|
|
1277
|
+
const BACKEND_CODE_EXTENSIONS = new Set(['.kt', '.kts', '.toml', '.swift', '.properties']);
|
|
1278
|
+
const BACKEND_IOS_EXTENSIONS = new Set([...BACKEND_CODE_EXTENSIONS, '.pbxproj']);
|
|
1279
|
+
const code = (regex, label, extensions = BACKEND_CODE_EXTENSIONS) => ({ regex, label, extensions });
|
|
1280
|
+
const BACKEND_RESIDUE_TOKENS = [
|
|
1281
|
+
code(/\bcore\.auth(?![A-Za-z0-9_])/, 'core.auth'),
|
|
1282
|
+
code(/\bcore\.api(?![A-Za-z0-9_])/, 'core.api'),
|
|
1283
|
+
code(/\bBACKEND_CLIENT\b/, 'BACKEND_CLIENT'),
|
|
1284
|
+
code(/\bFirebaseAuth\b/, 'FirebaseAuth', BACKEND_IOS_EXTENSIONS),
|
|
1285
|
+
code(/\bFirebaseCore\b/, 'FirebaseCore', BACKEND_IOS_EXTENSIONS),
|
|
1286
|
+
code(/\bGoogleSignIn\b/, 'GoogleSignIn', BACKEND_IOS_EXTENSIONS),
|
|
1287
|
+
code(/\bgoogle-services\b/, 'google-services'),
|
|
1288
|
+
code(/\bgoogleServices\b/, 'googleServices'),
|
|
1289
|
+
code(/\bLOCAL_SERVER\b/, 'LOCAL_SERVER', BACKEND_IOS_EXTENSIONS),
|
|
1290
|
+
code(/^\s*#if LOCAL_SERVER\b/m, 'LOCAL_SERVER', PLIST),
|
|
1291
|
+
code(/com\.apple\.developer\.applesignin/, 'applesignin', new Set([...BACKEND_IOS_EXTENSIONS, '.plist'])),
|
|
1292
|
+
{ ...classToken('rememberSessionStart', /\brememberSessionStart\b/), extensions: BACKEND_CODE_EXTENSIONS },
|
|
1293
|
+
{ ...classToken('registerSignInBridge', /\bregisterSignInBridge\b/), extensions: BACKEND_CODE_EXTENSIONS },
|
|
1294
|
+
// Any marker the template left (block and swap markers); the privacy manifest's block is
|
|
1295
|
+
// writePrivacyManifest's, after this check
|
|
1296
|
+
code(/cmpose:backend/, 'cmpose:backend', new Set([...BACKEND_IOS_EXTENSIONS, '.xcconfig', '.plist', '.xml', '.md']))
|
|
1297
|
+
];
|
|
1298
|
+
const BACKEND_RESIDUE_FILES = () => ['server', path.join('core', 'api'), path.join('core', 'auth'),
|
|
1299
|
+
path.join('feature', 'account'), path.join('feature', 'sharing')];
|
|
1300
|
+
|
|
1260
1301
|
// Whether a MONETIZATION_RESIDUE_TOKENS key is off (monetization = purchases and ads both off)
|
|
1261
1302
|
function monetizationKeyOff(config, key) {
|
|
1262
1303
|
if (key === MONETIZATION_MODULE) return config.purchases !== true && config.ads !== true;
|
|
@@ -1344,7 +1385,8 @@ async function assertNoMonetizationResidue(projectPath, packageName, config) {
|
|
|
1344
1385
|
if (tokens.length === 0) return;
|
|
1345
1386
|
const packagePath = packageName.replace(/\./g, '/');
|
|
1346
1387
|
const files = offKeys.flatMap((key) => MONETIZATION_RESIDUE_FILES[key](packagePath));
|
|
1347
|
-
|
|
1388
|
+
const pins = offKeys.includes('ads') ? ADS_PIN_IDENTITIES : null;
|
|
1389
|
+
await scanForResidue(projectPath, packageName, { tokens, files, pins, title: 'Monetization residue after removal' });
|
|
1348
1390
|
}
|
|
1349
1391
|
|
|
1350
1392
|
// Room off: nothing of core/room or feature/notes is left (see ROOM_RESIDUE_TOKENS)
|
|
@@ -1357,13 +1399,38 @@ async function assertNoRoomResidue(projectPath, packageName, config) {
|
|
|
1357
1399
|
});
|
|
1358
1400
|
}
|
|
1359
1401
|
|
|
1402
|
+
// Backend off: nothing of the backend is left (see BACKEND_RESIDUE_TOKENS)
|
|
1403
|
+
async function assertNoBackendResidue(projectPath, packageName, config) {
|
|
1404
|
+
if (config.backend === true) return;
|
|
1405
|
+
await scanForResidue(projectPath, packageName, {
|
|
1406
|
+
tokens: BACKEND_RESIDUE_TOKENS,
|
|
1407
|
+
files: BACKEND_RESIDUE_FILES(),
|
|
1408
|
+
title: 'Backend residue after removal'
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1360
1412
|
// Fails with `title` and a file: label list when a path in `files` (relative to the project) is still
|
|
1361
1413
|
// there or a token is in a file it is looked for in (user input and the user's own package masked)
|
|
1362
|
-
async function scanForResidue(projectPath, packageName, { tokens, files
|
|
1414
|
+
async function scanForResidue(projectPath, packageName, { tokens, files, pins, title }) {
|
|
1415
|
+
const problems = await findResidue(projectPath, packageName, { tokens, files, pins });
|
|
1416
|
+
if (problems.length > 0) {
|
|
1417
|
+
throw new Error(`${title}:\n ${problems.join('\n ')}`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// scanForResidue's problems as a `file: label` list (the verifier collects them); `pins`: identities
|
|
1422
|
+
// Package.resolved, if there, must no longer pin
|
|
1423
|
+
async function findResidue(projectPath, packageName, { tokens, files: residueFiles, pins = null }) {
|
|
1363
1424
|
const problems = [];
|
|
1364
1425
|
for (const rel of residueFiles) {
|
|
1365
1426
|
if (await fs.pathExists(path.join(projectPath, rel))) problems.push(`${rel}: file still present`);
|
|
1366
1427
|
}
|
|
1428
|
+
const resolvedPath = path.join(projectPath, ...PACKAGE_RESOLVED);
|
|
1429
|
+
if (pins && (await fs.pathExists(resolvedPath))) {
|
|
1430
|
+
for (const pin of JSON.parse(await fs.readFile(resolvedPath, 'utf8')).pins || []) {
|
|
1431
|
+
if (pins.has(pin.identity)) problems.push(`${path.join(...PACKAGE_RESOLVED)}: ${pin.identity} pin`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1367
1434
|
|
|
1368
1435
|
const scanned = new Set(tokens.flatMap((token) => [...tokenExtensions(token)]));
|
|
1369
1436
|
const files = [];
|
|
@@ -1400,10 +1467,7 @@ async function scanForResidue(projectPath, packageName, { tokens, files: residue
|
|
|
1400
1467
|
}
|
|
1401
1468
|
}
|
|
1402
1469
|
}
|
|
1403
|
-
|
|
1404
|
-
if (problems.length > 0) {
|
|
1405
|
-
throw new Error(`${title}:\n ${problems.join('\n ')}`);
|
|
1406
|
-
}
|
|
1470
|
+
return problems;
|
|
1407
1471
|
}
|
|
1408
1472
|
|
|
1409
1473
|
async function processCoreFeatures(projectPath, config, packageName) {
|
|
@@ -1479,6 +1543,25 @@ async function processCoreFeatures(projectPath, config, packageName) {
|
|
|
1479
1543
|
log.info('✅ Cleaned NetworkModule.kt');
|
|
1480
1544
|
}
|
|
1481
1545
|
|
|
1546
|
+
// 3a. Backend on: the backend's client records its calls in debug builds too, and core/network's iOS
|
|
1547
|
+
// test executable links the system SQLite only for the monitor
|
|
1548
|
+
const backendClientPath = path.join(projectPath, 'core', 'network', 'src', 'commonMain', 'kotlin', packagePath, 'core', 'network', 'BackendClient.kt');
|
|
1549
|
+
if (await fs.pathExists(backendClientPath)) {
|
|
1550
|
+
let content = await fs.readFile(backendClientPath, 'utf8');
|
|
1551
|
+
content = content.split('\n').filter((line) => {
|
|
1552
|
+
const t = line.trim();
|
|
1553
|
+
return t !== 'if (isDebug) installNetworkMonitor()' && !t.startsWith('// Debug builds only, like the request log: the monitor');
|
|
1554
|
+
}).join('\n');
|
|
1555
|
+
content = content.replace(/\n[ \t]*\n([ \t]*\})/g, '\n$1');
|
|
1556
|
+
await fs.writeFile(backendClientPath, content);
|
|
1557
|
+
}
|
|
1558
|
+
const networkGradlePath = path.join(projectPath, 'core', 'network', 'build.gradle.kts');
|
|
1559
|
+
if (await fs.pathExists(networkGradlePath)) {
|
|
1560
|
+
const content = await fs.readFile(networkGradlePath, 'utf8');
|
|
1561
|
+
const cleaned = content.replace(/\n*\/\/ The backend client \(BackendClient\.kt\) keeps the network monitor[^\n]*\n\/\/[^\n]*\nkotlin\.targets\.withType<[^\n]*KotlinNativeTarget>\(\)\.configureEach \{\n[^\n]*TestExecutable>\(\)\.configureEach \{\n\s*linkerOpts\("-lsqlite3"\)\n\s*\}\n\}/, '');
|
|
1562
|
+
if (cleaned !== content) await fs.writeFile(networkGradlePath, cleaned);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1482
1565
|
// 3b. Current template: remove the monitor helper files
|
|
1483
1566
|
for (const [sourceSet, fileName] of MONITOR_HELPER_FILES) {
|
|
1484
1567
|
await fs.remove(path.join(projectPath, 'core', 'network', 'src', sourceSet, 'kotlin', packagePath, 'core', 'network', fileName));
|
|
@@ -1596,7 +1679,8 @@ async function processCoreFeatures(projectPath, config, packageName) {
|
|
|
1596
1679
|
// permission stays while a monetization flag is on.
|
|
1597
1680
|
const manifestPath = path.join(projectPath, 'androidApp', 'src', 'main', 'AndroidManifest.xml');
|
|
1598
1681
|
const monetizationNeedsInternet = config.purchases === true || config.ads === true;
|
|
1599
|
-
|
|
1682
|
+
// backend implies network, so this is the network block's own guard should that rule change
|
|
1683
|
+
if (!monetizationNeedsInternet && config.backend !== true) {
|
|
1600
1684
|
await dropLines(manifestPath, (line) =>
|
|
1601
1685
|
/<uses-permission android:name="android\.permission\.INTERNET"\s*\/>/.test(line));
|
|
1602
1686
|
}
|
|
@@ -1929,14 +2013,19 @@ async function processCoreFeatures(projectPath, config, packageName) {
|
|
|
1929
2013
|
await rewriteIfExists(path.join(projectPath, 'androidApp', 'src', 'main', 'AndroidManifest.xml'), stripAdsManifest);
|
|
1930
2014
|
await rewriteIfExists(path.join(projectPath, 'iosApp', 'iosApp', 'Info.plist'), stripAdsInfoPlist);
|
|
1931
2015
|
|
|
1932
|
-
// 5. The Google Mobile Ads Swift package. Package.resolved
|
|
1933
|
-
// as a whole (Xcode writes a new one if the user adds a
|
|
1934
|
-
// xcshareddata/ directories it leaves empty.
|
|
2016
|
+
// 5. The Google Mobile Ads Swift package. Package.resolved loses the GMA and UMP pins (the
|
|
2017
|
+
// backend's stay); with no pin left it goes as a whole (Xcode writes a new one if the user adds a
|
|
2018
|
+
// package), with the swiftpm/ and xcshareddata/ directories it leaves empty.
|
|
1935
2019
|
await rewriteIfExists(
|
|
1936
2020
|
path.join(projectPath, 'iosApp', 'iosApp.xcodeproj', 'project.pbxproj'),
|
|
1937
2021
|
(content) => removeSwiftPackage(content, GMA_PACKAGE_URL)
|
|
1938
2022
|
);
|
|
1939
|
-
|
|
2023
|
+
const resolvedPath = path.join(projectPath, ...PACKAGE_RESOLVED);
|
|
2024
|
+
if (await fs.pathExists(resolvedPath)) {
|
|
2025
|
+
const stripped = stripPins(await fs.readFile(resolvedPath, 'utf8'), ADS_PIN_IDENTITIES);
|
|
2026
|
+
if (stripped === null) await fs.remove(resolvedPath);
|
|
2027
|
+
else await fs.writeFile(resolvedPath, stripped);
|
|
2028
|
+
}
|
|
1940
2029
|
for (const depth of [1, 2]) {
|
|
1941
2030
|
const emptied = path.join(projectPath, ...PACKAGE_RESOLVED.slice(0, -depth));
|
|
1942
2031
|
if ((await fs.pathExists(emptied)) && (await fs.readdir(emptied)).length === 0) await fs.remove(emptied);
|
|
@@ -2035,6 +2124,8 @@ async function processCoreFeatures(projectPath, config, packageName) {
|
|
|
2035
2124
|
await assertNoMonetizationResidue(projectPath, packageName, config);
|
|
2036
2125
|
// Room residue check, at the same point and for the same reason
|
|
2037
2126
|
await assertNoRoomResidue(projectPath, packageName, config);
|
|
2127
|
+
// Backend residue check (applyBackendOption removed it on the template package), likewise
|
|
2128
|
+
await assertNoBackendResidue(projectPath, packageName, config);
|
|
2038
2129
|
|
|
2039
2130
|
// --- Detekt Removal Logic ---
|
|
2040
2131
|
if (config.detekt !== true) {
|
|
@@ -2096,6 +2187,20 @@ async function processCoreFeatures(projectPath, config, packageName) {
|
|
|
2096
2187
|
}
|
|
2097
2188
|
}
|
|
2098
2189
|
|
|
2190
|
+
// 6. Modules outside the convention plugins that apply it by id (core/api and the server: the
|
|
2191
|
+
// backend's JVM-capable modules)
|
|
2192
|
+
const detektPluginLine = `id("${packageName}.detekt")`;
|
|
2193
|
+
for (const moduleDir of [path.join(projectPath, 'core', 'api'), path.join(projectPath, 'server')]) {
|
|
2194
|
+
await dropLines(path.join(moduleDir, 'build.gradle.kts'), (line) => line.trim() === detektPluginLine);
|
|
2195
|
+
}
|
|
2196
|
+
// The server also points detekt at its local runner's sources
|
|
2197
|
+
const serverGradle = path.join(projectPath, 'server', 'build.gradle.kts');
|
|
2198
|
+
if (await fs.pathExists(serverGradle)) {
|
|
2199
|
+
const content = await fs.readFile(serverGradle, 'utf8');
|
|
2200
|
+
const cleaned = content.replace(/(?:^|\n)(?:\/\/[^\n]*\n)?detekt \{ source\.from\("src\/local\/kotlin"\) \}\n(?:[ \t]*\n)?/, '\n');
|
|
2201
|
+
if (cleaned !== content) await fs.writeFile(serverGradle, cleaned);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2099
2204
|
// The CI workflow's detekt step would fail without the plugin
|
|
2100
2205
|
const ciPath = path.join(projectPath, '.github', 'workflows', 'ci.yml');
|
|
2101
2206
|
if (await fs.pathExists(ciPath)) {
|
|
@@ -2168,6 +2273,9 @@ module.exports = {
|
|
|
2168
2273
|
assertNoNetworkResidue,
|
|
2169
2274
|
assertNoMonetizationResidue,
|
|
2170
2275
|
assertNoRoomResidue,
|
|
2276
|
+
assertNoBackendResidue,
|
|
2277
|
+
findResidue,
|
|
2278
|
+
BACKEND_RESIDUE_TOKENS,
|
|
2171
2279
|
processCoreFeatures,
|
|
2172
2280
|
sortImportsLikeKtlint,
|
|
2173
2281
|
sortKotlinImports
|
package/src/generate.js
CHANGED
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
} = require('./fileService');
|
|
11
11
|
const { applyBlankShell, cloneFeatureModules, assertTemplateCapabilities } = require('./featureModules');
|
|
12
12
|
const { applyCiOption } = require('./ciOption');
|
|
13
|
+
const { applyBackendOption } = require('./backendOption');
|
|
13
14
|
const { writePrivacyManifest } = require('./privacyManifest');
|
|
14
15
|
const { fillReadme } = require('./readmeService');
|
|
15
16
|
const { buildGenerationRecord, writeGenerationRecord } = require('./generationRecord');
|
|
@@ -22,7 +23,7 @@ async function generateCmpProject(value, { projectDir, generatorVersion = null }
|
|
|
22
23
|
const { projectName, appName, packageName, templateType, features, featuresConfig, ci, minSdk, iosVersion } = value;
|
|
23
24
|
const blankConfig = templateType === 'blank' ? featuresConfig : null;
|
|
24
25
|
|
|
25
|
-
// An older template without the
|
|
26
|
+
// An older template without the modules an on flag needs stops here, naming what is missing
|
|
26
27
|
await assertTemplateCapabilities(projectDir, templateType, featuresConfig);
|
|
27
28
|
|
|
28
29
|
// The template's own versions (SDK levels for the log, the README's requirements table)
|
|
@@ -52,6 +53,8 @@ async function generateCmpProject(value, { projectDir, generatorVersion = null }
|
|
|
52
53
|
// blank's shell and every requested module come from the template, applied on the
|
|
53
54
|
// template's own package before the rename below
|
|
54
55
|
await applyBlankShell(projectDir, templateType, featuresConfig);
|
|
56
|
+
// The backend's parts stay (markers dropped) for blank with backend on; everywhere else they go
|
|
57
|
+
await applyBackendOption(projectDir, templateType === 'blank' && featuresConfig.backend === true);
|
|
55
58
|
await cloneFeatureModules(projectDir, sanitizedFeatures);
|
|
56
59
|
log.info(`✅ Template skeleton ready (${sanitizedFeatures.length} feature modules)`);
|
|
57
60
|
|
|
@@ -76,10 +79,11 @@ async function generateCmpProject(value, { projectDir, generatorVersion = null }
|
|
|
76
79
|
|
|
77
80
|
await updateiOSProject(projectDir, packageName, iosVersion);
|
|
78
81
|
await updateiOSConfigFiles(projectDir, packageName, appName, iosVersion);
|
|
79
|
-
// Store-ready: the privacy manifest declares the data the on flags collect (showcase:
|
|
82
|
+
// Store-ready: the privacy manifest declares the data the on flags collect (showcase: ads and
|
|
83
|
+
// purchases on, never backend)
|
|
80
84
|
await writePrivacyManifest(projectDir, templateType === 'blank'
|
|
81
|
-
? { ads: featuresConfig.ads === true, purchases: featuresConfig.purchases === true }
|
|
82
|
-
: { ads: true, purchases: true });
|
|
85
|
+
? { ads: featuresConfig.ads === true, purchases: featuresConfig.purchases === true, backend: featuresConfig.backend === true }
|
|
86
|
+
: { ads: true, purchases: true, backend: false });
|
|
83
87
|
log.info('✅ iOS configuration updated');
|
|
84
88
|
|
|
85
89
|
await fillReadme(projectDir, { versions, minSdk, iosVersion });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fs = require('fs-extra');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { listTextFiles } = require('./projectFiles');
|
|
4
|
-
const { escapeRegExp } = require('./fileService');
|
|
4
|
+
const { escapeRegExp, findResidue, BACKEND_RESIDUE_TOKENS } = require('./fileService');
|
|
5
5
|
const { TEST_SOURCE_SET_REGEX } = require('./featureModules');
|
|
6
6
|
const { OLD_PACKAGE_NAME } = require('./templateIdentity');
|
|
7
7
|
|
|
@@ -324,7 +324,7 @@ async function verifyStoreReady(projectPath, input, packagePath, problems) {
|
|
|
324
324
|
if (manifest.includes('cmpose:')) problems.add(`${manifestRel}: cmpose marker left`);
|
|
325
325
|
if (!manifest.includes('NSPrivacyAccessedAPICategoryUserDefaults')) problems.add(`${manifestRel}: required reason APIs missing`);
|
|
326
326
|
|
|
327
|
-
const flags = input.featuresConfig ? input.featuresConfig : { ads: true, purchases: true };
|
|
327
|
+
const flags = input.featuresConfig ? input.featuresConfig : { ads: true, purchases: true, backend: false };
|
|
328
328
|
if (flags.ads === true) {
|
|
329
329
|
if (!manifest.includes('NSPrivacyCollectedDataTypeAdvertisingData')) {
|
|
330
330
|
problems.add(`${manifestRel}: ad data not declared although ads is on`);
|
|
@@ -338,6 +338,12 @@ async function verifyStoreReady(projectPath, input, packagePath, problems) {
|
|
|
338
338
|
} else if (flags.purchases !== true && purchaseHistory) {
|
|
339
339
|
problems.add(`${manifestRel}: purchase history declared although purchases is off`);
|
|
340
340
|
}
|
|
341
|
+
const accountData = manifest.includes('NSPrivacyCollectedDataTypeEmailAddress');
|
|
342
|
+
if (flags.backend === true && !accountData) {
|
|
343
|
+
problems.add(`${manifestRel}: account data not declared although backend is on`);
|
|
344
|
+
} else if (flags.backend !== true && accountData) {
|
|
345
|
+
problems.add(`${manifestRel}: account data declared although backend is off`);
|
|
346
|
+
}
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
// Network UX: every project keeps core/update, wired through settings, initKoin and MainScreen, which
|
|
@@ -489,6 +495,65 @@ async function readIfExists(file) {
|
|
|
489
495
|
return (await fs.pathExists(file)) ? fs.readFile(file, 'utf8') : '';
|
|
490
496
|
}
|
|
491
497
|
|
|
498
|
+
// Backend: blank keeps the server, core/api, core/auth, feature/account and feature/sharing exactly when its
|
|
499
|
+
// backend flag is on, wired, with the Google Sign-In URL scheme left to the build setting; otherwise (and
|
|
500
|
+
// always in showcase) none of them and no reference to them. No cmpose:backend marker is ever left.
|
|
501
|
+
const BACKEND_MODULES = [
|
|
502
|
+
{ dir: 'server', build: 'server/build.gradle.kts', include: ':server' },
|
|
503
|
+
{ dir: 'core/api', build: 'core/api/build.gradle.kts', include: ':core:api' },
|
|
504
|
+
{ dir: 'core/auth', build: 'core/auth/build.gradle.kts', include: ':core:auth' },
|
|
505
|
+
{ dir: 'feature/account', build: 'feature/account/presentation/build.gradle.kts', include: ':feature:account:presentation' },
|
|
506
|
+
{ dir: 'feature/sharing', build: 'feature/sharing/presentation/build.gradle.kts', include: ':feature:sharing:presentation' }
|
|
507
|
+
];
|
|
508
|
+
const BACKEND_SHARED_DEPENDENCIES = ['projects.core.auth', 'projects.feature.account.presentation', 'projects.feature.sharing.presentation'];
|
|
509
|
+
const BACKEND_KOIN_MODULES = ['AuthModule', 'AccountModule', 'SharingModule'];
|
|
510
|
+
const GOOGLE_URL_SCHEME = '<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>';
|
|
511
|
+
const BACKEND_MARKER = 'cmpose:backend';
|
|
512
|
+
|
|
513
|
+
async function verifyBackend(projectPath, input, cfg, problems) {
|
|
514
|
+
const on = input.templateType === 'blank' && cfg.backend === true;
|
|
515
|
+
const exists = (rel) => fs.pathExists(path.join(projectPath, rel));
|
|
516
|
+
|
|
517
|
+
for (const file of await listTextFiles(projectPath)) {
|
|
518
|
+
if ((await fs.readFile(file, 'utf8')).includes(BACKEND_MARKER)) problems.add(`${relPath(projectPath, file)}: ${BACKEND_MARKER} marker left`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!on) {
|
|
522
|
+
const where = input.templateType === 'blank' ? 'although backend is off' : 'in a showcase project';
|
|
523
|
+
for (const { dir } of BACKEND_MODULES) {
|
|
524
|
+
if (await exists(dir)) problems.add(`${dir}: present ${where}`);
|
|
525
|
+
}
|
|
526
|
+
const tokens = BACKEND_RESIDUE_TOKENS.filter((token) => token.label !== BACKEND_MARKER);
|
|
527
|
+
for (const problem of await findResidue(projectPath, input.packageName, { tokens, files: [] })) {
|
|
528
|
+
problems.add(problem.split(path.sep).join('/'));
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const settings = await readIfExists(path.join(projectPath, 'settings.gradle.kts'));
|
|
534
|
+
for (const { build, include } of BACKEND_MODULES) {
|
|
535
|
+
if (!(await exists(build))) problems.add(`${build}: missing although backend is on`);
|
|
536
|
+
if (!settings.includes(`"${include}"`)) problems.add(`settings.gradle.kts: ${include} not included`);
|
|
537
|
+
}
|
|
538
|
+
const sharedBuild = await readIfExists(path.join(projectPath, 'shared', 'build.gradle.kts'));
|
|
539
|
+
for (const dependency of BACKEND_SHARED_DEPENDENCIES) {
|
|
540
|
+
if (!new RegExp(`\\b${escapeRegExp(dependency)}(?![A-Za-z0-9.])`).test(sharedBuild)) {
|
|
541
|
+
problems.add(`shared/build.gradle.kts: ${dependency} not a dependency`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const packagePath = input.packageName.replace(/\./g, '/');
|
|
545
|
+
const initKoinRel = `shared/src/commonMain/kotlin/${packagePath}/di/initKoin.kt`;
|
|
546
|
+
const initKoin = await readIfExists(path.join(projectPath, initKoinRel));
|
|
547
|
+
// Word match: a custom module named "auth" brings AuthPresentationModule, not AuthModule
|
|
548
|
+
for (const module of BACKEND_KOIN_MODULES) {
|
|
549
|
+
if (!new RegExp(`\\b${module}\\b`).test(initKoin)) problems.add(`${initKoinRel}: ${module} not registered`);
|
|
550
|
+
}
|
|
551
|
+
const plistRel = 'iosApp/iosApp/Info.plist';
|
|
552
|
+
if (!(await readIfExists(path.join(projectPath, plistRel))).includes(GOOGLE_URL_SCHEME)) {
|
|
553
|
+
problems.add(`${plistRel}: the $(GOOGLE_REVERSED_CLIENT_ID) URL scheme is missing`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
492
557
|
/**
|
|
493
558
|
* Checks the generated project right before it is zipped; throws if anything is wrong (the request fails).
|
|
494
559
|
* @param {string} projectPath
|
|
@@ -598,6 +663,9 @@ async function verifyGeneratedProject(projectPath, input) {
|
|
|
598
663
|
// 13. Paywall: the template's own paywall, the first-launch call and the interstitial pacer
|
|
599
664
|
await verifyPaywall(projectPath, input, packagePath, problems);
|
|
600
665
|
|
|
666
|
+
// 14. Backend: the server and its modules exactly when blank's backend flag is on, no residue otherwise
|
|
667
|
+
await verifyBackend(projectPath, input, input.featuresConfig || {}, problems);
|
|
668
|
+
|
|
601
669
|
if (problems.size > 0) {
|
|
602
670
|
// Cap the message: a badly broken generation (many unwired modules) can produce dozens of
|
|
603
671
|
// lines, which is unreadable in an error response or a log line.
|
|
@@ -610,4 +678,4 @@ async function verifyGeneratedProject(projectPath, input) {
|
|
|
610
678
|
}
|
|
611
679
|
}
|
|
612
680
|
|
|
613
|
-
module.exports = { verifyGeneratedProject };
|
|
681
|
+
module.exports = { verifyGeneratedProject, verifyBackend };
|
package/src/index.js
CHANGED
|
@@ -26,6 +26,7 @@ module.exports = {
|
|
|
26
26
|
generationRecord: require('./generationRecord'),
|
|
27
27
|
fileService: require('./fileService'),
|
|
28
28
|
featureModules: require('./featureModules'),
|
|
29
|
+
backendOption: require('./backendOption'),
|
|
29
30
|
TemplateCapabilityError: require('./featureModules').TemplateCapabilityError,
|
|
30
31
|
verifyGeneratedProject: require('./generationVerifier').verifyGeneratedProject,
|
|
31
32
|
validate: require('./validate').validate,
|
package/src/pins.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// The template tags this core version was tested with (immutable tags)
|
|
3
3
|
module.exports = Object.freeze({
|
|
4
|
-
cmp: Object.freeze({ repo: '
|
|
4
|
+
cmp: Object.freeze({ repo: 'whinstudio/cmpose-template', ref: 'template-2026.10.16' }),
|
|
5
5
|
// The release step replaces the placeholder with the game template's tag
|
|
6
|
-
game: Object.freeze({ repo: '
|
|
6
|
+
game: Object.freeze({ repo: 'whinstudio/cmpose-game-template', ref: 'game-template-2026.10.15.2' })
|
|
7
7
|
});
|
package/src/privacyManifest.js
CHANGED
|
@@ -4,7 +4,7 @@ const path = require('path');
|
|
|
4
4
|
// Store-ready: iosApp/iosApp/PrivacyInfo.xcprivacy marks what a flag adds. A block (each marker on its own
|
|
5
5
|
// line) goes when its flag is off and loses only its markers when on; the tracking value line flips.
|
|
6
6
|
const PRIVACY_MANIFEST = ['iosApp', 'iosApp', 'PrivacyInfo.xcprivacy'];
|
|
7
|
-
const BLOCKS = ['ads', 'purchases'];
|
|
7
|
+
const BLOCKS = ['ads', 'purchases', 'backend'];
|
|
8
8
|
const TRACKING_LINE = /<true\/><!-- cmpose:ads-tracking -->/;
|
|
9
9
|
|
|
10
10
|
function renderPrivacyManifest(content, flags) {
|
package/src/readmeService.js
CHANGED
|
@@ -23,7 +23,9 @@ const CORE_DESCRIPTIONS = {
|
|
|
23
23
|
review: 'in-app review (Play In-App Review, StoreKit) and the store page link',
|
|
24
24
|
update: 'app updates: Play In-App Updates, the App Store lookup and an optional minimum version',
|
|
25
25
|
ads: 'AdMob ads and consent (test IDs)',
|
|
26
|
-
room: 'Room database (bundled SQLite): AppDatabase, DAOs and schemas'
|
|
26
|
+
room: 'Room database (bundled SQLite): AppDatabase, DAOs and schemas',
|
|
27
|
+
api: 'models and routes shared by the app and the server',
|
|
28
|
+
auth: "Firebase sign-in: email, Google, Apple; the server client's token"
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
function renderRequirements(versions, { minSdk, iosVersion }) {
|
|
@@ -64,6 +66,9 @@ function renderModules(settingsContent) {
|
|
|
64
66
|
const layers = paths.filter((x) => x.startsWith(`:feature:${feature}:`)).map((x) => x.split(':')[3]).sort();
|
|
65
67
|
lines.push(layers.length ? `- \`:feature:${feature}\`: ${layers.join(', ')}` : `- \`:feature:${feature}\``);
|
|
66
68
|
}
|
|
69
|
+
if (paths.includes(':server')) {
|
|
70
|
+
lines.push('- `:server`: Ktor server: sign-in check, spaces, sharing, files; `./gradlew :server:runLocal`');
|
|
71
|
+
}
|
|
67
72
|
return lines.join('\n');
|
|
68
73
|
}
|
|
69
74
|
|
package/src/warnings.js
CHANGED
|
@@ -5,6 +5,10 @@ function generationWarnings({ templateType, featuresConfig }) {
|
|
|
5
5
|
const warnings = [];
|
|
6
6
|
if (on('ads')) warnings.push('Ads use AdMob test IDs: replace them with your own before release.');
|
|
7
7
|
if (on('purchases')) warnings.push('Purchases use the RevenueCat Test Store key: replace it before release.');
|
|
8
|
+
// Backend is blank only: showcase never has it
|
|
9
|
+
if (templateType === 'blank' && featuresConfig != null && featuresConfig.backend === true) {
|
|
10
|
+
warnings.push('Sign-in needs your Firebase config files (google-services.json, GoogleService-Info.plist) and the server: run ./gradlew :server:runLocal; see the README.');
|
|
11
|
+
}
|
|
8
12
|
return warnings;
|
|
9
13
|
}
|
|
10
14
|
|
package/src/xcodePackages.js
CHANGED