@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 CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cmpose/core",
3
- "version": "0.2.4",
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/Keremturker/cmpose.git",
8
+ "url": "git+https://github.com/whinstudio/cmpose.git",
9
9
  "directory": "packages/core"
10
10
  },
11
- "homepage": "https://github.com/Keremturker/cmpose/tree/main/packages/core#readme",
11
+ "homepage": "https://github.com/whinstudio/cmpose/tree/main/packages/core#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/Keremturker/cmpose/issues"
13
+ "url": "https://github.com/whinstudio/cmpose/issues"
14
14
  },
15
15
  "keywords": [
16
16
  "compose-multiplatform",
@@ -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'] }, // inspector => 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
 
@@ -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 purchases/ads flags need from the template. A template pinned before
151
- // the monetization release has none of it; generation then stops before the pipeline runs with a
152
- // 500 that names the missing modules (not the generic verifier 500 after it; not 503, which the
153
- // contract reserves for the concurrency limit that clients retry), and a readiness check reports the
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 the monetization modules (missing: ${missing.join(', ')}); update templateRef`;
176
+ return `Template lacks ${missing.join(', ')} (older than the release that added them); update templateRef`;
174
177
  }
175
178
 
176
- // Every monetization directory the template at `templatePath` lacks (for a readiness check: any of them
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 MONETIZATION_CAPABILITIES) {
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;
@@ -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
@@ -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', 'build.gradle.kts'], tokens: gradleTokens },
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
- await scanForResidue(projectPath, packageName, { tokens, files, title: 'Monetization residue after removal' });
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: residueFiles, title }) {
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
- if (!monetizationNeedsInternet) {
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 pins only GMA and UMP, so it goes
1933
- // as a whole (Xcode writes a new one if the user adds a package), with the swiftpm/ and
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
- await fs.remove(path.join(projectPath, ...PACKAGE_RESOLVED));
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 monetization modules stops here, naming what is missing
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: both on)
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: 'Keremturker/cmpose-template', ref: 'template-2026.10.14' }),
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: 'Keremturker/cmpose-game-template', ref: 'game-template-2026.10.15.2' })
6
+ game: Object.freeze({ repo: 'whinstudio/cmpose-game-template', ref: 'game-template-2026.10.15.2' })
7
7
  });
@@ -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) {
@@ -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
 
@@ -114,4 +114,4 @@ function removeSwiftPackageLf(content, repositoryUrl) {
114
114
  return result;
115
115
  }
116
116
 
117
- module.exports = { removeSwiftPackage };
117
+ module.exports = { removeSwiftPackage, parseObjects, isa };