@cmpose/core 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmpose/core",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "cmpose project generator core (Compose Multiplatform)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,10 @@ const BACKEND_SCHEMA = Object.freeze({
26
26
  'user', 'using', 'variadic', 'verbose', 'when', 'where', 'window', 'with'
27
27
  ])
28
28
  }),
29
- options: Object.freeze({ sharing: Object.freeze({ type: 'boolean', default: false }) }),
29
+ options: Object.freeze({
30
+ sharing: Object.freeze({ type: 'boolean', default: false }),
31
+ sample: Object.freeze({ type: 'boolean', default: true })
32
+ }),
30
33
  // The template's own names, which the generator renames
31
34
  identity: Object.freeze({ package: 'io.cmpose.backendtemplate', appName: 'cmpose-app', projectName: 'Cmpose Backend' }),
32
35
  anatomy: Object.freeze({ localTask: 'runLocal', localPort: 18001, deployDir: 'deploy' })
@@ -14,6 +14,11 @@ function describeBackend() {
14
14
  type: S.options.sharing.type,
15
15
  default: S.options.sharing.default,
16
16
  description: 'Spaces shared between users, with roles (viewer, editor, owner) and invitations. Off: every row belongs to one user.'
17
+ },
18
+ sample: {
19
+ type: S.options.sample.type,
20
+ default: S.options.sample.default,
21
+ description: 'Include the sample items feature (feature/sample) as the shape to copy'
17
22
  }
18
23
  },
