@mimik/be-project-builder 1.0.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 +94 -0
- package/eslint.config.js +71 -0
- package/index.js +206 -0
- package/lib/buildfile.js +290 -0
- package/lib/common.js +13 -0
- package/lib/configFiles.js +90 -0
- package/lib/helpers.js +311 -0
- package/lib/readFileSync.js +69 -0
- package/lib/scaffold.js +108 -0
- package/lib/writeFileSync.js +70 -0
- package/package.json +37 -0
- package/scaffoldFiles/local/commitMsgCheck.txt +3 -0
- package/scaffoldFiles/local/dotFiles.txt +3 -0
- package/scaffoldFiles/local/jsdoc.json +6 -0
- package/scaffoldFiles/local/scripts.txt +3 -0
- package/scaffoldFiles/local/setup.txt +3 -0
- package/scaffoldFiles/local/start-example.json +37 -0
- package/scaffoldFiles/local/testSetup.txt +3 -0
- package/scaffoldFiles/local/unScripts.txt +3 -0
- package/scaffoldFiles/package.json +51 -0
- package/scaffoldFiles/src/common.txt +16 -0
- package/scaffoldFiles/src/dbValidateHelper.txt +4 -0
- package/scaffoldFiles/src/index.txt +34 -0
- package/scaffoldFiles/src/infoController.txt +34 -0
- package/scaffoldFiles/src/infoProcessor.txt +26 -0
- package/scaffoldFiles/src/mustacheConfig.txt +57 -0
- package/scaffoldFiles/src/mustacheController.txt +20 -0
- package/scaffoldFiles/test/mustacheCommon.txt +44 -0
- package/scaffoldFiles/test/mustacheHttp-test-detached.txt +34 -0
- package/scaffoldFiles/test/mustacheHttp-test-normal.txt +36 -0
- package/scaffoldFiles/test/set-env.txt +7 -0
- package/scaffoldFiles/test/systemEndpoints.txt +87 -0
- package/scaffoldFiles/test/util.txt +36 -0
- package/test.sh +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @mimik/be-project-builder
|
|
2
|
+
|
|
3
|
+
Interactive project scaffolding tool for mimik backend services. It guides you through configuring core service settings, optional logging/location configs, and (optionally) fetching Swagger definitions to generate controller stubs.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js >= 24
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
If using this in another repo, add it as a dependency and run the entrypoint. For local use:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Run with `npx` (no install):
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx @mimik/be-project-builder
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install globally for a `be-project-builder` command:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install -g @mimik/be-project-builder
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
be-project-builder
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You can also run the tool directly with Node:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
node index.js
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
You will be prompted for:
|
|
42
|
+
|
|
43
|
+
- Projects directory (default: `./Projects` under your home directory)
|
|
44
|
+
- Optional location/log/mST/mIT/mID config updates
|
|
45
|
+
- Customer config (from file or Bitbucket)
|
|
46
|
+
- Service details (name, description, type, port, public protocol/domain)
|
|
47
|
+
- Optional Swagger API source (file, Bitbucket, or SwaggerHub)
|
|
48
|
+
|
|
49
|
+
The tool scaffolds a new service directory with `src/`, `local/`, and `test/` folders and writes config files into the chosen Projects directory.
|
|
50
|
+
|
|
51
|
+
## What it generates
|
|
52
|
+
|
|
53
|
+
Typical output structure for a new service:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
<Projects>/<service-name>/
|
|
57
|
+
package.json
|
|
58
|
+
src/
|
|
59
|
+
index.js
|
|
60
|
+
configuration/
|
|
61
|
+
controllers/
|
|
62
|
+
processors/
|
|
63
|
+
models/
|
|
64
|
+
lib/
|
|
65
|
+
api/ # only if an API definition was provided
|
|
66
|
+
local/
|
|
67
|
+
start-example.json
|
|
68
|
+
setup.js
|
|
69
|
+
scripts.js
|
|
70
|
+
unScripts.js
|
|
71
|
+
commitMsgCheck.js
|
|
72
|
+
dotFiles.js
|
|
73
|
+
testSetup.js
|
|
74
|
+
jsdoc.json
|
|
75
|
+
test/
|
|
76
|
+
normal/
|
|
77
|
+
detached/
|
|
78
|
+
src/
|
|
79
|
+
mock/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Notes
|
|
83
|
+
|
|
84
|
+
- API definitions can be loaded from a local file, Bitbucket, or SwaggerHub.
|
|
85
|
+
- When a Swagger definition is supplied, controller stubs are generated based on `x-swagger-router-controller` and `operationId` values.
|
|
86
|
+
- Config prompts write JSON/JSONC/YAML/.env files, preserving JSON comments where applicable.
|
|
87
|
+
|
|
88
|
+
## Troubleshooting
|
|
89
|
+
|
|
90
|
+
If you see errors fetching APIs or customer config, verify your Bitbucket/SwaggerHub credentials in the prompted `key.json` file and ensure network access.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
No license file found in this repository.
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import globals from 'globals';
|
|
2
|
+
import importPlugin from 'eslint-plugin-import';
|
|
3
|
+
import js from '@eslint/js';
|
|
4
|
+
import processDoc from '@mimik/eslint-plugin-document-env';
|
|
5
|
+
import stylistic from '@stylistic/eslint-plugin';
|
|
6
|
+
|
|
7
|
+
const MAX_LENGTH_LINE = 180;
|
|
8
|
+
const MAX_FUNCTION_PARAMETERS = 6;
|
|
9
|
+
const MAX_LINES_IN_FILES = 600;
|
|
10
|
+
const MAX_LINES_IN_FUNCTION = 150;
|
|
11
|
+
const MAX_STATEMENTS_IN_FUNCTION = 47;
|
|
12
|
+
const MIN_KEYS_IN_OBJECT = 10;
|
|
13
|
+
const MAX_COMPLEXITY = 40;
|
|
14
|
+
const ECMA_VERSION = 'latest';
|
|
15
|
+
const MAX_DEPTH = 6;
|
|
16
|
+
const ALLOWED_CONSTANTS = [0, 1, -1];
|
|
17
|
+
|
|
18
|
+
export default [
|
|
19
|
+
{
|
|
20
|
+
ignores: ['mochawesome-report/**', 'node_modules/**', 'dist/**'],
|
|
21
|
+
},
|
|
22
|
+
importPlugin.flatConfigs.recommended,
|
|
23
|
+
stylistic.configs.recommended,
|
|
24
|
+
js.configs.all,
|
|
25
|
+
{
|
|
26
|
+
plugins: {
|
|
27
|
+
processDoc,
|
|
28
|
+
},
|
|
29
|
+
languageOptions: {
|
|
30
|
+
ecmaVersion: ECMA_VERSION,
|
|
31
|
+
globals: {
|
|
32
|
+
...globals.nodeBuiltin,
|
|
33
|
+
console: 'readonly',
|
|
34
|
+
describe: 'readonly',
|
|
35
|
+
it: 'readonly',
|
|
36
|
+
require: 'readonly',
|
|
37
|
+
},
|
|
38
|
+
sourceType: 'module',
|
|
39
|
+
},
|
|
40
|
+
rules: {
|
|
41
|
+
'@stylistic/brace-style': ['warn', 'stroustrup', { allowSingleLine: true }],
|
|
42
|
+
'@stylistic/line-comment-position': ['off'],
|
|
43
|
+
'@stylistic/max-len': ['warn', MAX_LENGTH_LINE, { ignoreComments: true, ignoreStrings: true, ignoreRegExpLiterals: true }],
|
|
44
|
+
'@stylistic/semi': ['error', 'always'],
|
|
45
|
+
'capitalized-comments': ['off'],
|
|
46
|
+
'complexity': ['error', MAX_COMPLEXITY],
|
|
47
|
+
'curly': ['off'],
|
|
48
|
+
'id-length': ['error', { exceptions: ['x', 'y', 'z', 'i', 'j', 'k'] }],
|
|
49
|
+
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
|
|
50
|
+
'import/no-unresolved': ['error', { amd: true, caseSensitiveStrict: true, commonjs: true }],
|
|
51
|
+
'init-declarations': ['off'],
|
|
52
|
+
'linebreak-style': ['off'],
|
|
53
|
+
'max-depth': ['error', MAX_DEPTH],
|
|
54
|
+
'max-lines': ['warn', { max: MAX_LINES_IN_FILES, skipComments: true, skipBlankLines: true }],
|
|
55
|
+
'max-lines-per-function': ['warn', { max: MAX_LINES_IN_FUNCTION, skipComments: true, skipBlankLines: true }],
|
|
56
|
+
'max-params': ['error', MAX_FUNCTION_PARAMETERS],
|
|
57
|
+
'max-statements': ['warn', MAX_STATEMENTS_IN_FUNCTION],
|
|
58
|
+
'no-confusing-arrow': ['off'],
|
|
59
|
+
'no-inline-comments': ['off'],
|
|
60
|
+
'no-magic-numbers': ['error', { ignore: ALLOWED_CONSTANTS, enforceConst: true, detectObjects: true }],
|
|
61
|
+
'no-process-env': ['error'],
|
|
62
|
+
'no-ternary': ['off'],
|
|
63
|
+
'no-undefined': ['off'],
|
|
64
|
+
'one-var': ['error', 'never'],
|
|
65
|
+
'processDoc/validate-document-env': ['error'],
|
|
66
|
+
'quotes': ['warn', 'single'],
|
|
67
|
+
'sort-imports': ['error', { allowSeparatedGroups: true }],
|
|
68
|
+
'sort-keys': ['error', 'asc', { caseSensitive: true, minKeys: MIN_KEYS_IN_OBJECT, natural: false, allowLineSeparatedGroups: true }],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|
package/index.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint no-console: "off" */
|
|
3
|
+
import { PROJECT_DIR, TAB } from './lib/common.js';
|
|
4
|
+
import { createAPI, createCustomerConfig, createNewService, dirExists, getControllers } from './lib/helpers.js';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { parse, stringify } from 'comment-json';
|
|
7
|
+
import Mustache from 'mustache';
|
|
8
|
+
import color from 'ansi-colors';
|
|
9
|
+
import configFiles from './lib/configFiles.js';
|
|
10
|
+
import { configureFile } from './lib/buildfile.js';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { getFileByNameSync } from './lib/readFileSync.js';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import prompts from 'prompts';
|
|
15
|
+
import { scaffold } from './lib/scaffold.js';
|
|
16
|
+
import { writeFileByNameSync } from './lib/writeFileSync.js';
|
|
17
|
+
|
|
18
|
+
const { render } = Mustache;
|
|
19
|
+
|
|
20
|
+
const localDirectory = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const scaffoldDirectory = join(localDirectory, './scaffoldFiles');
|
|
22
|
+
const homeDirectory = homedir();
|
|
23
|
+
const prompt = options => prompts(options, {
|
|
24
|
+
onCancel: () => {
|
|
25
|
+
console.log(`${color.bold.green('✔')} ${color.bold.green('Canceled')}`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const { directory } = await prompt({ type: 'text', name: 'directory', message: 'Projects directory', initial: PROJECT_DIR });
|
|
30
|
+
const projectDirectory = resolve(homeDirectory, directory);
|
|
31
|
+
|
|
32
|
+
let customerConfig;
|
|
33
|
+
|
|
34
|
+
const pack = getFileByNameSync(scaffoldDirectory, 'package.json');
|
|
35
|
+
const startExample = getFileByNameSync(scaffoldDirectory, 'local/start-example.json', { jsonParse: parse });
|
|
36
|
+
const template = getFileByNameSync(scaffoldDirectory, 'src/mustacheController.txt');
|
|
37
|
+
|
|
38
|
+
const { changeLoc } = await prompt({ type: 'confirm', name: 'changeLoc', message: 'Change location config?' });
|
|
39
|
+
|
|
40
|
+
if (changeLoc) {
|
|
41
|
+
await configureFile(join(projectDirectory, 'locationConfig.json'), configFiles.location, { nonInteractive: false });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { changeLogs } = await prompt({ type: 'confirm', name: 'changeLogs', message: 'Change log config?' });
|
|
45
|
+
|
|
46
|
+
if (changeLogs) {
|
|
47
|
+
await configureFile(join(projectDirectory, 's3Log.json'), configFiles.s3Log, { nonInteractive: false });
|
|
48
|
+
await configureFile(join(projectDirectory, 'kinesisLog.json'), configFiles.kinesisLog, { nonInteractive: false, jsonParse: parse });
|
|
49
|
+
await configureFile(join(projectDirectory, 'sumoLog.json'), configFiles.sumoLog, { nonInteractive: false, jsonParse: parse });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { changeFile } = await prompt({ type: 'confirm', name: 'changeFile', message: 'Change mST, mIT, mID config?' });
|
|
53
|
+
|
|
54
|
+
if (changeFile) {
|
|
55
|
+
await configureFile(join(projectDirectory, 'mSTConfig.json'), configFiles.mST, { nonInteractive: false, jsonParse: parse });
|
|
56
|
+
await configureFile(join(projectDirectory, 'mITConfig.json'), configFiles.mIT, { nonInteractive: false, jsonParse: parse });
|
|
57
|
+
await configureFile(join(projectDirectory, 'mIDConfig.json'), configFiles.mID, { nonInteractive: false, jsonParse: parse });
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
customerConfig = await createCustomerConfig(directory, homeDirectory, projectDirectory);
|
|
61
|
+
|
|
62
|
+
if (customerConfig.configContent) {
|
|
63
|
+
writeFileByNameSync(projectDirectory, 'customerConfig.json', customerConfig.configContent);
|
|
64
|
+
console.log(`${color.bold.green('✔')} ${color.bold('Customer:')} ${color.bold.green(customerConfig.configContent.name)}${color.bold(', version:')} ${color.bold.green(customerConfig.configContent.version)}`);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) { console.log(`${color.bold.red('✖')} ${color.red(err.message)} - ${color.bold.red('no customer configuration: skipping')}`); };
|
|
68
|
+
|
|
69
|
+
if (!await dirExists(projectDirectory)) {
|
|
70
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red('Project directory')} ${color.bold.red(projectDirectory)} ${color.bold.red('must be configured')}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const service = await createNewService(directory, homeDirectory, projectDirectory);
|
|
75
|
+
|
|
76
|
+
if (!service) {
|
|
77
|
+
console.log(`${color.bold.green('✔')} ${color.bold.green('No service created')}`);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (service.newService) {
|
|
82
|
+
service.version = pack.version;
|
|
83
|
+
let API;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
API = await createAPI(homeDirectory, projectDirectory, service);
|
|
87
|
+
console.log(`${color.bold.green('✔')} ${color.bold('API:')} ${color.bold.green(API.name)}${color.bold(', version:')} ${color.bold.green(API.version)}`);
|
|
88
|
+
}
|
|
89
|
+
catch (err) { console.log(`${color.bold.red('✖')} ${color.red(err.message)} - ${color.bold.red('no API: skipping')}`); };
|
|
90
|
+
|
|
91
|
+
pack.name = service.name;
|
|
92
|
+
pack.description = service.description;
|
|
93
|
+
pack.mimik = {
|
|
94
|
+
type: service.type,
|
|
95
|
+
};
|
|
96
|
+
if (API) {
|
|
97
|
+
pack.swaggerFile = {
|
|
98
|
+
name: API.name,
|
|
99
|
+
version: API.version,
|
|
100
|
+
account: API.account,
|
|
101
|
+
provider: API.provider,
|
|
102
|
+
};
|
|
103
|
+
service.APIVersion = API.version;
|
|
104
|
+
};
|
|
105
|
+
startExample.SERVER_PORT = service.port;
|
|
106
|
+
startExample.SERVER_PUBLIC_PROTOCOL = service.protocol;
|
|
107
|
+
startExample.SERVER_PUBLIC_DOMAIN_NAME = service.domainName;
|
|
108
|
+
const srcDirectory = {
|
|
109
|
+
dir: 'src',
|
|
110
|
+
files: [
|
|
111
|
+
{ name: 'index.js', content: getFileByNameSync(scaffoldDirectory, 'src/index.txt') },
|
|
112
|
+
],
|
|
113
|
+
dirs: [
|
|
114
|
+
{ dir: 'configuration',
|
|
115
|
+
files: [{ name: 'config.js', content: render(getFileByNameSync(scaffoldDirectory, 'src/mustacheConfig.txt'), { serviceType: service.type }) }],
|
|
116
|
+
},
|
|
117
|
+
{ dir: 'controllers',
|
|
118
|
+
files: [{ name: 'infoController.js', content: getFileByNameSync(scaffoldDirectory, 'src/infoController.txt') }],
|
|
119
|
+
},
|
|
120
|
+
{ dir: 'processors',
|
|
121
|
+
files: [{ name: 'infoProcessor.js', content: getFileByNameSync(scaffoldDirectory, 'src/infoProcessor.txt') }],
|
|
122
|
+
},
|
|
123
|
+
{ dir: 'models' },
|
|
124
|
+
{ dir: 'lib',
|
|
125
|
+
files: [{ name: 'common.js', content: getFileByNameSync(scaffoldDirectory, 'src/common.txt') }],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
const mainDirectory = [
|
|
130
|
+
{ dir: '.', files: [{ name: 'package.json', content: JSON.stringify(pack, null, TAB) }] },
|
|
131
|
+
{ dir: 'local',
|
|
132
|
+
files: [
|
|
133
|
+
{ name: 'unScripts.js', content: getFileByNameSync(scaffoldDirectory, 'local/unScripts.txt') },
|
|
134
|
+
{ name: 'scripts.js', content: getFileByNameSync(scaffoldDirectory, 'local/scripts.txt') },
|
|
135
|
+
{ name: 'setup.js', content: getFileByNameSync(scaffoldDirectory, 'local/setup.txt') },
|
|
136
|
+
{ name: 'testSetup.js', content: getFileByNameSync(scaffoldDirectory, 'local/testSetup.txt') },
|
|
137
|
+
{ name: 'commitMsgCheck.js', content: getFileByNameSync(scaffoldDirectory, 'local/commitMsgCheck.txt') },
|
|
138
|
+
{ name: 'dotFiles.js', content: getFileByNameSync(scaffoldDirectory, 'local/dotFiles.txt') },
|
|
139
|
+
{ name: 'jsdoc.json', content: JSON.stringify(getFileByNameSync(scaffoldDirectory, 'local/jsdoc.json'), null, TAB) },
|
|
140
|
+
{ name: 'start-example.json', content: stringify(startExample, null, TAB) },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{ dir: 'test',
|
|
144
|
+
dirs: [
|
|
145
|
+
{ dir: 'normal',
|
|
146
|
+
files: [{ name: 'http-test.js', content: render(getFileByNameSync(scaffoldDirectory, 'test/mustacheHttp-test-normal.txt'), { SERVICE_TYPE: service.type.toUpperCase() }) }],
|
|
147
|
+
},
|
|
148
|
+
{ dir: 'detached',
|
|
149
|
+
files: [{ name: 'http-test.js', content: render(getFileByNameSync(scaffoldDirectory, 'test/mustacheHttp-test-detached.txt'), { SERVICE_TYPE: service.type.toUpperCase() }) }],
|
|
150
|
+
},
|
|
151
|
+
{ dir: 'src',
|
|
152
|
+
files: [
|
|
153
|
+
{ name: 'systemEndpoints.js', content: render(getFileByNameSync(scaffoldDirectory, 'test/systemEndpoints.txt'), { SERVICE_TYPE: service.type.toUpperCase() }) },
|
|
154
|
+
{ name: 'common.js', content: render(getFileByNameSync(scaffoldDirectory, 'test/mustacheCommon.txt'), { SERVICE_TYPE: service.type.toUpperCase(), serviceType: service.type }) },
|
|
155
|
+
{ name: 'set-env.js', content: getFileByNameSync(scaffoldDirectory, 'test/set-env.txt') },
|
|
156
|
+
{ name: 'util.js', content: getFileByNameSync(scaffoldDirectory, 'test/util.txt') },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{ dir: 'mock' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
if (API?.exist) {
|
|
165
|
+
mainDirectory.push(
|
|
166
|
+
{ dir: 'api',
|
|
167
|
+
files: [{ name: API.destination, content: JSON.stringify(API.content, null, TAB) }],
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
const controllerContents = getControllers(API.content, template);
|
|
171
|
+
|
|
172
|
+
Object.keys(controllerContents).forEach((name) => {
|
|
173
|
+
if (name !== 'infoController.js') {
|
|
174
|
+
srcDirectory.dirs[srcDirectory.dirs.findIndex(dirsItem => dirsItem.dir === 'controllers')].files.push({ name, content: controllerContents[name] });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
mainDirectory.push(srcDirectory);
|
|
179
|
+
try {
|
|
180
|
+
await scaffold(join(projectDirectory, service.name), mainDirectory);
|
|
181
|
+
console.log(`${color.bold.green('✔')} ${color.bold.green(service.name)} ${color.bold.green('service created')}`);
|
|
182
|
+
console.log(`${color.bold.green('✔')} ${color.bold.green(' description:')} ${color.bold.green(service.description)}`);
|
|
183
|
+
console.log(`${color.bold.green('✔')} ${color.bold.green(' type:')} ${color.bold.green(service.type)}`);
|
|
184
|
+
console.log(`${color.bold.green('✔')} ${color.bold.green(' version:')} ${color.bold.green(service.version)}`);
|
|
185
|
+
if (service.APIVersion) {
|
|
186
|
+
console.log(`${color.bold.green('✔')} ${color.bold.green(' API version:')} ${color.bold.green(service.APIVersion)}`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red(' no API defined')}`);
|
|
190
|
+
};
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
catch (err) { console.log(`${color.bold.red('✖')} ${color.bold.red(err.message)} - ${color.bold.red('skipping')}`); };
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red(service.name)} ${color.bold.red('already exists')} - ${color.bold.red('skipping')}`);
|
|
197
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red(' description:')} ${color.bold.red(service.description)}`);
|
|
198
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red(' type:')} ${color.bold.red(service.type)}`);
|
|
199
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red(' version:')} ${color.bold.red(service.version)}`);
|
|
200
|
+
if (service.APIVersion) {
|
|
201
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red(' API version:')} ${color.bold.red(service.APIVersion)}`);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
console.log(`${color.bold.red('✖')} ${color.bold.red(' no API defined')}`);
|
|
205
|
+
};
|
|
206
|
+
};
|
package/lib/buildfile.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/* eslint-disable no-process-env, processDoc/validate-document-env */
|
|
2
|
+
import * as JSONC from 'comment-json';
|
|
3
|
+
import * as yaml from 'yaml';
|
|
4
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, extname, resolve } from 'node:path';
|
|
6
|
+
import { TAB } from './common.js';
|
|
7
|
+
import dotenv from 'dotenv';
|
|
8
|
+
import prompts from 'prompts';
|
|
9
|
+
|
|
10
|
+
const detectFormat = (filePath) => {
|
|
11
|
+
const ext = extname(filePath).toLowerCase();
|
|
12
|
+
if (ext === '.json' || ext === '.jsonc') return 'json';
|
|
13
|
+
if (ext === '.yaml' || ext === '.yml') return 'yaml';
|
|
14
|
+
return 'env';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const fileExists = async (part) => {
|
|
18
|
+
try {
|
|
19
|
+
await access(part);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch { return false; }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const parseByFormat = (text, format, options) => {
|
|
26
|
+
if (!text) return {};
|
|
27
|
+
if (format === 'json') {
|
|
28
|
+
if (options.preserveComments) {
|
|
29
|
+
return JSONC.parse(text);
|
|
30
|
+
}
|
|
31
|
+
return options.jsonParse ? options.jsonParse(text) : JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
if (format === 'yaml') return yaml.parse(text) ?? {};
|
|
34
|
+
return dotenv.parse(text);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const quoteEnv = (str) => {
|
|
38
|
+
if (/^[A-Za-z0-9_./:@-]+$/u.test(str)) return str;
|
|
39
|
+
return `"${str.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"').replace(/\n/gu, '\\n')}"`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const serializeEnv = (flatObj) => {
|
|
43
|
+
const lines = [];
|
|
44
|
+
const keys = Object.keys(flatObj).sort();
|
|
45
|
+
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
const val = flatObj[key];
|
|
48
|
+
lines.push(`${key}=${quoteEnv(String(val ?? ''))}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push('');
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const stringifyByFormat = (obj, format, options = {}) => {
|
|
55
|
+
if (format === 'json') {
|
|
56
|
+
if (options.preserveComments) {
|
|
57
|
+
return `${JSONC.stringify(obj, null, TAB)}\n`;
|
|
58
|
+
}
|
|
59
|
+
return `${JSON.stringify(obj, null, TAB)}\n`;
|
|
60
|
+
}
|
|
61
|
+
if (format === 'yaml') return yaml.stringify(obj);
|
|
62
|
+
return serializeEnv(obj);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const deepGet = (obj, key) => {
|
|
66
|
+
if (!obj || !key) return undefined;
|
|
67
|
+
const parts = key.split('.');
|
|
68
|
+
let cur = obj;
|
|
69
|
+
for (const part of parts) {
|
|
70
|
+
if (cur === null || typeof cur !== 'object') return undefined;
|
|
71
|
+
cur = cur[part];
|
|
72
|
+
}
|
|
73
|
+
return cur;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const deepSet = (obj, key, value) => {
|
|
77
|
+
const parts = key.split('.');
|
|
78
|
+
let cur = obj;
|
|
79
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
80
|
+
const part = parts[i];
|
|
81
|
+
if (typeof cur[part] !== 'object' || cur[part] === null) cur[part] = {};
|
|
82
|
+
cur = cur[part];
|
|
83
|
+
}
|
|
84
|
+
cur[parts[parts.length - 1]] = value;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const getByPath = (obj, key, format) => {
|
|
88
|
+
if (format === 'env') return obj?.[key];
|
|
89
|
+
return deepGet(obj, key);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const setByPath = (obj, key, value, format) => {
|
|
93
|
+
if (format === 'env') {
|
|
94
|
+
obj[key] = value ?? '';
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
deepSet(obj, key, value);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Manage comments tracking previous values:
|
|
102
|
+
* - Add old value as comment (no duplicates)
|
|
103
|
+
* - Remove any comment that matches the new value
|
|
104
|
+
*/
|
|
105
|
+
const managePreviousValueComments = (obj, key, oldValue, newValue) => {
|
|
106
|
+
const parts = key.split('.');
|
|
107
|
+
let cur = obj;
|
|
108
|
+
|
|
109
|
+
// Navigate to parent object
|
|
110
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
111
|
+
const part = parts[i];
|
|
112
|
+
if (typeof cur[part] !== 'object' || cur[part] === null) return;
|
|
113
|
+
cur = cur[part];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const prop = parts[parts.length - 1];
|
|
117
|
+
const oldCommentText = `${prop}: ${JSON.stringify(oldValue)}`;
|
|
118
|
+
const newCommentText = `${prop}: ${JSON.stringify(newValue)}`;
|
|
119
|
+
|
|
120
|
+
// Get existing before comments for this property
|
|
121
|
+
const symbolKey = Symbol.for(`before:${prop}`);
|
|
122
|
+
const existingComments = cur[symbolKey] || [];
|
|
123
|
+
|
|
124
|
+
// Filter and process comments
|
|
125
|
+
const updatedComments = existingComments.filter((comm) => {
|
|
126
|
+
if (comm.type === 'LineComment' || comm.type === 'BlockComment') {
|
|
127
|
+
const trimmed = comm.value.trim();
|
|
128
|
+
// Remove comment if it matches the new value being set
|
|
129
|
+
if (trimmed === newCommentText) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Check if old value already exists in comments
|
|
137
|
+
const oldValueExists = updatedComments.some((comment) => {
|
|
138
|
+
if (comment.type === 'LineComment' || comment.type === 'BlockComment') {
|
|
139
|
+
return comment.value.trim() === oldCommentText;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Add old value as comment if not already present and not same as new value
|
|
145
|
+
if (!oldValueExists && oldValue !== newValue) {
|
|
146
|
+
const newComment = { type: 'LineComment', value: ` ${oldCommentText}` };
|
|
147
|
+
updatedComments.push(newComment);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
cur[symbolKey] = updatedComments;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const mapType = (type) => {
|
|
154
|
+
switch (type) {
|
|
155
|
+
case 'text':
|
|
156
|
+
case 'password':
|
|
157
|
+
case 'invisible':
|
|
158
|
+
case 'number':
|
|
159
|
+
case 'confirm':
|
|
160
|
+
return type;
|
|
161
|
+
default:
|
|
162
|
+
return 'text';
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const basicValidator = (type) => {
|
|
167
|
+
if (type === 'number') {
|
|
168
|
+
return val => (val === '' || val === undefined || Number.isFinite(Number(val))) ? true : 'Enter a number';
|
|
169
|
+
}
|
|
170
|
+
return () => true;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const pickKeys = (obj, keys, format) => {
|
|
174
|
+
const out = {};
|
|
175
|
+
|
|
176
|
+
for (const k of keys) out[k] = format === 'env' ? obj[k] : deepGet(obj, k);
|
|
177
|
+
return out;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const clone = val => JSON.parse(JSON.stringify(val ?? {}));
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Configure a file by prompting for values with defaults from:
|
|
184
|
+
* 1) existing file content (if any)
|
|
185
|
+
* 2) environment variables (mapped per item)
|
|
186
|
+
*
|
|
187
|
+
* @param {string} filePath - Absolute or relative path to the config file.
|
|
188
|
+
* Supports .json/.jsonc (with comments), .yaml/.yml, and .env
|
|
189
|
+
* @param {Array<{
|
|
190
|
+
* key: string, // config key; dot.path supported for JSON/YAML, flat for .env
|
|
191
|
+
* env?: string, // environment variable name to use as fallback default
|
|
192
|
+
* message?: string, // prompt label (defaults to key)
|
|
193
|
+
* type?: "text"|"password"|"number"|"confirm", // default: "text"
|
|
194
|
+
* validate?: (value)=>true|string, // optional extra validation
|
|
195
|
+
* format?: (value)=>true|string // optional format
|
|
196
|
+
* initial?: any // optional hard default if neither file nor env present
|
|
197
|
+
* }>} items
|
|
198
|
+
* @param {object} [options]
|
|
199
|
+
* @param {boolean} [options.nonInteractive=false] - If true, writes defaults without prompting
|
|
200
|
+
* @param {boolean} [options.createDirs=true] - Create parent dirs if missing
|
|
201
|
+
* @param {boolean} [options.preserveComments=true] - Preserve comments in JSON/JSONC files
|
|
202
|
+
* @param {boolean} [options.trackChanges=true] - Add comments for previous values when modified
|
|
203
|
+
* @param {function} [options.jsonParse=null] - Custom JSON parser (ignored if preserveComments is true)
|
|
204
|
+
* @returns {Promise<{written: boolean, format: "json"|"yaml"|"env", values: Record<string,any>}>}
|
|
205
|
+
*/
|
|
206
|
+
export const configureFile = async (filePath, items, options = {}) => {
|
|
207
|
+
const {
|
|
208
|
+
nonInteractive = false,
|
|
209
|
+
createDirs = true,
|
|
210
|
+
preserveComments = true,
|
|
211
|
+
trackChanges = true,
|
|
212
|
+
jsonParse = null,
|
|
213
|
+
} = options;
|
|
214
|
+
const abs = resolve(filePath);
|
|
215
|
+
const format = detectFormat(abs);
|
|
216
|
+
|
|
217
|
+
// 1) Read existing content (if any)
|
|
218
|
+
const exists = await fileExists(abs);
|
|
219
|
+
const fileData = exists ? await readFile(abs, 'utf8') : null;
|
|
220
|
+
const current = exists ? parseByFormat(fileData, format, { preserveComments, jsonParse }) : {};
|
|
221
|
+
|
|
222
|
+
// 2) Build prompt models with defaults: file > env > initial
|
|
223
|
+
const questions = [];
|
|
224
|
+
for (const item of items) {
|
|
225
|
+
const { key, env, message, type = 'text', validate, initial } = item;
|
|
226
|
+
const formatVal = item.format;
|
|
227
|
+
|
|
228
|
+
const fileVal = getByPath(current, key, format);
|
|
229
|
+
const envVal = env ? process.env[env] : undefined;
|
|
230
|
+
const defVal = fileVal ?? envVal ?? initial;
|
|
231
|
+
|
|
232
|
+
const question = {
|
|
233
|
+
name: key,
|
|
234
|
+
type: nonInteractive ? null : mapType(type),
|
|
235
|
+
message: message ?? key,
|
|
236
|
+
initial: type === 'number' && typeof defVal === 'string' ? Number(defVal) : defVal,
|
|
237
|
+
validate: validate ? validate : basicValidator(type),
|
|
238
|
+
format: formatVal,
|
|
239
|
+
};
|
|
240
|
+
if (!formatVal) delete question.format;
|
|
241
|
+
questions.push(question);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 3) Get answers (or synthesize from defaults if noninteractive)
|
|
245
|
+
let answers = {};
|
|
246
|
+
const onCancel = () => process.exit(1);
|
|
247
|
+
|
|
248
|
+
if (nonInteractive) {
|
|
249
|
+
for (const question of questions) {
|
|
250
|
+
answers[question.name] = question.initial;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const resp = await prompts(questions, { onCancel });
|
|
255
|
+
answers = resp || {};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Merge answers onto current (preserve other keys and comments)
|
|
259
|
+
const merged = preserveComments && format === 'json' ? current : clone(current);
|
|
260
|
+
for (const item of items) {
|
|
261
|
+
const { key, type } = item;
|
|
262
|
+
let val = answers[key];
|
|
263
|
+
|
|
264
|
+
if (typeof val !== 'undefined') {
|
|
265
|
+
if (type === 'number' && typeof val === 'string' && val.trim() !== '') {
|
|
266
|
+
const num = Number(val);
|
|
267
|
+
if (!Number.isNaN(num)) val = num;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Track previous value as comment if it changed
|
|
271
|
+
if (trackChanges && format === 'json' && preserveComments) {
|
|
272
|
+
const oldVal = getByPath(merged, key, format);
|
|
273
|
+
if (oldVal !== undefined && oldVal !== val) {
|
|
274
|
+
managePreviousValueComments(merged, key, oldVal, val);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
setByPath(merged, key, val, format);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 4) Write back to disk
|
|
283
|
+
if (createDirs) {
|
|
284
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
285
|
+
}
|
|
286
|
+
const out = stringifyByFormat(merged, format, { preserveComments });
|
|
287
|
+
await writeFile(abs, out, 'utf8');
|
|
288
|
+
|
|
289
|
+
return { written: true, format, values: pickKeys(merged, items.map(i => i.key), format) };
|
|
290
|
+
};
|