@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 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.
@@ -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
+ };
@@ -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
+ };
package/lib/common.js ADDED
@@ -0,0 +1,13 @@
1
+ const TAB = 2;
2
+
3
+ const PERM_FILE = 0o644;
4
+ const PERM_DIR = 0o755;
5
+
6
+ const PROJECT_DIR = './Projects';
7
+
8
+ export {
9
+ PERM_FILE,
10
+ PERM_DIR,
11
+ PROJECT_DIR,
12
+ TAB,
13
+ };