@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 +1 -1
- package/src/backend/contract.js +4 -1
- package/src/backend/describe.js +9 -3
- package/src/backend/generate.js +16 -3
- package/src/backend/index.js +6 -2
- package/src/backend/options.js +66 -37
- package/src/backend/record.js +1 -0
- package/src/backend/validate.js +31 -14
- package/src/backend/verifier.js +10 -3
- package/src/backendOption.js +2 -1
- package/src/contract.js +1 -1
- package/src/featureModules.js +7 -3
- package/src/fileService.js +14 -19
- package/src/generate.js +28 -7
- package/src/generationRecord.js +9 -2
- package/src/generationVerifier.js +120 -8
- package/src/index.js +3 -0
- package/src/pins.js +2 -2
- package/src/readmeService.js +11 -6
- package/src/serverFromBackend.js +82 -0
- package/src/validate.js +8 -0
package/package.json
CHANGED
package/src/backend/contract.js
CHANGED
|
@@ -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({
|
|
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' })
|
package/src/backend/describe.js
CHANGED
|
@@ -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>/
|
|
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.',
|
package/src/backend/generate.js
CHANGED
|
@@ -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
|
-
//
|
|
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 };
|
package/src/backend/index.js
CHANGED
|
@@ -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,
|
package/src/backend/options.js
CHANGED
|
@@ -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
|
|
7
|
-
// reference), so a generated project is byte for byte
|
|
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
|
|
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
|
|
14
|
-
const
|
|
15
|
-
// The resolver's own tools, which a generated project has no use for
|
|
16
|
-
const RESOLVER_TOOLS = ['
|
|
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:${
|
|
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:${
|
|
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
|
-
|
|
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>`
|
|
60
|
-
|
|
61
|
-
|
|
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:${
|
|
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:${
|
|
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))
|
|
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:${
|
|
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
|
|
122
|
-
async function
|
|
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(
|
|
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(`${
|
|
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
|
|
139
|
-
*
|
|
140
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
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 };
|
package/src/backend/record.js
CHANGED
|
@@ -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
|
};
|
package/src/backend/validate.js
CHANGED
|
@@ -22,24 +22,35 @@ function databaseNameProblem(appName) {
|
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
//
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
55
|
-
|
|
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 = {
|
|
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 };
|
package/src/backend/verifier.js
CHANGED
|
@@ -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 (
|
|
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
|
}
|
package/src/backendOption.js
CHANGED
|
@@ -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
|
|
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).
|
package/src/featureModules.js
CHANGED
|
@@ -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 = ['
|
|
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
|
};
|
package/src/fileService.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
930
|
-
//
|
|
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
|
|
2192
|
-
// backend's
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
}
|
package/src/generationRecord.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
8
|
+
backend: Object.freeze({ repo: 'whinstudio/cmpose-backend-template', ref: 'backend-template-2026.10.20' })
|
|
9
9
|
});
|
package/src/readmeService.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 {
|