19
24
  appNameRules: {
@@ -31,9 +36,9 @@ function describeBackend() {
31
36
  'src/main/kotlin/<package>/config': 'AppConfig: every setting, read from the environment',
32
37
  'src/main/kotlin/<package>/plugins': 'errors, security headers, CORS, body limits, rate limits, request ids, client version',
33
38
  'src/main/kotlin/<package>/auth': 'token verification and requireUser()',
34
- 'src/main/kotlin/<package>/users': 'GET and DELETE /api/v1/me',
39
+ 'src/main/kotlin/<package>/users': 'GET and DELETE /api/v1/me, PUT /api/v1/me/profile',
35
40
  'src/main/kotlin/<package>/files': 'uploads and downloads',
36
- 'src/main/kotlin/<package>/feature/sample': 'the sample feature (/api/v1/items): the shape to copy',
41
+ 'src/main/kotlin/<package>/feature/sample': 'the sample feature (/api/v1/items): the shape to copy (sample only)',
37
42
  'src/main/kotlin/<package>/sharing': 'spaces, roles and invitations (sharing only)',
38
43
  'src/main/kotlin/<package>/openapi': 'the API description, served at /api/v1/openapi.json',
39
44
  'src/main/resources/db/migration': 'Flyway migrations (V<n>__<name>.sql)',
@@ -51,7 +56,8 @@ function describeBackend() {
51
56
  'then uses the returned membership.spaceId.',
52
57
  notFoundRule: 'A row the caller may not see (another user\'s, or with sharing a space where the caller is not a member or has too low a role) ' +
53
58
  'answers 404 NOT_FOUND exactly like a missing row: never 403. Every query filters by the owner (owner_id, or space_id with sharing) in the same query.',
54
- newFeature: 'Put a new feature under src/main/kotlin/<package>/feature/<name>/ copying feature/sample (table, repository, routes, DTOs), ' +
59
+ newFeature: 'Put a new feature under src/main/kotlin/<package>/feature/<name>/ (table, repository, routes, DTOs) ' +
60
+ 'copying feature/sample when present, otherwise the shape of files/; ' +
55
61
  'add a new migration V<n>__<name>.sql (never edit one that has run), its tests (IDOR and validation included) and its routes in openapi/OpenApiSpec.kt.',
56
62
  newTable: 'Every new table points at its owner with REFERENCES users(id) ON DELETE CASCADE (with sharing: spaces(id) ON DELETE CASCADE), ' +
57
63
  'so account deletion removes its rows.',
@@ -3,12 +3,15 @@ const path = require('path');
3
3
  const fs = require('fs-extra');
4
4
  const { log } = require('../log');
5
5
  const { COMMIT_FILE_NAME, REF_FILE_NAME } = require('../templateVersions');
6
- const { applySharingOption } = require('./options');
6
+ const { applySharingOption, applySampleOption, removeResolverTools } = require('./options');
7
7
  const { applyBackendIdentity } = require('./identity');
8
8
  const { writeBackendRecord } = require('./record');
9
9
  const { verifyBackendProject } = require('./verifier');
10
10
 
11
11
  // The template cache's own records and the template's gate and notes never reach the project (tools/deploy-lint.sh stays)
12
+ // Added with the sample option: a template without it is older than this generator (an error, never tolerated)
13
+ const SAMPLE_PATHS_FILE = path.join('tools', 'sample-paths.txt');
14
+
12
15
  const LEFTOVERS = ['.git', COMMIT_FILE_NAME, REF_FILE_NAME, path.join('tools', 'gate.sh'), path.join('tools', 'TEMPLATE.md')];
13
16
 
14
17
  // Turns a fresh backend template copy (projectDir) into the requested project, in place. value: validateBackend().value.
@@ -18,9 +21,19 @@ async function generateBackend(value, { projectDir, templateRef = null, template
18
21
  const ref = templateRef || (await readMarker(REF_FILE_NAME));
19
22
  const commit = templateCommit || (await readMarker(COMMIT_FILE_NAME));
20
23
 
21
- // 1. options, before the package moves (tools/sharing-paths.txt names the template's package dir)
24
+ // 0. a template older than the sample option cannot be generated: say which one, before anything changes
25
+ if (!(await fs.pathExists(path.join(projectDir, SAMPLE_PATHS_FILE)))) {
26
+ throw new Error(`backend template ${ref || '(unknown ref)'} predates the sample option `
27
+ + '(no tools/sample-paths.txt); use a newer backend template tag');
28
+ }
29
+
30
+ // 1. options, sharing then sample (the template gate's order), before the package moves (tools/<option>-paths.txt
31
+ // names the template's package dir); then the resolver's own tools go
22
32
  await applySharingOption(projectDir, value.sharing);
23
33
  log.info(`✅ Sharing ${value.sharing ? 'on' : 'off'}`);
34
+ await applySampleOption(projectDir, value.sample);
35
+ log.info(`✅ Sample ${value.sample ? 'on' : 'off'}`);
36
+ await removeResolverTools(projectDir);
24
37
 
25
38
  // 2. identity
26
39
  await applyBackendIdentity(projectDir, value);
@@ -33,7 +46,7 @@ async function generateBackend(value, { projectDir, templateRef = null, template
33
46
  // 4. a broken project is never returned
34
47
  const problems = await verifyBackendProject(projectDir, value);
35
48
  if (problems.length) throw new Error(`backend verification failed: ${problems.join('; ')}`);
36
- return { sharing: value.sharing, record };
49
+ return { sharing: value.sharing, sample: value.sample, record };
37
50
  }
38
51
 
39
52
  module.exports = { generateBackend };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
- const { validateBackend } = require('./validate');
2
+ const { validateBackend, templateRefProblem } = require('./validate');
3
3
  const { BACKEND_SCHEMA, backendContractVersion } = require('./contract');
4
- const { applySharingOption, resolveContent } = require('./options');
4
+ const { applyMarkerOption, applySharingOption, applySampleOption, removeResolverTools, resolveContent } = require('./options');
5
5
  const { applyBackendIdentity } = require('./identity');
6
6
  const { verifyBackendProject } = require('./verifier');
7
7
  const { RECORD_PATH, buildBackendRecord, writeBackendRecord } = require('./record');
@@ -10,9 +10,13 @@ const { describeBackend } = require('./describe');
10
10
 
11
11
  module.exports = {
12
12
  validateBackend,
13
+ templateRefProblem,
13
14
  BACKEND_SCHEMA,
14
15
  backendContractVersion,
16
+ applyMarkerOption,
15
17
  applySharingOption,
18
+ applySampleOption,
19
+ removeResolverTools,
16
20
  resolveContent,
17
21
  applyBackendIdentity,
18
22
  verifyBackendProject,
@@ -3,38 +3,51 @@ const path = require('path');
3
3
  const fs = require('fs-extra');
4
4
  const { kindOf } = require('../markerBlocks');
5
5
 
6
- // The backend template's sharing part, resolved exactly as the template's tools/resolve-markers.sh does (the gate's
7
- // reference), so a generated project is byte for byte what the template's gate built and tested:
6
+ // The backend template's optional parts (cmpose:<option>: sharing, sample), resolved exactly as the template's
7
+ // tools/resolve-markers.sh <dir> <option> on|off does (the gate's reference), so a generated project is byte for byte
8
+ // what the template's gate built and tested:
8
9
  // on: only the marker lines go; a swap marker goes and the line after it stays.
9
10
  // off: each marked block goes with its markers, a swap marker's text replaces the line after it (at that line's
10
- // indentation), the paths of tools/sharing-paths.txt go, and so do the Kotlin imports the rest no longer names.
11
+ // indentation), the paths of tools/<option>-paths.txt go, and so do the Kotlin imports the rest no longer names.
11
12
  // Either way a blank line on both sides of a dropped run becomes one. Files are read as latin1, so bytes round-trip.
13
+ // A run sees only its own option's markers (another option's marker lines are plain text, so blocks of different
14
+ // options may nest), except that the line after a swap may not be a marker of any option. The generator runs
15
+ // sharing first, then sample.
12
16
 
13
- const TAG = 'sharing';
14
- const PATHS_FILE = path.join('tools', 'sharing-paths.txt');
15
- // The resolver's own tools, which a generated project has no use for
16
- const RESOLVER_TOOLS = ['sharing-paths.txt', 'resolve-markers.sh', 'resolve-markers.test.sh'];
17
+ const OPTION_RE = /^[a-z]+$/;
18
+ const pathsFileOf = (tag) => path.join('tools', `${tag}-paths.txt`);
19
+ // The resolver's own tools (with every tools/*-paths.txt), which a generated project has no use for
20
+ const RESOLVER_TOOLS = ['resolve-markers.sh', 'resolve-markers.test.sh'];
21
+ const PATHS_FILE_RE = /-paths\.txt$/;
17
22
  // The directories the resolver never enters, at any depth
18
23
  const PRUNED = new Set(['.git', 'build', '.gradle', '.kotlin', '.local-server', 'node_modules']);
19
24
  const COMMENTS = { slash: '//', dash: '--', hash: '#' };
25
+ // Every marker comment style, for what a swap must not stand before
26
+ const ANY_STYLE = ['//', '--', '#', '<!--'];
20
27
  const IMPORT_RE = /^import[ \t]+[A-Za-z0-9_.`]+([ \t]+as[ \t]+[A-Za-z0-9_`]+)?[ \t]*$/;
21
28
  const WORD = /[A-Za-z0-9_]/;
22
29
 
23
30
  // awk's trim: spaces and tabs at the start; spaces, tabs and \r at the end
24
31
  const trim = (s) => s.replace(/^[ \t]+/, '').replace(/[ \t\r]+$/, '');
25
32
 
26
- function formsOf(kind) {
27
- if (kind === 'xml') return { open: `<!-- cmpose:${TAG} -->`, close: `<!-- /cmpose:${TAG} -->`, swap: '', prefix: '<!--' };
33
+ function formsOf(kind, tag) {
34
+ if (kind === 'xml') return { open: `<!-- cmpose:${tag} -->`, close: `<!-- /cmpose:${tag} -->`, swap: '', prefix: '<!--' };
28
35
  const c = COMMENTS[kind];
29
- return { open: `${c} cmpose:${TAG}`, close: `${c} /cmpose:${TAG}`, swap: `${c} cmpose:${TAG}-off: `, prefix: c };
36
+ return { open: `${c} cmpose:${tag}`, close: `${c} /cmpose:${tag}`, swap: `${c} cmpose:${tag}-off: `, prefix: c };
30
37
  }
31
38
 
32
- // A line that looks like a marker but is none of the known forms
33
- function malformed(t, prefix) {
39
+ // A line that looks like a marker of this option (cmpose:<tag> not followed by a letter) but is none of the known forms
40
+ function malformed(t, prefix, tag) {
34
41
  if (!t.startsWith(prefix)) return false;
35
- return t.slice(prefix.length).replace(/^[ \t]*\/?/, '').startsWith('cmpose:');
42
+ const rest = t.slice(prefix.length).replace(/^[ \t]*\/?/, '');
43
+ const marker = `cmpose:${tag}`;
44
+ return rest.startsWith(marker) && !/[a-z]/.test(rest.charAt(marker.length));
36
45
  }
37
46
 
47
+ // A line that looks like a marker of any option, in any comment style
48
+ const anyMarker = (t) => ANY_STYLE.some((style) => t.startsWith(style)
49
+ && t.slice(style.length).replace(/^[ \t]*\/?/, '').startsWith('cmpose:'));
50
+
38
51
  // Whether the code names <name> as a whole word
39
52
  function names(code, name) {
40
53
  for (let at = code.indexOf(name); at >= 0; at = code.indexOf(name, at + 1)) {
@@ -56,9 +69,10 @@ function dropUnusedImports(lines) {
56
69
  });
57
70
  }
58
71
 
59
- /** One file's content resolved (enabled = on); throws `<file>:<line>: <problem>` on a marker error */
60
- function resolveContent(content, kind, enabled, { file = 'file', kotlin = false } = {}) {
61
- const { open, close, swap, prefix } = formsOf(kind);
72
+ /** One file's content with the markers of one option (tag) resolved (enabled = on); throws `<file>:<line>: <problem>`
73
+ * on a marker error */
74
+ function resolveContent(content, kind, enabled, { file = 'file', kotlin = false, tag = 'sharing' } = {}) {
75
+ const { open, close, swap, prefix } = formsOf(kind, tag);
62
76
  const lines = content.split('\n');
63
77
  if (content.endsWith('\n')) lines.pop();
64
78
  const die = (message, at) => { throw new Error(`${file}:${at}: ${message}`); };
@@ -79,7 +93,7 @@ function resolveContent(content, kind, enabled, { file = 'file', kotlin = false
79
93
  const line = lines[i];
80
94
  const t = trim(line);
81
95
  if (t === open) {
82
- if (inside) die(`nested cmpose:${TAG} block (open since line ${opened})`, i + 1);
96
+ if (inside) die(`nested cmpose:${tag} block (open since line ${opened})`, i + 1);
83
97
  inside = true; opened = i + 1; dropped = true;
84
98
  continue;
85
99
  }
@@ -90,19 +104,21 @@ function resolveContent(content, kind, enabled, { file = 'file', kotlin = false
90
104
  }
91
105
  if (inside && !enabled) continue;
92
106
  if (swap && t.startsWith(swap)) {
93
- if (i === lines.length - 1) die(`cmpose:${TAG}-off marker on the last line`, i + 1);
107
+ if (i === lines.length - 1) die(`cmpose:${tag}-off marker on the last line`, i + 1);
94
108
  let next = lines[i + 1];
95
109
  const nt = trim(next);
96
- if (nt === open || nt === close || malformed(nt, prefix)) die(`cmpose:${TAG}-off marker before a marker`, i + 1);
110
+ if (nt === open || nt === close || malformed(nt, prefix, tag) || anyMarker(nt)) {
111
+ die(`cmpose:${tag}-off marker before a marker`, i + 1);
112
+ }
97
113
  if (!enabled) next = next.match(/^[ \t]*/)[0] + t.slice(swap.length);
98
114
  i++;
99
115
  emit(next);
100
116
  continue;
101
117
  }
102
- if (malformed(t, prefix)) die(`malformed cmpose marker: ${t}`, i + 1);
118
+ if (malformed(t, prefix, tag)) die(`malformed cmpose marker: ${t}`, i + 1);
103
119
  emit(line);
104
120
  }
105
- if (inside) die(`unclosed cmpose:${TAG} block`, opened);
121
+ if (inside) die(`unclosed cmpose:${tag} block`, opened);
106
122
  const kept = kotlin && !enabled ? dropUnusedImports(out) : out;
107
123
  return kept.map((l) => `${l}\n`).join('');
108
124
  }
@@ -118,42 +134,55 @@ async function listFiles(dir, rel = '', out = []) {
118
134
  return out;
119
135
  }
120
136
 
121
- // The sharing-only paths: relative, inside the project
122
- async function readSharingPaths(projectDir) {
123
- const file = path.join(projectDir, PATHS_FILE);
124
- if (!(await fs.pathExists(file))) throw new Error(`${PATHS_FILE} is missing`);
137
+ // The option-only paths: relative, inside the project
138
+ async function readOptionPaths(projectDir, pathsFile) {
125
139
  const paths = [];
126
- for (const raw of (await fs.readFile(file, 'utf8')).split('\n')) {
140
+ for (const raw of (await fs.readFile(path.join(projectDir, pathsFile), 'utf8')).split('\n')) {
127
141
  const p = raw.replace(/\r$/, '');
128
142
  if (p === '' || p.startsWith('#')) continue;
129
143
  const slashed = `/${p}/`;
130
144
  if (slashed.startsWith('//') || slashed.includes('/../') || slashed.includes('/./')) {
131
- throw new Error(`${PATHS_FILE}: '${p}' must be a relative path inside the project`);
145
+ throw new Error(`${pathsFile}: '${p}' must be a relative path inside the project`);
132
146
  }
133
147
  paths.push(p);
134
148
  }
135
149
  return paths;
136
150
  }
137
151
 
138
- /** Resolves the sharing markers of a fresh template copy in place, then drops the resolver's own tools. Every file is
139
- * resolved before any is written, so a marker error leaves the directory as it was. */
140
- async function applySharingOption(projectDir, enabled) {
152
+ /** Resolves the cmpose:<tag> markers of a template copy in place and, when off, deletes the paths of
153
+ * tools/<tag>-paths.txt. Only the files holding a marker of this option are resolved. The paths file must exist in
154
+ * either mode, and every file is resolved before any is written, so an error leaves the directory as it was. */
155
+ async function applyMarkerOption(projectDir, tag, enabled) {
156
+ if (typeof tag !== 'string' || !OPTION_RE.test(tag)) throw new Error(`option must be lowercase letters: ${JSON.stringify(tag)}`);
157
+ const pathsFile = pathsFileOf(tag);
158
+ if (!(await fs.pathExists(path.join(projectDir, pathsFile)))) throw new Error(`${pathsFile.split(path.sep).join('/')} is missing`);
159
+ // the resolver's file pick: cmpose:<tag> not followed by a letter
160
+ const own = new RegExp(`cmpose:${tag}([^a-z]|$)`, 'm');
141
161
  const results = [];
142
162
  for (const rel of await listFiles(projectDir)) {
143
163
  const kind = kindOf(rel);
144
164
  if (!kind) continue;
145
165
  const file = path.join(projectDir, rel);
146
166
  const content = await fs.readFile(file, 'latin1');
147
- if (!content.includes('cmpose:')) continue;
167
+ if (!own.test(content)) continue;
148
168
  const kotlin = rel.endsWith('.kt') || rel.endsWith('.kts');
149
- results.push({ file, content: resolveContent(content, kind, enabled, { file: rel, kotlin }) });
169
+ results.push({ file, content: resolveContent(content, kind, enabled, { file: rel, kotlin, tag }) });
150
170
  }
151
- const sharingPaths = enabled ? [] : await readSharingPaths(projectDir);
171
+ const optionPaths = enabled ? [] : await readOptionPaths(projectDir, pathsFile);
152
172
  // writeFile keeps an existing file's mode
153
173
  for (const { file, content } of results) await fs.writeFile(file, content, 'latin1');
154
- for (const p of sharingPaths) await fs.remove(path.join(projectDir, p));
155
- for (const tool of RESOLVER_TOOLS) await fs.remove(path.join(projectDir, 'tools', tool));
156
- return { resolvedFiles: results.length, removedPaths: sharingPaths };
174
+ for (const p of optionPaths) await fs.remove(path.join(projectDir, p));
175
+ return { resolvedFiles: results.length, removedPaths: optionPaths };
176
+ }
177
+
178
+ const applySharingOption = (projectDir, enabled) => applyMarkerOption(projectDir, 'sharing', enabled);
179
+ const applySampleOption = (projectDir, enabled) => applyMarkerOption(projectDir, 'sample', enabled);
180
+
181
+ /** Deletes the resolver's own tools: tools/resolve-markers.sh, tools/resolve-markers.test.sh and every tools/*-paths.txt */
182
+ async function removeResolverTools(projectDir) {
183
+ const tools = path.join(projectDir, 'tools');
184
+ const names = (await fs.pathExists(tools)) ? await fs.readdir(tools) : [];
185
+ for (const name of [...RESOLVER_TOOLS, ...names.filter((n) => PATHS_FILE_RE.test(n))]) await fs.remove(path.join(tools, name));
157
186
  }
158
187
 
159
- module.exports = { applySharingOption, resolveContent, PRUNED };
188
+ module.exports = { applyMarkerOption, applySharingOption, applySampleOption, removeResolverTools, resolveContent, PRUNED };
@@ -16,6 +16,7 @@ function buildBackendRecord(value, { templateRef = null, templateCommit = null,
16
16
  kind: 'backend',
17
17
  contractVersion: backendContractVersion(),
18
18
  sharing: value.sharing === true,
19
+ sample: value.sample !== false,
19
20
  template: { repo: pins.backend.repo, ref: templateRef || null, commit: templateCommit || null },
20
21
  generatedAt: now.toISOString()
21
22
  };
@@ -22,24 +22,35 @@ function databaseNameProblem(appName) {
22
22
  return null;
23
23
  }
24
24
 
25
- // Every problem as { field, message }; the value holds only the known fields (sharing defaults to false)
25
+ // Why appName cannot name a backend (null = it can): the pattern, the route prefixes, then the database name
26
+ function appNameProblem(appName) {
27
+ if (typeof appName !== 'string' || !APP_NAME_RE.test(appName)) {
28
+ return 'appName must be 2-31 lowercase letters, digits or -, starting with a letter';
29
+ }
30
+ if (S.appName.reserved.includes(appName)) return `appName "${appName}" is reserved (${S.appName.reserved.join(', ')})`;
31
+ return databaseNameProblem(appName);
32
+ }
33
+
34
+ // Why ref cannot name a backend template tag (null = it can); field names the input in the message
35
+ function templateRefProblem(ref, field = 'templateRef') {
36
+ if (typeof ref !== 'string' || !REF_RE.test(ref) || ref.startsWith('.')) {
37
+ return `${field} must be a tag or branch name (letters, digits, ., _ or -, not starting with .)`;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // Every problem as { field, message }; the value holds only the known fields (sharing defaults to false, sample to true)
26
43
  function validateBackend(input) {
27
44
  if (!isObject(input)) return { ok: false, errors: [{ field: 'input', message: 'input must be an object' }] };
28
- const { projectName, appName, packageName, outputDir, sharing, templateRef } = input;
45
+ const { projectName, appName, packageName, outputDir, sharing, sample, templateRef } = input;
29
46
  const errors = [];
30
47
  const fail = (field, message) => errors.push({ field, message });
31
48
 
32
49
  if (typeof projectName !== 'string' || !PV.validateProjectName(projectName)) fail('projectName', PV.messages.projectNameInvalid);
33
50
  else if (projectName.length > PV.MAX_LENGTH_PROJECT_NAME) fail('projectName', PV.messages.projectNameTooLong);
34
51
 
35
- if (typeof appName !== 'string' || !APP_NAME_RE.test(appName)) {
36
- fail('appName', 'appName must be 2-31 lowercase letters, digits or -, starting with a letter');
37
- } else if (S.appName.reserved.includes(appName)) {
38
- fail('appName', `appName "${appName}" is reserved (${S.appName.reserved.join(', ')})`);
39
- } else {
40
- const problem = databaseNameProblem(appName);
41
- if (problem) fail('appName', problem);
42
- }
52
+ const nameProblem = appNameProblem(appName);
53
+ if (nameProblem) fail('appName', nameProblem);
43
54
 
44
55
  if (typeof packageName !== 'string' || !PV.validatePackageName(packageName) || isTemplatePackage(packageName)) {
45
56
  fail('packageName', PV.messages.packageNameInvalid);
@@ -50,15 +61,21 @@ function validateBackend(input) {
50
61
  if (typeof outputDir !== 'string' || !path.isAbsolute(outputDir)) fail('outputDir', 'outputDir must be an absolute path');
51
62
 
52
63
  if (sharing !== undefined && typeof sharing !== 'boolean') fail('sharing', 'sharing must be true or false');
64
+ if (sample !== undefined && typeof sample !== 'boolean') fail('sample', 'sample must be true or false');
53
65
 
54
- if (templateRef !== undefined && (typeof templateRef !== 'string' || !REF_RE.test(templateRef) || templateRef.startsWith('.'))) {
55
- fail('templateRef', 'templateRef must be a tag or branch name (letters, digits, ., _ or -, not starting with .)');
66
+ if (templateRef !== undefined) {
67
+ const problem = templateRefProblem(templateRef);
68
+ if (problem) fail('templateRef', problem);
56
69
  }
57
70
 
58
71
  if (errors.length) return { ok: false, errors };
59
- const value = { projectName, appName, packageName, outputDir, sharing: sharing === undefined ? S.options.sharing.default : sharing };
72
+ const value = {
73
+ projectName, appName, packageName, outputDir,
74
+ sharing: sharing === undefined ? S.options.sharing.default : sharing,
75
+ sample: sample === undefined ? S.options.sample.default : sample
76
+ };
60
77
  if (templateRef !== undefined) value.templateRef = templateRef;
61
78
  return { ok: true, value };
62
79
  }
63
80
 
64
- module.exports = { validateBackend };
81
+ module.exports = { validateBackend, templateRefProblem, appNameProblem };
@@ -8,6 +8,9 @@ const { identityPairs } = require('./identity');
8
8
  const MARKER_RE = /^[ \t]*(?:\/\/|--|#|<!--)[ \t]*\/?cmpose:/m;
9
9
  // The template gate's sharing words, in the code, docs and deploy parts
10
10
  const SHARING_WORDS = /space|invite|member/i;
11
+ // The template gate's sample words (no_sample_words), in the same parts
12
+ const SAMPLE_WORDS = /feature[./]sample|\/items|Item(Repository|Routes|Table|Dto|OpenApi)|V3__sample/;
13
+ const SAMPLE_MIGRATION = 'V3__sample.sql';
11
14
  const SHARING_TARGETS = ['src', 'SECURITY-CHECKLIST.md', 'deploy', 'README.md'];
12
15
  const SECRET_PATTERNS = [
13
16
  { name: 'private key', re: /-----BEGIN .*PRIVATE KEY-----/ },
@@ -35,7 +38,7 @@ async function listFiles(dir, rel = '', out = []) {
35
38
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
39
 
37
40
  /** Problems of a generated backend project (empty = clean), sorted */
38
- async function verifyBackendProject(projectDir, { sharing, packageName, appName, projectName = '' }) {
41
+ async function verifyBackendProject(projectDir, { sharing, sample, packageName, appName, projectName = '' }) {
39
42
  const problems = [];
40
43
  const pairs = identityPairs({ packageName, appName, projectName });
41
44
  // A template token the project's own names spell (a package io.cmpose.backendtemplatex) is not residue
@@ -51,15 +54,19 @@ async function verifyBackendProject(projectDir, { sharing, packageName, appName,
51
54
  }
52
55
  if (rel.split('/')[0] !== MARKER_SKIPPED && MARKER_RE.test(text)) problems.push(`marker left in ${rel}`);
53
56
  if (rel.startsWith('tools/') && !KEPT_TOOLS.includes(rel)) problems.push(`template tool left in ${rel}`);
54
- if (!sharing && SHARING_TARGETS.some((t) => rel === t || rel.startsWith(`${t}/`))) {
57
+ if (SHARING_TARGETS.some((t) => rel === t || rel.startsWith(`${t}/`))) {
55
58
  const own = ownNamesRe ? text.replace(ownNamesRe, '') : text;
56
- if (SHARING_WORDS.test(own)) problems.push(`sharing word in ${rel} (sharing is off)`);
59
+ if (!sharing && SHARING_WORDS.test(own)) problems.push(`sharing word in ${rel} (sharing is off)`);
60
+ if (sample === false && SAMPLE_WORDS.test(own)) problems.push(`sample word in ${rel} (sample is off)`);
57
61
  }
62
+ if (sample === false && path.posix.basename(rel) === SAMPLE_MIGRATION) problems.push(`sample migration ${rel} left (sample is off)`);
58
63
  for (const { name, re } of SECRET_PATTERNS) if (re.test(text)) problems.push(`${name} in ${rel}`);
59
64
  }
60
65
 
61
66
  const packageDir = path.posix.join('src', 'main', 'kotlin', ...packageName.split('.'));
62
67
  if (!(await fs.pathExists(path.join(projectDir, packageDir)))) problems.push(`missing package dir ${packageDir}`);
68
+ const sampleDir = path.posix.join(packageDir, 'feature', 'sample');
69
+ if (sample === false && (await fs.pathExists(path.join(projectDir, sampleDir)))) problems.push(`sample dir ${sampleDir} left (sample is off)`);
63
70
  for (const rel of [`deploy/docker-compose.${appName}.yml`, `deploy/nginx/${appName}.conf`]) {
64
71
  if (!(await fs.pathExists(path.join(projectDir, rel)))) problems.push(`missing ${rel}`);
65
72
  }
@@ -10,7 +10,7 @@ const { removeMarkedBlocks, applySwapMarkers } = require('./markerBlocks');
10
10
 
11
11
  const KIND_BY_EXTENSION = {
12
12
  '.kt': 'slash', '.kts': 'slash', '.swift': 'slash', '.xcconfig': 'slash',
13
- '.toml': 'hash', '.gitignore': 'hash', '.properties': 'hash',
13
+ '.toml': 'hash', '.gitignore': 'hash', '.properties': 'hash', '.yml': 'hash', '.yaml': 'hash',
14
14
  '.md': 'html', '.xml': 'html', '.plist': 'html', '.xcprivacy': 'html'
15
15
  };
16
16
  const SKIP_DIRS = new Set(['.git', '.gradle', 'build', 'node_modules']);
@@ -18,6 +18,7 @@ const SKIP_DIRS = new Set(['.git', '.gradle', 'build', 'node_modules']);
18
18
  // Template-relative paths the backend adds outside the files it marks; deleted when backend is off
19
19
  const TEMPLATE_PACKAGE_PATH = 'com/kturker/multiplatform';
20
20
  const BACKEND_PATHS = [
21
+ // The template has no server/ of its own (it comes from the backend template); kept so a stale fetched copy goes too
21
22
  'server',
22
23
  'core/api',
23
24
  'core/auth',
package/src/contract.js CHANGED
@@ -31,7 +31,7 @@ const SCHEMA = {
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
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' },
34
+ 'core/api': { requires: ['backend'], note: "the app's client models and routes for the backend API, checked by ApiContractTest" },
35
35
  'core/auth': { requires: ['backend'], note: 'Firebase sign-in (email, Google, Apple); the server client\'s token' }
36
36
  },
37
37
  // featuresConfig flag -> feature modules kept in a blank project (same requires/requiresAny rules).
@@ -34,6 +34,10 @@ const BLANK_ONLY_FEATURES = ['notes'];
34
34
  const NOTES_INCLUDE_REGEX = /^\s*include\("?:?feature:notes(?::(?:contract|presentation))?"\)\s*$/;
35
35
  // Cloned modules carry no test sources (Compass counts the iOS tests of a project as the agent's)
36
36
  const TEST_SOURCE_SET_REGEX = /(^|\/)src\/[^/]*Test\//;
37
+ // The template test source sets a blank project keeps: Android-host checks only, never an iOS test. shared's Koin
38
+ // graph check, and core/api's contract test (ApiContractTest: the app's models and routes against server/; core/api
39
+ // is there only with the backend)
40
+ const BLANK_KEPT_TEST_DIRS = ['shared/src/androidHostTest/', 'core/api/src/androidHostTest/'];
37
41
 
38
42
  function capitalize(name) {
39
43
  return name.charAt(0).toUpperCase() + name.slice(1);
@@ -159,8 +163,8 @@ const MONETIZATION_CAPABILITIES = [
159
163
  { dir: `${BLANK_OVERLAY_DIR}/${LAYERS_DIR}/${MONETIZATION_LAYER.name}`, needed: MONETIZATION_LAYER.applies }
160
164
  ];
161
165
  // 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']
166
+ // before the backend release still serves every other option (server/ comes from the backend template)
167
+ const BACKEND_CAPABILITIES = ['core/api', 'core/auth', 'feature/account', 'feature/sharing']
164
168
  .map((dir) => ({ dir, needed: (cfg) => cfg.backend === true }));
165
169
 
166
170
  class TemplateCapabilityError extends Error {
@@ -242,6 +246,6 @@ async function cloneFeatureModules(projectPath, features) {
242
246
  }
243
247
 
244
248
  module.exports = {
245
- applyBlankShell, cloneFeatureModules, wrapLongSingleParamSignatures, capitalize, TEST_SOURCE_SET_REGEX,
249
+ applyBlankShell, cloneFeatureModules, wrapLongSingleParamSignatures, capitalize, TEST_SOURCE_SET_REGEX, BLANK_KEPT_TEST_DIRS,
246
250
  assertTemplateCapabilities, missingTemplateCapabilities, templateLacksMessage, TemplateCapabilityError
247
251
  };
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const { listTextFiles } = require('./projectFiles');
4
4
  const { removeSwiftPackage } = require('./xcodePackages');
5
5
  const { stripPins } = require('./backendOption');
6
- const { capitalize, TEST_SOURCE_SET_REGEX } = require('./featureModules');
6
+ const { capitalize, TEST_SOURCE_SET_REGEX, BLANK_KEPT_TEST_DIRS } = require('./featureModules');
7
7
  const { removeMarkedBlock } = require('./readmeService');
8
8
  const { log } = require('./log');
9
9
 
@@ -84,12 +84,13 @@ const TEMPLATE_FEATURE_MODULES = {
84
84
  sharing: (cfg) => cfg.backend === true
85
85
  };
86
86
 
87
- // Deletes every test source set (src/*Test) of a Gradle module; a missing src/ is fine
88
- async function removeTestSourceSets(moduleDir) {
87
+ // Deletes every test source set (src/*Test) of a Gradle module but those in BLANK_KEPT_TEST_DIRS; a missing src/ is fine
88
+ async function removeTestSourceSets(projectPath, moduleDir) {
89
89
  const srcDir = path.join(moduleDir, 'src');
90
90
  if (!(await fs.pathExists(srcDir))) return;
91
91
  for (const sourceSet of await fs.readdir(srcDir)) {
92
- if (TEST_SOURCE_SET_REGEX.test(`src/${sourceSet}/`)) await fs.remove(path.join(srcDir, sourceSet));
92
+ const rel = `${path.relative(projectPath, srcDir).split(path.sep).join('/')}/${sourceSet}/`;
93
+ if (TEST_SOURCE_SET_REGEX.test(rel) && !BLANK_KEPT_TEST_DIRS.includes(rel)) await fs.remove(path.join(srcDir, sourceSet));
93
94
  }
94
95
  }
95
96
 
@@ -926,8 +927,9 @@ async function processBlankApp(projectPath, packageName, features, featuresConfi
926
927
 
927
928
  // The template's own core tests (ThemeModeTest, ColorSchemeForTest, ...) stay in the showcase.
928
929
  // Compass counts every iOS test of a project as the agent's, so a blank app starts with none
929
- // (clones carry none either, see featureModules.js); shared's Android-host Koin graph check stays.
930
- // The same goes for the layers of the template's own feature modules (monetization, notes).
930
+ // (clones carry none either, see featureModules.js). The Android-host checks of BLANK_KEPT_TEST_DIRS stay:
931
+ // shared's Koin graph check and, with the backend, core/api's contract test against server/.
932
+ // The same goes for the layers of the template's own feature modules (monetization, notes, account, sharing).
931
933
  const testParents = [
932
934
  path.join(projectPath, 'core'),
933
935
  ...Object.keys(TEMPLATE_FEATURE_MODULES).map((module) => path.join(projectPath, 'feature', module))
@@ -935,7 +937,7 @@ async function processBlankApp(projectPath, packageName, features, featuresConfi
935
937
  for (const parentDir of testParents) {
936
938
  if (!(await fs.pathExists(parentDir))) continue;
937
939
  for (const moduleName of await fs.readdir(parentDir)) {
938
- await removeTestSourceSets(path.join(parentDir, moduleName));
940
+ await removeTestSourceSets(projectPath, path.join(parentDir, moduleName));
939
941
  }
940
942
  }
941
943
 
@@ -1706,6 +1708,8 @@ async function processCoreFeatures(projectPath, config, packageName) {
1706
1708
  'ktor-client-core',
1707
1709
  'ktor-client-darwin',
1708
1710
  'ktor-client-logging',
1711
+ // core/network's test client (MockEngine)
1712
+ 'ktor-client-mock',
1709
1713
  'ktor-serialization-kotlinx-json',
1710
1714
  // Coil's network fetcher lives in core:network and goes with it
1711
1715
  'coil-network-ktor3'
@@ -2188,19 +2192,10 @@ async function processCoreFeatures(projectPath, config, packageName) {
2188
2192
  }
2189
2193
  }
2190
2194
 
2191
- // 6. Modules outside the convention plugins that apply it by id (core/api and the server: the
2192
- // backend's JVM-capable modules)
2195
+ // 6. Modules outside the convention plugins that apply it by id (core/api, the backend's JVM-capable module;
2196
+ // server/ is the backend template's own build with its own detekt, placed after this step)
2193
2197
  const detektPluginLine = `id("${packageName}.detekt")`;
2194
- for (const moduleDir of [path.join(projectPath, 'core', 'api'), path.join(projectPath, 'server')]) {
2195
- await dropLines(path.join(moduleDir, 'build.gradle.kts'), (line) => line.trim() === detektPluginLine);
2196
- }
2197
- // The server also points detekt at its local runner's sources
2198
- const serverGradle = path.join(projectPath, 'server', 'build.gradle.kts');
2199
- if (await fs.pathExists(serverGradle)) {
2200
- const content = await fs.readFile(serverGradle, 'utf8');
2201
- const cleaned = content.replace(/(?:^|\n)(?:\/\/[^\n]*\n)?detekt \{ source\.from\("src\/local\/kotlin"\) \}\n(?:[ \t]*\n)?/, '\n');
2202
- if (cleaned !== content) await fs.writeFile(serverGradle, cleaned);
2203
- }
2198
+ await dropLines(path.join(projectPath, 'core', 'api', 'build.gradle.kts'), (line) => line.trim() === detektPluginLine);
2204
2199
 
2205
2200
  // The CI workflow's detekt step would fail without the plugin
2206
2201
  const ciPath = path.join(projectPath, '.github', 'workflows', 'ci.yml');
package/src/generate.js CHANGED
@@ -16,12 +16,24 @@ const { fillReadme } = require('./readmeService');
16
16
  const { buildGenerationRecord, writeGenerationRecord } = require('./generationRecord');
17
17
  const { verifyGeneratedProject } = require('./generationVerifier');
18
18
  const { generationWarnings } = require('./warnings');
19
+ const { placeServer, SERVER_DIR } = require('./serverFromBackend');
19
20
 
20
21
  // Turns a fresh template copy (projectDir) into the requested project, in place.
21
22
  // value: validate().value. Throws when the template lacks an option or the result fails verification.
22
- async function generateCmpProject(value, { projectDir, generatorVersion = null }) {
23
+ // backendTemplate ({ dir, repo, ref, commit }): a fresh backend template copy, required for blank with the backend; it
24
+ // is moved into the project as server/. serverImageCheck: the verifier's server image check (opt-in: a check, true for
25
+ // Docker, false for none; unset, CMPOSE_SERVER_IMAGE_CHECK=1 asks for Docker).
26
+ async function generateCmpProject(value, { projectDir, generatorVersion = null, backendTemplate = null, serverImageCheck } = {}) {
23
27
  const { projectName, appName, packageName, templateType, features, featuresConfig, ci, minSdk, iosVersion } = value;
24
28
  const blankConfig = templateType === 'blank' ? featuresConfig : null;
29
+ const backendOn = templateType === 'blank' && featuresConfig.backend === true;
30
+ // Before anything is touched: server/ comes from the backend template only
31
+ if (backendOn && !(backendTemplate && backendTemplate.dir)) {
32
+ throw new Error('backend: the backend template is required (backendTemplate)');
33
+ }
34
+ if (backendOn && (await fs.pathExists(path.join(projectDir, SERVER_DIR)))) {
35
+ throw new Error(`backend: the CMP template already has ${SERVER_DIR}/, which comes from the backend template (use a newer templateRef)`);
36
+ }
25
37
 
26
38
  // An older template without the modules an on flag needs stops here, naming what is missing
27
39
  await assertTemplateCapabilities(projectDir, templateType, featuresConfig);
@@ -54,7 +66,7 @@ async function generateCmpProject(value, { projectDir, generatorVersion = null }
54
66
  // template's own package before the rename below
55
67
  await applyBlankShell(projectDir, templateType, featuresConfig);
56
68
  // 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);
69
+ await applyBackendOption(projectDir, backendOn);
58
70
  await cloneFeatureModules(projectDir, sanitizedFeatures);
59
71
  log.info(`✅ Template skeleton ready (${sanitizedFeatures.length} feature modules)`);
60
72
 
@@ -82,21 +94,29 @@ async function generateCmpProject(value, { projectDir, generatorVersion = null }
82
94
  // Store-ready: the privacy manifest declares the data the on flags collect (showcase: ads and
83
95
  // purchases on, never backend)
84
96
  await writePrivacyManifest(projectDir, templateType === 'blank'
85
- ? { ads: featuresConfig.ads === true, purchases: featuresConfig.purchases === true, backend: featuresConfig.backend === true }
97
+ ? { ads: featuresConfig.ads === true, purchases: featuresConfig.purchases === true, backend: backendOn }
86
98
  : { ads: true, purchases: true, backend: false });
87
99
  log.info('✅ iOS configuration updated');
88
100
 
89
- await fillReadme(projectDir, { versions, minSdk, iosVersion });
90
-
91
101
  // Package rename and module cloning moved imports out of ktlint order: re-sort them last
92
102
  await sortKotlinImports(projectDir);
93
103
 
104
+ // server/ comes from the backend template, after the app's rename and import sort (which never touch it)
105
+ const server = backendOn
106
+ ? await placeServer(projectDir, { packageName, projectName, backendTemplate, generatorVersion })
107
+ : null;
108
+ const serverTemplate = server ? server.template : null;
109
+
110
+ // After server/ is in place: the module list names it
111
+ await fillReadme(projectDir, { versions, minSdk, iosVersion });
112
+
94
113
  // The project's trace: which template, which generator, which options; no personal data
95
114
  await writeGenerationRecord(projectDir, buildGenerationRecord({
96
115
  templateRef, templateCommit, backendRevision: generatorVersion,
97
116
  templateType, ci, minSdk, iosVersion,
98
117
  features: sanitizedFeatures,
99
- featuresConfig: blankConfig
118
+ featuresConfig: blankConfig,
119
+ serverTemplate
100
120
  }));
101
121
 
102
122
  // Last check: a broken project is never returned
@@ -104,12 +124,13 @@ async function generateCmpProject(value, { projectDir, generatorVersion = null }
104
124
  projectName, appName, packageName, templateType,
105
125
  features: sanitizedFeatures,
106
126
  featuresConfig: blankConfig
107
- });
127
+ }, { serverImageCheck });
108
128
 
109
129
  return {
110
130
  projectDir, templateRef, templateCommit, templateType,
111
131
  features: sanitizedFeatures,
112
132
  versions,
133
+ serverTemplate,
113
134
  warnings: generationWarnings({ templateType, featuresConfig })
114
135
  };
115
136
  }
@@ -32,13 +32,20 @@ function sanitizeFeaturesConfig(featuresConfig) {
32
32
  return { ...rest, customDetektYaml: typeof detektYamlContent === 'string' && detektYamlContent.trim().length > 0 };
33
33
  }
34
34
 
35
- /** The .cmpose/generation.json content written into the generated project (no personal data) */
36
- function buildGenerationRecord({ templateRef, templateCommit, backendRevision, templateType, ci, minSdk, iosVersion, features, featuresConfig, now = new Date() }) {
35
+ /** The .cmpose/generation.json content written into the generated project (no personal data). backendRevision names
36
+ * the generator (backend.revision); serverTemplate is the backend template server/ came from, null without one. */
37
+ function buildGenerationRecord({
38
+ templateRef, templateCommit, backendRevision, templateType, ci, minSdk, iosVersion, features, featuresConfig,
39
+ serverTemplate = null, now = new Date()
40
+ }) {
37
41
  return {
38
42
  generator: 'cmpose',
39
43
  generatedAt: now.toISOString(),
40
44
  template: { ref: templateRef || null, commit: templateCommit || null },
41
45
  backend: { revision: backendRevision || null },
46
+ serverTemplate: serverTemplate
47
+ ? { repo: serverTemplate.repo || null, ref: serverTemplate.ref || null, commit: serverTemplate.commit || null }
48
+ : null,
42
49
  options: {
43
50
  templateType,
44
51
  ci: ci === true,
@@ -1,9 +1,12 @@
1
1
  const fs = require('fs-extra');
2
2
  const path = require('path');
3
+ const { execFile } = require('child_process');
4
+ const { log } = require('./log');
3
5
  const { listTextFiles } = require('./projectFiles');
4
6
  const { escapeRegExp, findResidue, BACKEND_RESIDUE_TOKENS } = require('./fileService');
5
- const { TEST_SOURCE_SET_REGEX } = require('./featureModules');
7
+ const { TEST_SOURCE_SET_REGEX, BLANK_KEPT_TEST_DIRS } = require('./featureModules');
6
8
  const { OLD_PACKAGE_NAME } = require('./templateIdentity');
9
+ const { SERVER_DIR, INCLUDE_SERVER_BUILD, serverPackageOf } = require('./serverFromBackend');
7
10
 
8
11
  // The template's own identity: if it is left in a generated project, the rename missed a spot.
9
12
  // The user's input can carry the same text (appName accepts dots and letters, ≤30; the project name
@@ -81,8 +84,6 @@ function capitalize(name) {
81
84
  }
82
85
 
83
86
  const relPath = (projectPath, file) => path.relative(projectPath, file).split(path.sep).join('/');
84
- // The one test source set blank keeps: the Android-host Koin graph check
85
- const KOIN_GRAPH_TEST_DIR = 'shared/src/androidHostTest/';
86
87
 
87
88
  // Blank's shell wires the theme and the strings through its layers; Theming off leaves
88
89
  // no reference to the theme packages it removes; no template test ships (Compass's test gate).
@@ -118,7 +119,7 @@ async function verifyBlankShell(projectPath, input, problems) {
118
119
  for (const file of files) {
119
120
  const filePath = relPath(projectPath, file);
120
121
  const match = filePath.match(TEST_SOURCE_SET_REGEX);
121
- if (!match || filePath.startsWith(KOIN_GRAPH_TEST_DIR)) continue;
122
+ if (!match || BLANK_KEPT_TEST_DIRS.some((dir) => filePath.startsWith(dir))) continue;
122
123
  const dir = filePath.slice(0, match.index + match[0].length - 1);
123
124
  problems.add(`${dir}: template tests in a blank project (Compass counts every iOS test as the agent's)`);
124
125
  }
@@ -498,8 +499,8 @@ async function readIfExists(file) {
498
499
  // Backend: blank keeps the server, core/api, core/auth, feature/account and feature/sharing exactly when its
499
500
  // backend flag is on, wired, with the Google Sign-In URL scheme left to the build setting; otherwise (and
500
501
  // always in showcase) none of them and no reference to them. No cmpose:backend marker is ever left.
502
+ // server/ is the backend template, an included build (includeBuild("server")), so it is checked apart from the modules.
501
503
  const BACKEND_MODULES = [
502
- { dir: 'server', build: 'server/build.gradle.kts', include: ':server' },
503
504
  { dir: 'core/api', build: 'core/api/build.gradle.kts', include: ':core:api' },
504
505
  { dir: 'core/auth', build: 'core/auth/build.gradle.kts', include: ':core:auth' },
505
506
  { dir: 'feature/account', build: 'feature/account/presentation/build.gradle.kts', include: ':feature:account:presentation' },
@@ -509,6 +510,44 @@ const BACKEND_SHARED_DEPENDENCIES = ['projects.core.auth', 'projects.feature.acc
509
510
  const BACKEND_KOIN_MODULES = ['AuthModule', 'AccountModule', 'SharingModule'];
510
511
  const GOOGLE_URL_SCHEME = '<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>';
511
512
  const BACKEND_MARKER = 'cmpose:backend';
513
+ const LOCAL_SERVER_DIR = /^[ \t]*localServerDir[ \t]*=[ \t]*\.\.\/\.local-server[ \t]*$/m;
514
+ const BACKEND_TEMPLATE_PACKAGE = 'io.cmpose.backendtemplate';
515
+ const SERVER_PRUNED = new Set(['.git', '.gradle', '.kotlin', 'build', '.local-server']);
516
+
517
+ // Every file under server/ (build output aside) holding the backend template's own package: the rename missed it
518
+ async function serverTemplateResidue(projectPath, rel = SERVER_DIR, out = []) {
519
+ for (const entry of await fs.readdir(path.join(projectPath, rel), { withFileTypes: true })) {
520
+ if (SERVER_PRUNED.has(entry.name)) continue;
521
+ const child = `${rel}/${entry.name}`;
522
+ if (child.includes(BACKEND_TEMPLATE_PACKAGE.replace(/\./g, '/'))) out.push(child);
523
+ if (entry.isDirectory()) {
524
+ await serverTemplateResidue(projectPath, child, out);
525
+ } else if (entry.isFile()) {
526
+ const buffer = await fs.readFile(path.join(projectPath, child));
527
+ if (!buffer.includes(0) && buffer.toString('utf8').includes(BACKEND_TEMPLATE_PACKAGE)) out.push(child);
528
+ }
529
+ }
530
+ return out;
531
+ }
532
+
533
+ async function verifyServer(projectPath, input, settings, problems) {
534
+ const exists = (rel) => fs.pathExists(path.join(projectPath, rel));
535
+ for (const rel of ['server/settings.gradle.kts', 'server/build.gradle.kts']) {
536
+ if (!(await exists(rel))) problems.add(`${rel}: missing although backend is on`);
537
+ }
538
+ if (!INCLUDE_SERVER_BUILD.test(settings)) problems.add('settings.gradle.kts: includeBuild("server") missing although backend is on');
539
+ if (!LOCAL_SERVER_DIR.test(await readIfExists(path.join(projectPath, SERVER_DIR, 'gradle.properties')))) {
540
+ problems.add('server/gradle.properties: localServerDir=../.local-server missing (the local server lives in the root .local-server/)');
541
+ }
542
+ if (await exists('server/.github')) problems.add('server/.github: the backend template\'s CI left (the project\'s own CI builds server/)');
543
+ const serverPath = serverPackageOf(input.packageName).replace(/\./g, '/');
544
+ for (const rel of [`server/src/main/kotlin/${serverPath}`, `server/src/local/kotlin/${serverPath}/local/LocalMain.kt`]) {
545
+ if (!(await exists(rel))) problems.add(`${rel}: missing although backend is on`);
546
+ }
547
+ // A package that spells the template's own (io.cmpose.backendtemplate.x) is the user's, not residue
548
+ if (!(await exists(SERVER_DIR)) || input.packageName.includes(BACKEND_TEMPLATE_PACKAGE)) return;
549
+ for (const rel of new Set(await serverTemplateResidue(projectPath))) problems.add(`${rel}: ${BACKEND_TEMPLATE_PACKAGE} left`);
550
+ }
512
551
 
513
552
  async function verifyBackend(projectPath, input, cfg, problems) {
514
553
  const on = input.templateType === 'blank' && cfg.backend === true;
@@ -520,7 +559,7 @@ async function verifyBackend(projectPath, input, cfg, problems) {
520
559
 
521
560
  if (!on) {
522
561
  const where = input.templateType === 'blank' ? 'although backend is off' : 'in a showcase project';
523
- for (const { dir } of BACKEND_MODULES) {
562
+ for (const dir of [SERVER_DIR, ...BACKEND_MODULES.map((m) => m.dir)]) {
524
563
  if (await exists(dir)) problems.add(`${dir}: present ${where}`);
525
564
  }
526
565
  const tokens = BACKEND_RESIDUE_TOKENS.filter((token) => token.label !== BACKEND_MARKER);
@@ -535,6 +574,7 @@ async function verifyBackend(projectPath, input, cfg, problems) {
535
574
  if (!(await exists(build))) problems.add(`${build}: missing although backend is on`);
536
575
  if (!settings.includes(`"${include}"`)) problems.add(`settings.gradle.kts: ${include} not included`);
537
576
  }
577
+ await verifyServer(projectPath, input, settings, problems);
538
578
  const sharedBuild = await readIfExists(path.join(projectPath, 'shared', 'build.gradle.kts'));
539
579
  for (const dependency of BACKEND_SHARED_DEPENDENCIES) {
540
580
  if (!new RegExp(`\\b${escapeRegExp(dependency)}(?![A-Za-z0-9.])`).test(sharedBuild)) {
@@ -554,12 +594,77 @@ async function verifyBackend(projectPath, input, cfg, problems) {
554
594
  }
555
595
  }
556
596
 
597
+ // One version for both platforms (the server's X-Client-Version check compares it): the iOS MARKETING_VERSION equals
598
+ // AppConfig.versionName. Keyed on both values, so a template without them (staging, an older tag) is not checked.
599
+ async function verifyAppVersion(projectPath, problems) {
600
+ const xcconfig = await readIfExists(path.join(projectPath, 'iosApp/Configuration/Config.xcconfig'));
601
+ const marketing = xcconfig.match(/^[ \t]*MARKETING_VERSION[ \t]*=[ \t]*([^\s]+)[ \t]*$/m);
602
+ const appConfig = await readIfExists(path.join(projectPath, 'build-logic/src/main/kotlin/config/AppConfig.kt'));
603
+ const part = (name) => (appConfig.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)\\b`)) || [])[1];
604
+ const [major, minor, patch] = ['versionMajor', 'versionMinor', 'versionPatch'].map(part);
605
+ if (!marketing || major === undefined || minor === undefined || patch === undefined) return;
606
+ const versionName = `${major}.${minor}.${patch}`;
607
+ if (marketing[1] !== versionName) problems.add(`iOS MARKETING_VERSION (${marketing[1]}) must equal AppConfig.versionName (${versionName})`);
608
+ }
609
+
610
+ const run = (command, args, options) => new Promise((resolve) => {
611
+ execFile(command, args, { maxBuffer: 64 * 1024 * 1024, ...options }, (error, stdout, stderr) => resolve({ error, stdout, stderr }));
612
+ });
613
+ const DOCKER_INFO_TIMEOUT_MS = 20000;
614
+ const DOCKER_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
615
+
616
+ /** The server image check: docker build -f server/deploy/Dockerfile server. { status: 'PASS' | 'SKIP' | 'FAIL', detail };
617
+ * SKIP when Docker (the docker command and its daemon) is not available */
618
+ async function checkServerImage(projectPath, { docker = 'docker' } = {}) {
619
+ const info = await run(docker, ['info', '--format', '{{.ServerVersion}}'], { timeout: DOCKER_INFO_TIMEOUT_MS });
620
+ if (info.error) return { status: 'SKIP', detail: 'Docker is not available' };
621
+ const build = await run(docker, ['build', '--quiet', '-f', path.join(SERVER_DIR, 'deploy', 'Dockerfile'), SERVER_DIR],
622
+ { cwd: projectPath, timeout: DOCKER_BUILD_TIMEOUT_MS });
623
+ if (build.error) {
624
+ const tail = `${build.stderr || build.error.message}`.trim().split('\n').slice(-5).join(' | ');
625
+ return { status: 'FAIL', detail: tail };
626
+ }
627
+ // The image itself is not kept (the build cache is)
628
+ const image = build.stdout.trim().split('\n').pop();
629
+ if (image) await run(docker, ['image', 'rm', image], { timeout: DOCKER_INFO_TIMEOUT_MS });
630
+ return { status: 'PASS', detail: image };
631
+ }
632
+
633
+ const SERVER_IMAGE_ENV = 'CMPOSE_SERVER_IMAGE_CHECK';
634
+ const NOT_REQUESTED = `not requested (set ${SERVER_IMAGE_ENV}=1)`;
635
+
636
+ /** Which server image check runs, null = none. Opt-in: the option (a check function, true for the Docker check, false
637
+ * for none) wins; without it, CMPOSE_SERVER_IMAGE_CHECK=1 asks for the Docker check. Generation for an end user never
638
+ * pays minutes for an image or loses a valid project to the network. */
639
+ function resolveServerImageCheck(option, env = process.env) {
640
+ if (typeof option === 'function') return option;
641
+ if (option === true) return checkServerImage;
642
+ if (option === false) return null;
643
+ return env[SERVER_IMAGE_ENV] === '1' ? checkServerImage : null;
644
+ }
645
+
646
+ // The server image builds (the backend template's own gate does not build it with the app's server package); skipped
647
+ // without Docker, and unless asked for
648
+ async function verifyServerImage(projectPath, serverImageCheck, problems) {
649
+ const { status, detail } = serverImageCheck
650
+ ? await serverImageCheck(projectPath)
651
+ : { status: 'SKIP', detail: NOT_REQUESTED };
652
+ if (status === 'FAIL') {
653
+ problems.add(`server/deploy/Dockerfile: the server image does not build (docker build -f server/deploy/Dockerfile server): ${detail}`);
654
+ } else {
655
+ log.info(`${status === 'PASS' ? '✅' : '⏭️'} server image: ${status}${status === 'SKIP' && detail ? ` (${detail})` : ''}`);
656
+ }
657
+ }
658
+
557
659
  /**
558
660
  * Checks the generated project right before it is zipped; throws if anything is wrong (the request fails).
559
661
  * @param {string} projectPath
560
662
  * @param {{ projectName: string, appName: string, packageName: string, templateType: string, features: string[], featuresConfig?: object | null }} input
663
+ * @param {{ serverImageCheck?: boolean | ((projectPath: string) => Promise<{ status: 'PASS' | 'SKIP' | 'FAIL', detail?: string }>),
664
+ * env?: object }} [options] the server image check (backend on; resolveServerImageCheck: opt-in, the option or
665
+ * CMPOSE_SERVER_IMAGE_CHECK=1 in env), run last and only when nothing else failed
561
666
  */
562
- async function verifyGeneratedProject(projectPath, input) {
667
+ async function verifyGeneratedProject(projectPath, input, { serverImageCheck, env = process.env } = {}) {
563
668
  const packagePath = input.packageName.replace(/\./g, '/');
564
669
  const problems = new Set();
565
670
 
@@ -666,6 +771,13 @@ async function verifyGeneratedProject(projectPath, input) {
666
771
  // 14. Backend: the server and its modules exactly when blank's backend flag is on, no residue otherwise
667
772
  await verifyBackend(projectPath, input, input.featuresConfig || {}, problems);
668
773
 
774
+ // 15. One app version for both platforms
775
+ await verifyAppVersion(projectPath, problems);
776
+
777
+ // 16. The server image, last: it takes minutes, so only a project that passed everything else is built
778
+ const backendOn = input.templateType === 'blank' && (input.featuresConfig || {}).backend === true;
779
+ if (backendOn && problems.size === 0) await verifyServerImage(projectPath, resolveServerImageCheck(serverImageCheck, env), problems);
780
+
669
781
  if (problems.size > 0) {
670
782
  // Cap the message: a badly broken generation (many unwired modules) can produce dozens of
671
783
  // lines, which is unreadable in an error response or a log line.
@@ -678,4 +790,4 @@ async function verifyGeneratedProject(projectPath, input) {
678
790
  }
679
791
  }
680
792
 
681
- module.exports = { verifyGeneratedProject, verifyBackend };
793
+ module.exports = { verifyGeneratedProject, verifyBackend, verifyAppVersion, checkServerImage, resolveServerImageCheck, SERVER_IMAGE_ENV };
package/src/index.js CHANGED
@@ -34,6 +34,9 @@ module.exports = {
34
34
  minSdkAboveTarget: require('./validate').minSdkAboveTarget,
35
35
  generationWarnings: require('./warnings').generationWarnings,
36
36
  generateCmpProject: require('./generate').generateCmpProject,
37
+ // server/ from the backend template (also the template maintainers' fetch-server script)
38
+ placeServer: require('./serverFromBackend').placeServer,
39
+ deriveServerAppName: require('./serverFromBackend').deriveServerAppName,
37
40
  templateCache: require('./templateCache'),
38
41
  game: require('./game'),
39
42
  backend: require('./backend'),
package/src/pins.js CHANGED
@@ -1,9 +1,9 @@
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: 'whinstudio/cmpose-template', ref: 'template-2026.10.17' }),
4
+ cmp: Object.freeze({ repo: 'whinstudio/cmpose-template', ref: 'template-2026.10.20' }),
5
5
  // The release step replaces the placeholder with the game template's tag
6
6
  game: Object.freeze({ repo: 'whinstudio/cmpose-game-template', ref: 'game-template-2026.10.19' }),
7
7
  // The release step replaces the placeholder with the backend template's tag
8
- backend: Object.freeze({ repo: 'whinstudio/cmpose-backend-template', ref: 'backend-template-2026.10.19' })
8
+ backend: Object.freeze({ repo: 'whinstudio/cmpose-backend-template', ref: 'backend-template-2026.10.20' })
9
9
  });
@@ -1,6 +1,7 @@
1
1
  const { log } = require('./log');
2
2
  const fs = require('fs-extra');
3
3
  const path = require('path');
4
+ const { INCLUDE_SERVER_BUILD, SERVER_DIR } = require('./serverFromBackend');
4
5
 
5
6
  // The generated README's requirements table and module map. The template's README marks
6
7
  // both sections (<!-- cmpose:requirements --> … <!-- /cmpose:requirements -->); a README without
@@ -24,7 +25,7 @@ const CORE_DESCRIPTIONS = {
24
25
  update: 'app updates: Play In-App Updates, the App Store lookup and an optional minimum version',
25
26
  ads: 'AdMob ads and consent (test IDs)',
26
27
  room: 'Room database (bundled SQLite): AppDatabase, DAOs and schemas',
27
- api: 'models and routes shared by the app and the server',
28
+ api: "the app's client models and routes for the backend API, checked by ApiContractTest",
28
29
  auth: "Firebase sign-in: email, Google, Apple; the server client's token"
29
30
  };
30
31
 
@@ -48,7 +49,12 @@ function renderRequirements(versions, { minSdk, iosVersion }) {
48
49
  ].join('\n');
49
50
  }
50
51
 
51
- function renderModules(settingsContent) {
52
+ // server/ is an included build (INCLUDE_SERVER_BUILD), not a module
53
+ const SERVER_LINE = '- `server/`: Ktor server from the cmpose backend template (an included build): sign-in check, spaces, sharing, files; '
54
+ + '`./gradlew :server:runLocal`; deploying: `server/deploy/README.md`';
55
+
56
+ // serverDir: whether the project has server/ (the settings include it only when it is there)
57
+ function renderModules(settingsContent, { serverDir = false } = {}) {
52
58
  const paths = [...settingsContent.matchAll(/^\s*include\(([^)]*)\)/gm)]
53
59
  .flatMap((m) => [...m[1].matchAll(/"(:?[^"]+)"/g)].map((q) => q[1]))
54
60
  .map((p) => (p.startsWith(':') ? p : `:${p}`));
@@ -66,9 +72,7 @@ function renderModules(settingsContent) {
66
72
  const layers = paths.filter((x) => x.startsWith(`:feature:${feature}:`)).map((x) => x.split(':')[3]).sort();
67
73
  lines.push(layers.length ? `- \`:feature:${feature}\`: ${layers.join(', ')}` : `- \`:feature:${feature}\``);
68
74
  }
69
- if (paths.includes(':server')) {
70
- lines.push('- `:server`: Ktor server: sign-in check, spaces, sharing, files; `./gradlew :server:runLocal`');
71
- }
75
+ if (serverDir && INCLUDE_SERVER_BUILD.test(settingsContent)) lines.push(SERVER_LINE);
72
76
  return lines.join('\n');
73
77
  }
74
78
 
@@ -99,7 +103,8 @@ async function fillReadme(projectPath, { versions, minSdk, iosVersion }) {
99
103
  if (!content.includes('<!-- cmpose:requirements -->') && !content.includes('<!-- cmpose:modules -->')) return false;
100
104
  const settings = await fs.readFile(path.join(projectPath, 'settings.gradle.kts'), 'utf8').catch(() => '');
101
105
  let updated = replaceBetweenMarkers(content, 'cmpose:requirements', renderRequirements(versions, { minSdk, iosVersion }));
102
- updated = replaceBetweenMarkers(updated, 'cmpose:modules', renderModules(settings));
106
+ const serverDir = await fs.pathExists(path.join(projectPath, SERVER_DIR));
107
+ updated = replaceBetweenMarkers(updated, 'cmpose:modules', renderModules(settings, { serverDir }));
103
108
  await fs.writeFile(readmePath, updated);
104
109
  return true;
105
110
  } catch (error) {
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const { log } = require('./log');
5
+ const { appNameProblem } = require('./backend/validate');
6
+ const { generateBackend } = require('./backend/generate');
7
+
8
+ // A blank project's server/ is the cmpose backend template, generated in place as an included build (the root settings
9
+ // include it with includeBuild("server")): sharing on, sample off, the package <app package>.server, and the local
10
+ // server's folder at the project root (.local-server/, Compass's contract).
11
+
12
+ const SERVER_DIR = 'server';
13
+ const LOCAL_SERVER_DIR_LINE = 'localServerDir=../.local-server';
14
+ // The root settings' includeBuild("server") on a line that is not a comment (the template guards it with an if)
15
+ const INCLUDE_SERVER_BUILD = /^(?![ \t]*\/\/).*\bincludeBuild\("server"\)/m;
16
+ // The template's CI and the backend's own generation record: the app's CI and record cover server/
17
+ const SERVER_LEFTOVERS = ['.github', '.cmpose'];
18
+ const BASE_MAX = 27;
19
+
20
+ const serverPackageOf = (packageName) => `${packageName}.server`;
21
+
22
+ // The project name in kebab case (MyApp -> my-app, MyAPIApp -> my-api-app), cut at BASE_MAX without a trailing -
23
+ function kebabBase(projectName) {
24
+ const kebab = String(projectName)
25
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
26
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9-]+/g, '-')
29
+ .replace(/-+/g, '-')
30
+ .replace(/^[^a-z]+/, '');
31
+ return kebab.slice(0, BASE_MAX).replace(/-+$/, '');
32
+ }
33
+
34
+ /** The server's appName for a project: the project name in kebab case, then with -app, then with -server; the first
35
+ * one the backend's appName rule accepts */
36
+ function deriveServerAppName(projectName) {
37
+ const base = kebabBase(projectName);
38
+ for (const candidate of [base, `${base}-app`, `${base}-server`]) {
39
+ if (!appNameProblem(candidate)) return candidate;
40
+ }
41
+ throw new Error(`Cannot derive a server name from ${projectName}`);
42
+ }
43
+
44
+ // localServerDir=../.local-server as the file's own line (an existing localServerDir line is replaced)
45
+ async function writeLocalServerDir(serverDir) {
46
+ const file = path.join(serverDir, 'gradle.properties');
47
+ const content = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf8') : '';
48
+ const lines = content.split('\n').filter((line) => !/^\s*localServerDir\s*=/.test(line));
49
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
50
+ lines.push(LOCAL_SERVER_DIR_LINE);
51
+ await fs.writeFile(file, `${lines.join('\n')}\n`);
52
+ }
53
+
54
+ /** Moves a fresh backend template copy (backendTemplate.dir) to <projectDir>/server and generates it there. Fails when
55
+ * server/ already exists. Returns the server's appName, package and template.
56
+ * On a failure part way, server/ may be half written: the caller discards the project dir. */
57
+ async function placeServer(projectDir, { packageName, projectName, backendTemplate, generatorVersion = null, now = new Date() }) {
58
+ const { dir, repo = null, ref = null, commit = null } = backendTemplate || {};
59
+ if (!dir) throw new Error('backend: the backend template is required (backendTemplate)');
60
+ const serverDir = path.join(projectDir, SERVER_DIR);
61
+ if (await fs.pathExists(serverDir)) throw new Error(`${SERVER_DIR}/ already exists in the project`);
62
+
63
+ // 1. move: a rename on the same file system, otherwise a copy and a removal
64
+ await fs.move(dir, serverDir);
65
+
66
+ // 2. generate
67
+ const appName = deriveServerAppName(projectName);
68
+ const serverPackage = serverPackageOf(packageName);
69
+ await generateBackend({ projectName, appName, packageName: serverPackage, sharing: true, sample: false }, {
70
+ projectDir: serverDir, templateRef: ref, templateCommit: commit, generatorVersion, now
71
+ });
72
+
73
+ // 3. the local server's folder is the project root's .local-server/
74
+ await writeLocalServerDir(serverDir);
75
+
76
+ // 4. leftovers
77
+ for (const leftover of SERVER_LEFTOVERS) await fs.remove(path.join(serverDir, leftover));
78
+ log.info(`✅ Server ready (${appName}, ${serverPackage})`);
79
+ return { appName, packageName: serverPackage, template: { repo, ref, commit } };
80
+ }
81
+
82
+ module.exports = { deriveServerAppName, placeServer, serverPackageOf, SERVER_DIR, LOCAL_SERVER_DIR_LINE, INCLUDE_SERVER_BUILD };
package/src/validate.js CHANGED
@@ -4,6 +4,7 @@ const LIMITS = require('./limits');
4
4
  const { configureValidator } = require('./contract');
5
5
  const { parseGenerateBody } = require('./requestParser');
6
6
  const { normalizeFeaturesConfig } = require('./featuresConfig');
7
+ const { serverPackageOf } = require('./serverFromBackend');
7
8
 
8
9
  // compile/target SDK come from the template (AppConfig.kt); a minSdk above its targetSdk cannot build
9
10
  function minSdkAboveTarget(minSdk, targetSdk) {
@@ -60,6 +61,13 @@ function validate(input, { limits = LIMITS, templateTargetSdk = null } = {}) {
60
61
  !PV.validateUrl(normalizedFeaturesConfig.networkBaseUrl)) {
61
62
  return fail(PV.messages.urlInvalid);
62
63
  }
64
+ // The backend's server package is the app's plus .server (server/ from the backend template)
65
+ if (normalizedFeaturesConfig.backend === true) {
66
+ const serverPackage = serverPackageOf(packageName);
67
+ if (!PV.validatePackageName(serverPackage) || !PV.validateMaxLength(serverPackage, PV.MAX_LENGTH_PACKAGE_NAME)) {
68
+ return fail(`packageName is too long for the server package (${serverPackage})`);
69
+ }
70
+ }
63
71
  }
64
72
 
65
73
  return {