@cmpose/core 0.3.0 → 0.5.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/README.md +1 -1
- package/package.json +1 -1
- package/src/backend/contract.js +39 -0
- package/src/backend/describe.js +73 -0
- package/src/backend/generate.js +39 -0
- package/src/backend/identity.js +109 -0
- package/src/backend/index.js +24 -0
- package/src/backend/options.js +159 -0
- package/src/backend/record.js +31 -0
- package/src/backend/validate.js +64 -0
- package/src/backend/verifier.js +69 -0
- package/src/backendOption.js +1 -64
- package/src/game/applyTheme.js +129 -0
- package/src/game/contract.js +4 -1
- package/src/game/describe.js +15 -3
- package/src/game/generate.js +19 -4
- package/src/game/index.js +8 -0
- package/src/game/record.js +4 -2
- package/src/game/themeColors.js +173 -0
- package/src/game/themes.js +112 -0
- package/src/game/validate.js +10 -1
- package/src/index.js +2 -0
- package/src/markerBlocks.js +93 -0
- package/src/pins.js +3 -1
package/README.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
The cmpose generator library: it turns the pinned Compose Multiplatform template into a new project (validation, package and name rewrites, optional features and modules). See the README at the root of the cmpose repository for usage and development.
|
|
4
4
|
|
|
5
|
-
It also generates Unity mobile games from the pinned game template: `game.validateGame`, `game.generateGame` and `game.describeGame
|
|
5
|
+
It also generates Unity mobile games from the pinned game template: `game.validateGame`, `game.generateGame` and `game.describeGame` (`describeGame({ themes: await game.readThemes(templateDir) })` adds the template's theme families).
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { hashContract } = require('../contract');
|
|
3
|
+
|
|
4
|
+
// The Ktor backend generator's contract: what the backend_* tools accept. Published facts feed contractVersion.
|
|
5
|
+
const BACKEND_SCHEMA = Object.freeze({
|
|
6
|
+
appName: Object.freeze({
|
|
7
|
+
pattern: '^[a-z][a-z0-9-]{1,30}$',
|
|
8
|
+
// the server's own route prefixes
|
|
9
|
+
reserved: Object.freeze(['api', 'health', 'admin', 'static']),
|
|
10
|
+
// the shared server's own services, which an app's compose service and nginx location must not shadow
|
|
11
|
+
infrastructure: Object.freeze(['postgres', 'nginx', 'proxy', 'db', 'redis']),
|
|
12
|
+
// PostgreSQL's own databases and schema, and its pg_ prefix for roles (matched on the database name)
|
|
13
|
+
postgresReserved: Object.freeze(['public', 'template0', 'template1', 'postgres']),
|
|
14
|
+
postgresPrefix: 'pg_',
|
|
15
|
+
// PostgreSQL's reserved key words (both reserved columns of the key word appendix): the database name never is one
|
|
16
|
+
sqlKeywords: Object.freeze([
|
|
17
|
+
'all', 'analyse', 'analyze', 'and', 'any', 'array', 'as', 'asc', 'asymmetric', 'authorization', 'binary', 'both',
|
|
18
|
+
'case', 'cast', 'check', 'collate', 'collation', 'column', 'concurrently', 'constraint', 'create', 'cross',
|
|
19
|
+
'current_catalog', 'current_date', 'current_role', 'current_schema', 'current_time', 'current_timestamp',
|
|
20
|
+
'current_user', 'default', 'deferrable', 'desc', 'distinct', 'do', 'else', 'end', 'except', 'false', 'fetch',
|
|
21
|
+
'for', 'foreign', 'freeze', 'from', 'full', 'grant', 'group', 'having', 'ilike', 'in', 'initially', 'inner',
|
|
22
|
+
'intersect', 'into', 'is', 'isnull', 'join', 'lateral', 'leading', 'left', 'like', 'limit', 'localtime',
|
|
23
|
+
'localtimestamp', 'natural', 'not', 'notnull', 'null', 'offset', 'on', 'only', 'or', 'order', 'outer',
|
|
24
|
+
'overlaps', 'placing', 'primary', 'references', 'returning', 'right', 'select', 'session_user', 'similar',
|
|
25
|
+
'some', 'symmetric', 'system_user', 'table', 'tablesample', 'then', 'to', 'trailing', 'true', 'union', 'unique',
|
|
26
|
+
'user', 'using', 'variadic', 'verbose', 'when', 'where', 'window', 'with'
|
|
27
|
+
])
|
|
28
|
+
}),
|
|
29
|
+
options: Object.freeze({ sharing: Object.freeze({ type: 'boolean', default: false }) }),
|
|
30
|
+
// The template's own names, which the generator renames
|
|
31
|
+
identity: Object.freeze({ package: 'io.cmpose.backendtemplate', appName: 'cmpose-app', projectName: 'Cmpose Backend' }),
|
|
32
|
+
anatomy: Object.freeze({ localTask: 'runLocal', localPort: 18001, deployDir: 'deploy' })
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function backendContractVersion() {
|
|
36
|
+
return hashContract(BACKEND_SCHEMA);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { BACKEND_SCHEMA, backendContractVersion };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const pins = require('../pins');
|
|
3
|
+
const { BACKEND_SCHEMA: S, backendContractVersion } = require('./contract');
|
|
4
|
+
|
|
5
|
+
/** What the Ktor backend generator offers, plus the rules an AI client follows when it writes code in the project */
|
|
6
|
+
function describeBackend() {
|
|
7
|
+
const port = S.anatomy.localPort;
|
|
8
|
+
return {
|
|
9
|
+
contractVersion: backendContractVersion(),
|
|
10
|
+
templateRef: pins.backend.ref,
|
|
11
|
+
template: pins.backend,
|
|
12
|
+
options: {
|
|
13
|
+
sharing: {
|
|
14
|
+
type: S.options.sharing.type,
|
|
15
|
+
default: S.options.sharing.default,
|
|
16
|
+
description: 'Spaces shared between users, with roles (viewer, editor, owner) and invitations. Off: every row belongs to one user.'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
appNameRules: {
|
|
20
|
+
pattern: S.appName.pattern,
|
|
21
|
+
reserved: S.appName.reserved.slice(),
|
|
22
|
+
infrastructure: S.appName.infrastructure.slice(),
|
|
23
|
+
postgresReserved: S.appName.postgresReserved.slice(),
|
|
24
|
+
postgresPrefix: 'pg-',
|
|
25
|
+
sqlKeywords: S.appName.sqlKeywords.slice(),
|
|
26
|
+
databaseName: 'appName with - replaced by _ (database and database user)'
|
|
27
|
+
},
|
|
28
|
+
anatomy: {
|
|
29
|
+
stack: 'Kotlin on JVM 21, Ktor, Exposed, Flyway, PostgreSQL, HikariCP; Firebase ID tokens verified on the server',
|
|
30
|
+
paths: {
|
|
31
|
+
'src/main/kotlin/<package>/config': 'AppConfig: every setting, read from the environment',
|
|
32
|
+
'src/main/kotlin/<package>/plugins': 'errors, security headers, CORS, body limits, rate limits, request ids, client version',
|
|
33
|
+
'src/main/kotlin/<package>/auth': 'token verification and requireUser()',
|
|
34
|
+
'src/main/kotlin/<package>/users': 'GET and DELETE /api/v1/me',
|
|
35
|
+
'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',
|
|
37
|
+
'src/main/kotlin/<package>/sharing': 'spaces, roles and invitations (sharing only)',
|
|
38
|
+
'src/main/kotlin/<package>/openapi': 'the API description, served at /api/v1/openapi.json',
|
|
39
|
+
'src/main/resources/db/migration': 'Flyway migrations (V<n>__<name>.sql)',
|
|
40
|
+
'src/local/kotlin': 'the local runner (embedded PostgreSQL)',
|
|
41
|
+
[S.anatomy.deployDir + '/']: 'Dockerfile, compose fragment, nginx location, database script, .env.example'
|
|
42
|
+
},
|
|
43
|
+
routes: { api: '/api/v1', liveness: '/health', readiness: '/ready', openapi: '/api/v1/openapi.json' },
|
|
44
|
+
containerPort: 8080,
|
|
45
|
+
localPort: port,
|
|
46
|
+
requiredEnv: ['DATABASE_URL', 'DATABASE_USER', 'DATABASE_PASSWORD', 'FIREBASE_PROJECT_ID', 'FILES_DIR']
|
|
47
|
+
},
|
|
48
|
+
conventions: {
|
|
49
|
+
protectRoutes: 'Every route under /api/v1 calls call.requireUser() before it reads the body or looks anything up (401 without a valid token). ' +
|
|
50
|
+
'With sharing, a route on a space\'s data calls call.requireRole(spaces, spaceId, Role.VIEWER) to read and Role.EDITOR to write, ' +
|
|
51
|
+
'then uses the returned membership.spaceId.',
|
|
52
|
+
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
|
+
'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), ' +
|
|
55
|
+
'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
|
+
newTable: 'Every new table points at its owner with REFERENCES users(id) ON DELETE CASCADE (with sharing: spaces(id) ON DELETE CASCADE), ' +
|
|
57
|
+
'so account deletion removes its rows.',
|
|
58
|
+
securityChecklist: 'Go through SECURITY-CHECKLIST.md for every new route or table: authorization, owner filter, validation at the boundary, ' +
|
|
59
|
+
'DTOs without id/owner/role fields, no SQL string building, no personal data in logs, rate limits on sensitive routes.',
|
|
60
|
+
errors: 'Fail with notFound(), badRequest(...) or an ApiException; the body is {"error":{"code","message","requestId"}} (+ fields for validation). ' +
|
|
61
|
+
'A 500 carries no detail.',
|
|
62
|
+
runLocal: `./gradlew ${S.anatomy.localTask} serves on 127.0.0.1:${port} with an embedded PostgreSQL in .local-server/; ` +
|
|
63
|
+
'it needs .local-server/config.properties with firebaseProjectId=<Firebase project id> (optional port=). ' +
|
|
64
|
+
'./gradlew resetLocal deletes .local-server/; ./gradlew build compiles, runs detekt and the tests.',
|
|
65
|
+
deploy: `${S.anatomy.deployDir}/ holds the Dockerfile (container port 8080), docker-compose.<appName>.yml, nginx/<appName>.conf, ` +
|
|
66
|
+
'sql/create-<appName>.sql, .env.example and README.md (first deployment, updates, rollback); secrets come only from the environment. ' +
|
|
67
|
+
'bash tools/deploy-lint.sh checks them.',
|
|
68
|
+
clientHeaders: 'The app sends Authorization: Bearer <Firebase ID token> and X-Client-Version; below MIN_CLIENT_VERSION the answer is 426 CLIENT_UPGRADE_REQUIRED.'
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { describeBackend };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { log } = require('../log');
|
|
5
|
+
const { COMMIT_FILE_NAME, REF_FILE_NAME } = require('../templateVersions');
|
|
6
|
+
const { applySharingOption } = require('./options');
|
|
7
|
+
const { applyBackendIdentity } = require('./identity');
|
|
8
|
+
const { writeBackendRecord } = require('./record');
|
|
9
|
+
const { verifyBackendProject } = require('./verifier');
|
|
10
|
+
|
|
11
|
+
// The template cache's own records and the template's gate and notes never reach the project (tools/deploy-lint.sh stays)
|
|
12
|
+
const LEFTOVERS = ['.git', COMMIT_FILE_NAME, REF_FILE_NAME, path.join('tools', 'gate.sh'), path.join('tools', 'TEMPLATE.md')];
|
|
13
|
+
|
|
14
|
+
// Turns a fresh backend template copy (projectDir) into the requested project, in place. value: validateBackend().value.
|
|
15
|
+
// templateRef and templateCommit default to the cache's markers. Throws when a step fails or the result fails verification.
|
|
16
|
+
async function generateBackend(value, { projectDir, templateRef = null, templateCommit = null, generatorVersion = null, now = new Date() }) {
|
|
17
|
+
const readMarker = (name) => fs.readFile(path.join(projectDir, name), 'utf8').then((v) => v.trim() || null, () => null);
|
|
18
|
+
const ref = templateRef || (await readMarker(REF_FILE_NAME));
|
|
19
|
+
const commit = templateCommit || (await readMarker(COMMIT_FILE_NAME));
|
|
20
|
+
|
|
21
|
+
// 1. options, before the package moves (tools/sharing-paths.txt names the template's package dir)
|
|
22
|
+
await applySharingOption(projectDir, value.sharing);
|
|
23
|
+
log.info(`✅ Sharing ${value.sharing ? 'on' : 'off'}`);
|
|
24
|
+
|
|
25
|
+
// 2. identity
|
|
26
|
+
await applyBackendIdentity(projectDir, value);
|
|
27
|
+
log.info(`✅ Renamed to ${value.appName} (${value.packageName})`);
|
|
28
|
+
|
|
29
|
+
// 3. leftovers, then the record
|
|
30
|
+
for (const leftover of LEFTOVERS) await fs.remove(path.join(projectDir, leftover));
|
|
31
|
+
const record = await writeBackendRecord(projectDir, value, { templateRef: ref, templateCommit: commit, generatorVersion, now });
|
|
32
|
+
|
|
33
|
+
// 4. a broken project is never returned
|
|
34
|
+
const problems = await verifyBackendProject(projectDir, value);
|
|
35
|
+
if (problems.length) throw new Error(`backend verification failed: ${problems.join('; ')}`);
|
|
36
|
+
return { sharing: value.sharing, record };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { generateBackend };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { BACKEND_SCHEMA: S } = require('./contract');
|
|
5
|
+
const { PRUNED } = require('./options');
|
|
6
|
+
|
|
7
|
+
const TEMPLATE_APP = S.identity.appName;
|
|
8
|
+
const dbName = (appName) => appName.replace(/-/g, '_');
|
|
9
|
+
const envPrefix = (appName) => dbName(appName).toUpperCase();
|
|
10
|
+
|
|
11
|
+
/** The template's identity tokens and what each becomes */
|
|
12
|
+
function identityPairs({ packageName, appName, projectName }) {
|
|
13
|
+
return [
|
|
14
|
+
{ from: S.identity.package, to: packageName },
|
|
15
|
+
{ from: S.identity.package.replace(/\./g, '/'), to: packageName.replace(/\./g, '/') },
|
|
16
|
+
{ from: S.identity.projectName, to: projectName },
|
|
17
|
+
{ from: TEMPLATE_APP, to: appName },
|
|
18
|
+
{ from: dbName(TEMPLATE_APP), to: dbName(appName) },
|
|
19
|
+
{ from: envPrefix(TEMPLATE_APP), to: envPrefix(appName) }
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
24
|
+
// One pass, so a new name is never renamed again
|
|
25
|
+
function renameAll(pairs) {
|
|
26
|
+
const to = new Map(pairs.map((p) => [p.from, p.to]));
|
|
27
|
+
const re = new RegExp([...to.keys()].sort((a, b) => b.length - a.length).map(escapeRe).join('|'), 'g');
|
|
28
|
+
return (text) => text.replace(re, (m) => to.get(m));
|
|
29
|
+
}
|
|
30
|
+
// A file with a NUL byte is binary (gradle-wrapper.jar) and keeps its bytes
|
|
31
|
+
const isBinary = (buffer) => buffer.includes(0);
|
|
32
|
+
|
|
33
|
+
async function walk(dir, rel, out) {
|
|
34
|
+
for (const entry of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
|
|
35
|
+
if (PRUNED.has(entry.name)) continue;
|
|
36
|
+
const r = rel ? path.join(rel, entry.name) : entry.name;
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
out.push({ rel: r, dir: true });
|
|
39
|
+
await walk(dir, r, out);
|
|
40
|
+
} else if (entry.isFile()) {
|
|
41
|
+
out.push({ rel: r, dir: false });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Moves every file of the template package dir (any source set) into the new package dir, then drops the emptied dirs
|
|
48
|
+
async function movePackageDirs(projectDir, packageName) {
|
|
49
|
+
const from = S.identity.package.split('.');
|
|
50
|
+
const to = packageName.split('.');
|
|
51
|
+
const srcDir = path.join(projectDir, 'src');
|
|
52
|
+
if (!(await fs.pathExists(srcDir))) return;
|
|
53
|
+
for (const sourceSet of await fs.readdir(srcDir)) {
|
|
54
|
+
const kotlinDir = path.join(srcDir, sourceSet, 'kotlin');
|
|
55
|
+
const oldDir = path.join(kotlinDir, ...from);
|
|
56
|
+
if (!(await fs.pathExists(oldDir))) continue;
|
|
57
|
+
const newDir = path.join(kotlinDir, ...to);
|
|
58
|
+
const staging = path.join(kotlinDir, `.cmpose-package-${process.pid}`);
|
|
59
|
+
await fs.move(oldDir, staging);
|
|
60
|
+
// the template package's parents (io/cmpose) go when nothing else lives in them
|
|
61
|
+
for (let depth = from.length - 1; depth > 0; depth--) {
|
|
62
|
+
const parent = path.join(kotlinDir, ...from.slice(0, depth));
|
|
63
|
+
if ((await fs.readdir(parent)).length) break;
|
|
64
|
+
await fs.remove(parent);
|
|
65
|
+
}
|
|
66
|
+
if (await fs.pathExists(newDir)) throw new Error(`cannot move the package to ${path.relative(projectDir, newDir)}: it exists`);
|
|
67
|
+
await fs.move(staging, newDir);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Renames the template's package, app name (and its database and env forms) and display name: in the text files, the
|
|
72
|
+
* package dirs and every file or dir name holding the app name. Binary files and file modes stay. */
|
|
73
|
+
async function applyBackendIdentity(projectDir, { packageName, appName, projectName }) {
|
|
74
|
+
const rename = renameAll(identityPairs({ packageName, appName, projectName }));
|
|
75
|
+
const entries = await walk(projectDir, '', []);
|
|
76
|
+
|
|
77
|
+
// 1. content
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
if (e.dir) continue;
|
|
80
|
+
const file = path.join(projectDir, e.rel);
|
|
81
|
+
const buffer = await fs.readFile(file);
|
|
82
|
+
if (isBinary(buffer)) continue;
|
|
83
|
+
const text = buffer.toString('utf8');
|
|
84
|
+
const next = rename(text);
|
|
85
|
+
if (next !== text) await fs.writeFile(file, next);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. package dirs
|
|
89
|
+
await movePackageDirs(projectDir, packageName);
|
|
90
|
+
|
|
91
|
+
// 3. names holding the app name, deepest first; every target is checked before anything moves
|
|
92
|
+
const appRename = renameAll([{ from: TEMPLATE_APP, to: appName }]);
|
|
93
|
+
const moves = (await walk(projectDir, '', []))
|
|
94
|
+
.filter((e) => path.basename(e.rel).includes(TEMPLATE_APP))
|
|
95
|
+
.sort((a, b) => b.rel.split(path.sep).length - a.rel.split(path.sep).length)
|
|
96
|
+
.map((e) => {
|
|
97
|
+
const from = path.join(projectDir, e.rel);
|
|
98
|
+
return { rel: e.rel, from, to: path.join(path.dirname(from), appRename(path.basename(from))) };
|
|
99
|
+
});
|
|
100
|
+
const targets = new Set();
|
|
101
|
+
for (const m of moves) {
|
|
102
|
+
if (targets.has(m.to) || (await fs.pathExists(m.to))) throw new Error(`cannot rename ${m.rel}: target exists`);
|
|
103
|
+
targets.add(m.to);
|
|
104
|
+
}
|
|
105
|
+
for (const m of moves) await fs.move(m.from, m.to);
|
|
106
|
+
return { renamedFiles: moves.length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { applyBackendIdentity, identityPairs, dbName, envPrefix };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { validateBackend } = require('./validate');
|
|
3
|
+
const { BACKEND_SCHEMA, backendContractVersion } = require('./contract');
|
|
4
|
+
const { applySharingOption, resolveContent } = require('./options');
|
|
5
|
+
const { applyBackendIdentity } = require('./identity');
|
|
6
|
+
const { verifyBackendProject } = require('./verifier');
|
|
7
|
+
const { RECORD_PATH, buildBackendRecord, writeBackendRecord } = require('./record');
|
|
8
|
+
const { generateBackend } = require('./generate');
|
|
9
|
+
const { describeBackend } = require('./describe');
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
validateBackend,
|
|
13
|
+
BACKEND_SCHEMA,
|
|
14
|
+
backendContractVersion,
|
|
15
|
+
applySharingOption,
|
|
16
|
+
resolveContent,
|
|
17
|
+
applyBackendIdentity,
|
|
18
|
+
verifyBackendProject,
|
|
19
|
+
RECORD_PATH,
|
|
20
|
+
buildBackendRecord,
|
|
21
|
+
writeBackendRecord,
|
|
22
|
+
generateBackend,
|
|
23
|
+
describeBackend
|
|
24
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { kindOf } = require('../markerBlocks');
|
|
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:
|
|
8
|
+
// on: only the marker lines go; a swap marker goes and the line after it stays.
|
|
9
|
+
// 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
|
+
// Either way a blank line on both sides of a dropped run becomes one. Files are read as latin1, so bytes round-trip.
|
|
12
|
+
|
|
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
|
+
// The directories the resolver never enters, at any depth
|
|
18
|
+
const PRUNED = new Set(['.git', 'build', '.gradle', '.kotlin', '.local-server', 'node_modules']);
|
|
19
|
+
const COMMENTS = { slash: '//', dash: '--', hash: '#' };
|
|
20
|
+
const IMPORT_RE = /^import[ \t]+[A-Za-z0-9_.`]+([ \t]+as[ \t]+[A-Za-z0-9_`]+)?[ \t]*$/;
|
|
21
|
+
const WORD = /[A-Za-z0-9_]/;
|
|
22
|
+
|
|
23
|
+
// awk's trim: spaces and tabs at the start; spaces, tabs and \r at the end
|
|
24
|
+
const trim = (s) => s.replace(/^[ \t]+/, '').replace(/[ \t\r]+$/, '');
|
|
25
|
+
|
|
26
|
+
function formsOf(kind) {
|
|
27
|
+
if (kind === 'xml') return { open: `<!-- cmpose:${TAG} -->`, close: `<!-- /cmpose:${TAG} -->`, swap: '', prefix: '<!--' };
|
|
28
|
+
const c = COMMENTS[kind];
|
|
29
|
+
return { open: `${c} cmpose:${TAG}`, close: `${c} /cmpose:${TAG}`, swap: `${c} cmpose:${TAG}-off: `, prefix: c };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A line that looks like a marker but is none of the known forms
|
|
33
|
+
function malformed(t, prefix) {
|
|
34
|
+
if (!t.startsWith(prefix)) return false;
|
|
35
|
+
return t.slice(prefix.length).replace(/^[ \t]*\/?/, '').startsWith('cmpose:');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Whether the code names <name> as a whole word
|
|
39
|
+
function names(code, name) {
|
|
40
|
+
for (let at = code.indexOf(name); at >= 0; at = code.indexOf(name, at + 1)) {
|
|
41
|
+
const before = at > 0 ? code[at - 1] : '';
|
|
42
|
+
const after = code[at + name.length] || '';
|
|
43
|
+
if (!WORD.test(before) && !WORD.test(after)) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The Kotlin imports (star imports aside) whose simple name or alias the rest of the file no longer names
|
|
49
|
+
function dropUnusedImports(lines) {
|
|
50
|
+
const code = lines.filter((l) => !IMPORT_RE.test(l)).map((l) => `${l}\n`).join('');
|
|
51
|
+
return lines.filter((line) => {
|
|
52
|
+
if (!IMPORT_RE.test(line) || /\.\*[ \t]*$/.test(line)) return true;
|
|
53
|
+
let name = trim(line);
|
|
54
|
+
name = /[ \t]as[ \t]/.test(name) ? name.replace(/^.*[ \t]as[ \t]+/, '') : name.replace(/^.*\./, '');
|
|
55
|
+
return names(code, name.replace(/`/g, ''));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
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);
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
if (content.endsWith('\n')) lines.pop();
|
|
64
|
+
const die = (message, at) => { throw new Error(`${file}:${at}: ${message}`); };
|
|
65
|
+
const out = [];
|
|
66
|
+
let inside = false;
|
|
67
|
+
let opened = 0;
|
|
68
|
+
let dropped = false;
|
|
69
|
+
let joining = false;
|
|
70
|
+
const emit = (s) => {
|
|
71
|
+
if (dropped) { joining = true; dropped = false; }
|
|
72
|
+
if (joining) {
|
|
73
|
+
if (s === '' && out.length && out[out.length - 1] === '') return;
|
|
74
|
+
joining = false;
|
|
75
|
+
}
|
|
76
|
+
out.push(s);
|
|
77
|
+
};
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i];
|
|
80
|
+
const t = trim(line);
|
|
81
|
+
if (t === open) {
|
|
82
|
+
if (inside) die(`nested cmpose:${TAG} block (open since line ${opened})`, i + 1);
|
|
83
|
+
inside = true; opened = i + 1; dropped = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (t === close) {
|
|
87
|
+
if (!inside) die('block close without an open', i + 1);
|
|
88
|
+
inside = false; dropped = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (inside && !enabled) continue;
|
|
92
|
+
if (swap && t.startsWith(swap)) {
|
|
93
|
+
if (i === lines.length - 1) die(`cmpose:${TAG}-off marker on the last line`, i + 1);
|
|
94
|
+
let next = lines[i + 1];
|
|
95
|
+
const nt = trim(next);
|
|
96
|
+
if (nt === open || nt === close || malformed(nt, prefix)) die(`cmpose:${TAG}-off marker before a marker`, i + 1);
|
|
97
|
+
if (!enabled) next = next.match(/^[ \t]*/)[0] + t.slice(swap.length);
|
|
98
|
+
i++;
|
|
99
|
+
emit(next);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (malformed(t, prefix)) die(`malformed cmpose marker: ${t}`, i + 1);
|
|
103
|
+
emit(line);
|
|
104
|
+
}
|
|
105
|
+
if (inside) die(`unclosed cmpose:${TAG} block`, opened);
|
|
106
|
+
const kept = kotlin && !enabled ? dropUnusedImports(out) : out;
|
|
107
|
+
return kept.map((l) => `${l}\n`).join('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Every file below dir outside the pruned directories, as paths relative to dir
|
|
111
|
+
async function listFiles(dir, rel = '', out = []) {
|
|
112
|
+
for (const entry of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
|
|
113
|
+
if (PRUNED.has(entry.name)) continue;
|
|
114
|
+
const r = rel ? path.join(rel, entry.name) : entry.name;
|
|
115
|
+
if (entry.isDirectory()) await listFiles(dir, r, out);
|
|
116
|
+
else if (entry.isFile()) out.push(r);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
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`);
|
|
125
|
+
const paths = [];
|
|
126
|
+
for (const raw of (await fs.readFile(file, 'utf8')).split('\n')) {
|
|
127
|
+
const p = raw.replace(/\r$/, '');
|
|
128
|
+
if (p === '' || p.startsWith('#')) continue;
|
|
129
|
+
const slashed = `/${p}/`;
|
|
130
|
+
if (slashed.startsWith('//') || slashed.includes('/../') || slashed.includes('/./')) {
|
|
131
|
+
throw new Error(`${PATHS_FILE}: '${p}' must be a relative path inside the project`);
|
|
132
|
+
}
|
|
133
|
+
paths.push(p);
|
|
134
|
+
}
|
|
135
|
+
return paths;
|
|
136
|
+
}
|
|
137
|
+
|
|
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) {
|
|
141
|
+
const results = [];
|
|
142
|
+
for (const rel of await listFiles(projectDir)) {
|
|
143
|
+
const kind = kindOf(rel);
|
|
144
|
+
if (!kind) continue;
|
|
145
|
+
const file = path.join(projectDir, rel);
|
|
146
|
+
const content = await fs.readFile(file, 'latin1');
|
|
147
|
+
if (!content.includes('cmpose:')) continue;
|
|
148
|
+
const kotlin = rel.endsWith('.kt') || rel.endsWith('.kts');
|
|
149
|
+
results.push({ file, content: resolveContent(content, kind, enabled, { file: rel, kotlin }) });
|
|
150
|
+
}
|
|
151
|
+
const sharingPaths = enabled ? [] : await readSharingPaths(projectDir);
|
|
152
|
+
// writeFile keeps an existing file's mode
|
|
153
|
+
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 };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { applySharingOption, resolveContent, PRUNED };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const pins = require('../pins');
|
|
5
|
+
const { backendContractVersion } = require('./contract');
|
|
6
|
+
|
|
7
|
+
const RECORD_PATH = path.join('.cmpose', 'generation.json');
|
|
8
|
+
|
|
9
|
+
/** The .cmpose/generation.json content of a generated backend: the options and the template, no personal data
|
|
10
|
+
* (no output dir); generatorVersion names the caller (e.g. mcp@0.4.0), null when not given */
|
|
11
|
+
function buildBackendRecord(value, { templateRef = null, templateCommit = null, generatorVersion = null, now = new Date() } = {}) {
|
|
12
|
+
return {
|
|
13
|
+
generator: '@cmpose/core',
|
|
14
|
+
coreVersion: require('../../package.json').version,
|
|
15
|
+
generatorVersion: generatorVersion || null,
|
|
16
|
+
kind: 'backend',
|
|
17
|
+
contractVersion: backendContractVersion(),
|
|
18
|
+
sharing: value.sharing === true,
|
|
19
|
+
template: { repo: pins.backend.repo, ref: templateRef || null, commit: templateCommit || null },
|
|
20
|
+
generatedAt: now.toISOString()
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function writeBackendRecord(projectDir, value, options = {}) {
|
|
25
|
+
const record = buildBackendRecord(value, options);
|
|
26
|
+
const file = path.join(projectDir, RECORD_PATH);
|
|
27
|
+
await fs.outputFile(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
28
|
+
return record;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { RECORD_PATH, buildBackendRecord, writeBackendRecord };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const PV = require('../ProjectValidator');
|
|
4
|
+
const { BACKEND_SCHEMA: S } = require('./contract');
|
|
5
|
+
|
|
6
|
+
const APP_NAME_RE = new RegExp(S.appName.pattern);
|
|
7
|
+
// templateCache's ref rule: a tag or branch name, never a path
|
|
8
|
+
const REF_RE = /^[A-Za-z0-9_.-]+$/;
|
|
9
|
+
|
|
10
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
11
|
+
const isTemplatePackage = (v) => v === S.identity.package || v.startsWith(S.identity.package + '.');
|
|
12
|
+
|
|
13
|
+
// Why an appName cannot name a server service, a database and its user (null = it can)
|
|
14
|
+
function databaseNameProblem(appName) {
|
|
15
|
+
const dbName = appName.replace(/-/g, '_');
|
|
16
|
+
if (S.appName.infrastructure.includes(appName)) {
|
|
17
|
+
return `appName "${appName}" is a server service name (${S.appName.infrastructure.join(', ')})`;
|
|
18
|
+
}
|
|
19
|
+
if (S.appName.postgresReserved.includes(dbName)) return `appName "${appName}" is a PostgreSQL reserved name (${S.appName.postgresReserved.join(', ')})`;
|
|
20
|
+
if (dbName.startsWith(S.appName.postgresPrefix)) return `appName "${appName}" starts with pg-: PostgreSQL reserves ${S.appName.postgresPrefix} for its own roles`;
|
|
21
|
+
if (S.appName.sqlKeywords.includes(dbName)) return `appName "${appName}" gives the database name ${dbName}, a PostgreSQL reserved key word`;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Every problem as { field, message }; the value holds only the known fields (sharing defaults to false)
|
|
26
|
+
function validateBackend(input) {
|
|
27
|
+
if (!isObject(input)) return { ok: false, errors: [{ field: 'input', message: 'input must be an object' }] };
|
|
28
|
+
const { projectName, appName, packageName, outputDir, sharing, templateRef } = input;
|
|
29
|
+
const errors = [];
|
|
30
|
+
const fail = (field, message) => errors.push({ field, message });
|
|
31
|
+
|
|
32
|
+
if (typeof projectName !== 'string' || !PV.validateProjectName(projectName)) fail('projectName', PV.messages.projectNameInvalid);
|
|
33
|
+
else if (projectName.length > PV.MAX_LENGTH_PROJECT_NAME) fail('projectName', PV.messages.projectNameTooLong);
|
|
34
|
+
|
|
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
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof packageName !== 'string' || !PV.validatePackageName(packageName) || isTemplatePackage(packageName)) {
|
|
45
|
+
fail('packageName', PV.messages.packageNameInvalid);
|
|
46
|
+
} else if (packageName.length > PV.MAX_LENGTH_PACKAGE_NAME) {
|
|
47
|
+
fail('packageName', PV.messages.packageNameTooLong);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof outputDir !== 'string' || !path.isAbsolute(outputDir)) fail('outputDir', 'outputDir must be an absolute path');
|
|
51
|
+
|
|
52
|
+
if (sharing !== undefined && typeof sharing !== 'boolean') fail('sharing', 'sharing must be true or false');
|
|
53
|
+
|
|
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 .)');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (errors.length) return { ok: false, errors };
|
|
59
|
+
const value = { projectName, appName, packageName, outputDir, sharing: sharing === undefined ? S.options.sharing.default : sharing };
|
|
60
|
+
if (templateRef !== undefined) value.templateRef = templateRef;
|
|
61
|
+
return { ok: true, value };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { validateBackend };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { PRUNED } = require('./options');
|
|
5
|
+
const { identityPairs } = require('./identity');
|
|
6
|
+
|
|
7
|
+
// A marker line of any form (// -- # <!--), open, close, swap or malformed
|
|
8
|
+
const MARKER_RE = /^[ \t]*(?:\/\/|--|#|<!--)[ \t]*\/?cmpose:/m;
|
|
9
|
+
// The template gate's sharing words, in the code, docs and deploy parts
|
|
10
|
+
const SHARING_WORDS = /space|invite|member/i;
|
|
11
|
+
const SHARING_TARGETS = ['src', 'SECURITY-CHECKLIST.md', 'deploy', 'README.md'];
|
|
12
|
+
const SECRET_PATTERNS = [
|
|
13
|
+
{ name: 'private key', re: /-----BEGIN .*PRIVATE KEY-----/ },
|
|
14
|
+
{ name: 'service account key', re: /"private_key":/ },
|
|
15
|
+
{ name: 'Google API key', re: /AIza[0-9A-Za-z_-]{35}/ }
|
|
16
|
+
];
|
|
17
|
+
// tools/ scripts may spell the marker forms
|
|
18
|
+
const MARKER_SKIPPED = 'tools';
|
|
19
|
+
// The one template tool a project keeps
|
|
20
|
+
const KEPT_TOOLS = ['tools/deploy-lint.sh'];
|
|
21
|
+
|
|
22
|
+
async function listFiles(dir, rel = '', out = []) {
|
|
23
|
+
for (const entry of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
|
|
24
|
+
if (PRUNED.has(entry.name)) continue;
|
|
25
|
+
const r = rel ? `${rel}/${entry.name}` : entry.name;
|
|
26
|
+
if (entry.isDirectory()) { await listFiles(dir, r, out); continue; }
|
|
27
|
+
if (!entry.isFile()) continue;
|
|
28
|
+
const buffer = await fs.readFile(path.join(dir, r));
|
|
29
|
+
// a binary file (gradle-wrapper.jar) is checked by name only
|
|
30
|
+
out.push({ rel: r, text: buffer.includes(0) ? '' : buffer.toString('utf8') });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
|
|
37
|
+
/** Problems of a generated backend project (empty = clean), sorted */
|
|
38
|
+
async function verifyBackendProject(projectDir, { sharing, packageName, appName, projectName = '' }) {
|
|
39
|
+
const problems = [];
|
|
40
|
+
const pairs = identityPairs({ packageName, appName, projectName });
|
|
41
|
+
// A template token the project's own names spell (a package io.cmpose.backendtemplatex) is not residue
|
|
42
|
+
const tokens = pairs.filter((p) => !p.to.includes(p.from)).map((p) => p.from);
|
|
43
|
+
// The project's own names never count as sharing words (an app named space-notes)
|
|
44
|
+
const ownNames = pairs.map((p) => p.to).filter(Boolean).sort((a, b) => b.length - a.length);
|
|
45
|
+
const ownNamesRe = ownNames.length ? new RegExp(ownNames.map(escapeRe).join('|'), 'g') : null;
|
|
46
|
+
|
|
47
|
+
for (const { rel, text } of await listFiles(projectDir)) {
|
|
48
|
+
for (const token of tokens) {
|
|
49
|
+
if (rel.includes(token)) problems.push(`old identity ${token} in the name ${rel}`);
|
|
50
|
+
if (text.includes(token)) problems.push(`old identity ${token} in ${rel}`);
|
|
51
|
+
}
|
|
52
|
+
if (rel.split('/')[0] !== MARKER_SKIPPED && MARKER_RE.test(text)) problems.push(`marker left in ${rel}`);
|
|
53
|
+
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}/`))) {
|
|
55
|
+
const own = ownNamesRe ? text.replace(ownNamesRe, '') : text;
|
|
56
|
+
if (SHARING_WORDS.test(own)) problems.push(`sharing word in ${rel} (sharing is off)`);
|
|
57
|
+
}
|
|
58
|
+
for (const { name, re } of SECRET_PATTERNS) if (re.test(text)) problems.push(`${name} in ${rel}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const packageDir = path.posix.join('src', 'main', 'kotlin', ...packageName.split('.'));
|
|
62
|
+
if (!(await fs.pathExists(path.join(projectDir, packageDir)))) problems.push(`missing package dir ${packageDir}`);
|
|
63
|
+
for (const rel of [`deploy/docker-compose.${appName}.yml`, `deploy/nginx/${appName}.conf`]) {
|
|
64
|
+
if (!(await fs.pathExists(path.join(projectDir, rel)))) problems.push(`missing ${rel}`);
|
|
65
|
+
}
|
|
66
|
+
return problems.sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { verifyBackendProject };
|