enable-generate-constants 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Doty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # enable-generate-constants
2
+
3
+ CLI tool to query a MongoDB collection and generate constant/permission JSON files
4
+ (e.g., `_permissions.json` or custom target) in specified target package directories.
5
+
6
+ Output files are written into `<targetFolder>/src/<filename>` if a `src/` folder
7
+ exists, otherwise `<targetFolder>/<filename>`.
8
+
9
+ ## Usage
10
+
11
+ ```sh
12
+ npx enable-generate-constants --collection <collection> --folders <folders> [options]
13
+ ```
14
+
15
+ ## Options
16
+
17
+ | Flag | Description |
18
+ |------|-------------|
19
+ | `-f, --find <query>` | Optional MongoDB query as JSON or `field=val` pairs (default: `{}`) |
20
+ | `-r, --repoRoot <path>` | Repository root containing `.env` (default: current directory) |
21
+ | `-o, --folders <paths>` | Comma-separated list of folders to write output to (**required**) |
22
+ | `-c, --collection <name>` | MongoDB collection containing codes (**required**) |
23
+ | `-n, --name <field>` | Field name to extract (default: `code`) |
24
+ | `-F, --file <filename>` | Output JSON filename (default: `_permissions.json`) |
25
+ | `-d, --database <name>` | Database name override (defaults to `MONGODB_DATABASE` in `.env`) |
26
+ | `-u, --connectionString <uri>` | MongoDB connection URI override (defaults to `.env`) |
27
+ | `--dry-run` | Query database and display results without writing files |
28
+ | `-h, --help` | Show usage help |
29
+
30
+ ## Examples
31
+
32
+ ```sh
33
+ # Generate permissions for UX packages from feature collection
34
+ npx enable-generate-constants --find "{}" --collection feature --folders ux/saas,ux/portal
35
+
36
+ # Generate constants with a custom output file and field name
37
+ npx enable-generate-constants --collection roles --name roleKey --file _roles.json --folders packages/auth
38
+
39
+ # Preview output with --dry-run
40
+ npx enable-generate-constants --collection feature --folders ux/saas --dry-run
41
+ ```
package/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from './src/cli.mjs';
4
+
5
+ await runCli();
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "enable-generate-constants",
3
+ "version": "1.0.1",
4
+ "description": "CLI to query MongoDB and generate constant/permission JSON files.",
5
+ "type": "module",
6
+ "module": "./index.mjs",
7
+ "bin": {
8
+ "enable-generate-constants": "./index.mjs"
9
+ },
10
+ "license": "MIT",
11
+ "author": "Contributors",
12
+ "dependencies": {
13
+ "dotenv": "^16.6.1",
14
+ "mongodb": "^6.21.0"
15
+ },
16
+ "scripts": {
17
+ "start": "node index.mjs",
18
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest tests/*.test.mjs"
19
+ }
20
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,12 @@
1
+ import { generateConstants } from './lib.mjs';
2
+ import { parseCliArgs } from './options.mjs';
3
+
4
+ export const runCli = async (argv = process.argv, log = console.log, exit = process.exit) => {
5
+ try {
6
+ const options = parseCliArgs(argv, exit, log);
7
+ await generateConstants(options, log);
8
+ } catch (e) {
9
+ log(`❌ Error: ${e.message}`);
10
+ exit(1);
11
+ }
12
+ };
package/src/lib.mjs ADDED
@@ -0,0 +1,103 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { MongoClient } from 'mongodb';
5
+ import dotenv from 'dotenv';
6
+ import { parseQuery } from './options.mjs';
7
+
8
+ // Format code strings into uppercase constant keys (e.g. "user.read" -> "USER_READ")
9
+ export function toConstantKey(codeValue) {
10
+ return codeValue
11
+ .trim()
12
+ .replace(/[^a-zA-Z0-9]+/g, '_')
13
+ .replace(/^_+|_+$/g, '')
14
+ .toUpperCase();
15
+ }
16
+
17
+ // Prefer writing inside target's src/ folder if it exists
18
+ export function resolveOutputDir(targetDir) {
19
+ const srcDir = join(targetDir, 'src');
20
+ return existsSync(srcDir) && statSync(srcDir).isDirectory() ? srcDir : targetDir;
21
+ }
22
+
23
+ // Convert docs list to sorted constant key-value map
24
+ export function mapDocsToConstants(docs, nameField = 'code') {
25
+ const constants = {};
26
+ for (const doc of docs) {
27
+ const val = doc[nameField];
28
+ if (typeof val !== 'string' || val.trim() === '') continue;
29
+ constants[toConstantKey(val)] = val;
30
+ }
31
+ return Object.fromEntries(
32
+ Object.entries(constants).sort(([a], [b]) => a.localeCompare(b))
33
+ );
34
+ }
35
+
36
+ export async function generateConstants(options, log = console.log, clientOverride = null) {
37
+ const envPath = join(options.repoRoot, '.env');
38
+
39
+ if (existsSync(envPath)) {
40
+ dotenv.config({ path: envPath });
41
+ }
42
+
43
+ const databaseName =
44
+ options.database ||
45
+ process.env.MONGODB_DATABASE;
46
+
47
+ if (!databaseName) {
48
+ throw new Error(
49
+ 'Database name not found. Provide --database or set MONGODB_DATABASE in .env.'
50
+ );
51
+ }
52
+
53
+ const envName = databaseName.toUpperCase().replace(/-/g, '_');
54
+ const connectionString =
55
+ options.connectionString ||
56
+ process.env[`${envName}_CONNECTIONSTRING`] ||
57
+ process.env.MONGODB_URI ||
58
+ process.env.MONGO_URI ||
59
+ process.env.CONNECTIONSTRING;
60
+
61
+ if (!connectionString) {
62
+ throw new Error(
63
+ `Connection string not found. Provide --connectionString or set ${envName}_CONNECTIONSTRING / MONGODB_URI in .env.`
64
+ );
65
+ }
66
+
67
+ const query = parseQuery(options.query);
68
+ const client = clientOverride || new MongoClient(connectionString);
69
+
70
+ try {
71
+ if (!clientOverride) {
72
+ await client.connect();
73
+ }
74
+ const docs = await client
75
+ .db(databaseName)
76
+ .collection(options.collection)
77
+ .find(query)
78
+ .toArray();
79
+
80
+ const constants = mapDocsToConstants(docs, options.name);
81
+ const count = Object.keys(constants).length;
82
+
83
+ for (const folder of options.folders) {
84
+ const targetFolder = join(options.repoRoot, folder);
85
+ const outputDir = resolveOutputDir(targetFolder);
86
+ const outputPath = join(outputDir, options.file);
87
+
88
+ if (options.dryRun) {
89
+ log(`[DRY RUN] Would generate ${outputPath} with ${count} constants.`);
90
+ } else {
91
+ await mkdir(dirname(outputPath), { recursive: true });
92
+ await writeFile(outputPath, `${JSON.stringify(constants, null, 2)}\n`, 'utf8');
93
+ log(`Generated ${outputPath} with ${count} constants.`);
94
+ }
95
+ }
96
+
97
+ return constants;
98
+ } finally {
99
+ if (!clientOverride) {
100
+ await client.close();
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,136 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ export const DEFAULT_QUERY = '{}';
4
+ export const DEFAULT_NAME_FIELD = 'code';
5
+ export const DEFAULT_FILE_NAME = '_permissions.json';
6
+
7
+ export const getDefaultOptions = () => ({
8
+ query: DEFAULT_QUERY,
9
+ repoRoot: process.cwd(),
10
+ folders: [],
11
+ collection: null,
12
+ name: DEFAULT_NAME_FIELD,
13
+ file: DEFAULT_FILE_NAME,
14
+ database: null,
15
+ connectionString: null,
16
+ dryRun: false
17
+ });
18
+
19
+ export const printHelp = (log = console.log) => {
20
+ log(`
21
+ Usage:
22
+ npx enable-generate-constants [options]
23
+
24
+ Options:
25
+ -f, --find <query> Optional MongoDB query as JSON or field=value pairs (default: "{}")
26
+ -r, --repoRoot <path> Repository root containing .env (default: current directory)
27
+ -o, --folders <paths> Comma-separated folders where constant file is written
28
+ -c, --collection <name> MongoDB collection containing constant/permission codes
29
+ -n, --name <field> Field name to extract (default: "code")
30
+ -F, --file <filename> Output JSON filename (default: "_permissions.json")
31
+ -d, --database <name> Database name (overrides MONGODB_DATABASE in .env)
32
+ -u, --connectionString <uri> MongoDB connection URI (overrides .env)
33
+ --dry-run Query and display constants without writing files
34
+ -h, --help Show this help message
35
+
36
+ Example:
37
+ npx enable-generate-constants --find "{}" --collection feature --folders ux/saas,ux/portal
38
+ `);
39
+ };
40
+
41
+ export function parseQuery(queryText = '{}') {
42
+ const value = (queryText || '{}').trim();
43
+ if (value.startsWith('{') || value.startsWith('[')) {
44
+ try {
45
+ const query = JSON.parse(value);
46
+ if (query === null || Array.isArray(query) || typeof query !== 'object') {
47
+ throw new Error('JSON query must be an object.');
48
+ }
49
+ return query;
50
+ } catch (error) {
51
+ if (error.message === 'JSON query must be an object.') {
52
+ throw error;
53
+ }
54
+ throw new Error(`Invalid JSON passed to --find: ${error.message}`);
55
+ }
56
+ }
57
+
58
+ const query = {};
59
+ for (const pair of value.split(',')) {
60
+ const separator = pair.indexOf('=');
61
+ if (separator <= 0) {
62
+ throw new Error(`Invalid --find expression "${pair}". Use field=value or JSON.`);
63
+ }
64
+ const key = pair.slice(0, separator).trim();
65
+ const rawValue = pair.slice(separator + 1).trim();
66
+ if (!key || !rawValue) {
67
+ throw new Error(`Invalid --find expression "${pair}". Use field=value or JSON.`);
68
+ }
69
+ try {
70
+ query[key] = JSON.parse(rawValue);
71
+ } catch {
72
+ query[key] = rawValue;
73
+ }
74
+ }
75
+ return query;
76
+ }
77
+
78
+ export function parseCliArgs(argv = process.argv, exit = process.exit, log = console.log) {
79
+ const args = argv.slice(2);
80
+ const options = getDefaultOptions();
81
+
82
+ for (let index = 0; index < args.length; index += 1) {
83
+ const arg = args[index];
84
+ if (arg === '--') {
85
+ continue;
86
+ } else if (arg === '-h' || arg === '--help') {
87
+ printHelp(log);
88
+ exit(0);
89
+ } else if (arg === '-f' || arg === '--find') {
90
+ options.query = args[++index];
91
+ } else if (arg.startsWith('--find=')) {
92
+ options.query = arg.slice('--find='.length);
93
+ } else if (arg === '-r' || arg === '--repoRoot' || arg === '--repo-root') {
94
+ options.repoRoot = resolve(args[++index]);
95
+ } else if (arg.startsWith('--repoRoot=') || arg.startsWith('--repo-root=')) {
96
+ options.repoRoot = resolve(arg.slice(arg.indexOf('=') + 1));
97
+ } else if (arg === '-o' || arg === '--folders') {
98
+ options.folders = (args[++index] || '').split(',').map((folder) => folder.trim()).filter(Boolean);
99
+ } else if (arg.startsWith('--folders=')) {
100
+ options.folders = arg.slice('--folders='.length).split(',').map((folder) => folder.trim()).filter(Boolean);
101
+ } else if (arg === '-c' || arg === '--collection') {
102
+ options.collection = args[++index];
103
+ } else if (arg.startsWith('--collection=')) {
104
+ options.collection = arg.slice('--collection='.length);
105
+ } else if (arg === '-n' || arg === '--name') {
106
+ options.name = args[++index];
107
+ } else if (arg.startsWith('--name=')) {
108
+ options.name = arg.slice('--name='.length);
109
+ } else if (arg === '-F' || arg === '--file') {
110
+ options.file = args[++index];
111
+ } else if (arg.startsWith('--file=')) {
112
+ options.file = arg.slice('--file='.length);
113
+ } else if (arg === '-d' || arg === '--database') {
114
+ options.database = args[++index];
115
+ } else if (arg.startsWith('--database=')) {
116
+ options.database = arg.slice('--database='.length);
117
+ } else if (arg === '-u' || arg === '--connectionString' || arg === '--uri') {
118
+ options.connectionString = args[++index];
119
+ } else if (arg.startsWith('--connectionString=') || arg.startsWith('--uri=')) {
120
+ options.connectionString = arg.slice(arg.indexOf('=') + 1);
121
+ } else if (arg === '--dry-run') {
122
+ options.dryRun = true;
123
+ } else {
124
+ throw new Error(`Unknown option: ${arg}`);
125
+ }
126
+ }
127
+
128
+ if (options.folders.length === 0) {
129
+ throw new Error('--folders is required and must contain at least one folder.');
130
+ }
131
+ if (!options.collection) {
132
+ throw new Error('--collection is required.');
133
+ }
134
+
135
+ return options;
136
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { toConstantKey, resolveOutputDir, mapDocsToConstants, generateConstants } from '../src/lib.mjs';
6
+
7
+ describe('enable-generate-constants lib', () => {
8
+ describe('toConstantKey', () => {
9
+ it('converts dots, dashes, and special characters to uppercase snake_case', () => {
10
+ expect(toConstantKey('user.read_profile')).toBe('USER_READ_PROFILE');
11
+ expect(toConstantKey('saas-feature:view-all')).toBe('SAAS_FEATURE_VIEW_ALL');
12
+ expect(toConstantKey('__leading_and_trailing__')).toBe('LEADING_AND_TRAILING');
13
+ });
14
+ });
15
+
16
+ describe('resolveOutputDir', () => {
17
+ it('returns src folder when present in target directory', () => {
18
+ const dir = mkdtempSync(join(tmpdir(), 'const-test-'));
19
+ mkdirSync(join(dir, 'src'));
20
+ try {
21
+ expect(resolveOutputDir(dir)).toBe(join(dir, 'src'));
22
+ } finally {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ it('falls back to directory root when src does not exist', () => {
28
+ const dir = mkdtempSync(join(tmpdir(), 'const-test-'));
29
+ try {
30
+ expect(resolveOutputDir(dir)).toBe(dir);
31
+ } finally {
32
+ rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ });
35
+ });
36
+
37
+ describe('mapDocsToConstants', () => {
38
+ it('maps and sorts document field values alphabetically by key', () => {
39
+ const docs = [
40
+ { code: 'zebra.access' },
41
+ { code: 'alpha.access' },
42
+ { code: 'beta.access' },
43
+ { code: '' },
44
+ { code: null }
45
+ ];
46
+
47
+ const result = mapDocsToConstants(docs, 'code');
48
+ expect(Object.keys(result)).toEqual(['ALPHA_ACCESS', 'BETA_ACCESS', 'ZEBRA_ACCESS']);
49
+ expect(result).toEqual({
50
+ ALPHA_ACCESS: 'alpha.access',
51
+ BETA_ACCESS: 'beta.access',
52
+ ZEBRA_ACCESS: 'zebra.access'
53
+ });
54
+ });
55
+ });
56
+
57
+ describe('generateConstants', () => {
58
+ it('queries client, maps constants, and writes files to target folders', async () => {
59
+ const tempDir = mkdtempSync(join(tmpdir(), 'gen-const-'));
60
+ const folderA = join(tempDir, 'pkgA');
61
+ const folderB = join(tempDir, 'pkgB');
62
+ mkdirSync(folderA);
63
+ mkdirSync(join(folderB, 'src'), { recursive: true });
64
+
65
+ const fakeDocs = [
66
+ { code: 'auth.login' },
67
+ { code: 'auth.logout' }
68
+ ];
69
+
70
+ const mockClient = {
71
+ db: () => ({
72
+ collection: () => ({
73
+ find: () => ({
74
+ toArray: async () => fakeDocs
75
+ })
76
+ })
77
+ })
78
+ };
79
+
80
+ const options = {
81
+ query: '{}',
82
+ repoRoot: tempDir,
83
+ folders: ['pkgA', 'pkgB'],
84
+ collection: 'permissions',
85
+ name: 'code',
86
+ file: '_permissions.json',
87
+ database: 'test_db',
88
+ connectionString: 'mongodb://localhost:27017',
89
+ dryRun: false
90
+ };
91
+
92
+ try {
93
+ const result = await generateConstants(options, () => {}, mockClient);
94
+ expect(result).toEqual({
95
+ AUTH_LOGIN: 'auth.login',
96
+ AUTH_LOGOUT: 'auth.logout'
97
+ });
98
+
99
+ const fileA = join(folderA, '_permissions.json');
100
+ const fileB = join(folderB, 'src', '_permissions.json');
101
+
102
+ expect(existsSync(fileA)).toBe(true);
103
+ expect(existsSync(fileB)).toBe(true);
104
+
105
+ const contentA = JSON.parse(readFileSync(fileA, 'utf8'));
106
+ expect(contentA).toEqual({
107
+ AUTH_LOGIN: 'auth.login',
108
+ AUTH_LOGOUT: 'auth.logout'
109
+ });
110
+ } finally {
111
+ rmSync(tempDir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ it('does not write files when dryRun is true', async () => {
116
+ const tempDir = mkdtempSync(join(tmpdir(), 'gen-const-dry-'));
117
+ const folderA = join(tempDir, 'pkgA');
118
+ mkdirSync(folderA);
119
+
120
+ const mockClient = {
121
+ db: () => ({
122
+ collection: () => ({
123
+ find: () => ({
124
+ toArray: async () => [{ code: 'test.code' }]
125
+ })
126
+ })
127
+ })
128
+ };
129
+
130
+ const options = {
131
+ query: '{}',
132
+ repoRoot: tempDir,
133
+ folders: ['pkgA'],
134
+ collection: 'permissions',
135
+ name: 'code',
136
+ file: '_permissions.json',
137
+ database: 'test_db',
138
+ connectionString: 'mongodb://localhost:27017',
139
+ dryRun: true
140
+ };
141
+
142
+ try {
143
+ await generateConstants(options, () => {}, mockClient);
144
+ const fileA = join(folderA, '_permissions.json');
145
+ expect(existsSync(fileA)).toBe(false);
146
+ } finally {
147
+ rmSync(tempDir, { recursive: true, force: true });
148
+ }
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { getDefaultOptions, parseCliArgs, parseQuery } from '../src/options.mjs';
3
+
4
+ describe('enable-generate-constants options', () => {
5
+ it('returns expected default options', () => {
6
+ const opts = getDefaultOptions();
7
+ expect(opts.query).toBe('{}');
8
+ expect(opts.folders).toEqual([]);
9
+ expect(opts.collection).toBe(null);
10
+ expect(opts.name).toBe('code');
11
+ expect(opts.file).toBe('_permissions.json');
12
+ expect(opts.dryRun).toBe(false);
13
+ });
14
+
15
+ describe('parseQuery', () => {
16
+ it('parses valid JSON object queries', () => {
17
+ expect(parseQuery('{"active": true}')).toEqual({ active: true });
18
+ });
19
+
20
+ it('throws when JSON query is not an object', () => {
21
+ expect(() => parseQuery('["not", "object"]')).toThrow('JSON query must be an object.');
22
+ });
23
+
24
+ it('parses key=value string queries', () => {
25
+ expect(parseQuery('tier=ux,active=true,count=10')).toEqual({
26
+ tier: 'ux',
27
+ active: true,
28
+ count: 10
29
+ });
30
+ });
31
+
32
+ it('throws on invalid key=value pairs', () => {
33
+ expect(() => parseQuery('invalidpair')).toThrow('Invalid --find expression');
34
+ });
35
+ });
36
+
37
+ describe('parseCliArgs', () => {
38
+ it('parses valid CLI arguments', () => {
39
+ const opts = parseCliArgs([
40
+ 'node',
41
+ 'index.mjs',
42
+ '-f',
43
+ '{"status":"active"}',
44
+ '-c',
45
+ 'permissions',
46
+ '-o',
47
+ 'packages/app,packages/core',
48
+ '-n',
49
+ 'featureCode',
50
+ '-F',
51
+ '_constants.json',
52
+ '-d',
53
+ 'test_db',
54
+ '-u',
55
+ 'mongodb://localhost:27017',
56
+ '--dry-run'
57
+ ]);
58
+
59
+ expect(opts.query).toBe('{"status":"active"}');
60
+ expect(opts.collection).toBe('permissions');
61
+ expect(opts.folders).toEqual(['packages/app', 'packages/core']);
62
+ expect(opts.name).toBe('featureCode');
63
+ expect(opts.file).toBe('_constants.json');
64
+ expect(opts.database).toBe('test_db');
65
+ expect(opts.connectionString).toBe('mongodb://localhost:27017');
66
+ expect(opts.dryRun).toBe(true);
67
+ });
68
+
69
+ it('throws when --folders is missing', () => {
70
+ expect(() => parseCliArgs(['node', 'index.mjs', '-c', 'features'])).toThrow(
71
+ '--folders is required and must contain at least one folder.'
72
+ );
73
+ });
74
+
75
+ it('throws when --collection is missing', () => {
76
+ expect(() => parseCliArgs(['node', 'index.mjs', '-o', 'packages/app'])).toThrow(
77
+ '--collection is required.'
78
+ );
79
+ });
80
+
81
+ it('throws on unknown options', () => {
82
+ expect(() => parseCliArgs(['node', 'index.mjs', '-c', 'feat', '-o', 'pkg', '--unknown'])).toThrow(
83
+ 'Unknown option: --unknown'
84
+ );
85
+ });
86
+ });
87
+ });