@qelos/plugins-cli 0.0.14 → 0.0.15

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/cli.mjs CHANGED
@@ -10,6 +10,7 @@ import createCommand from './commands/create.mjs';
10
10
  import pushCommand from './commands/push.mjs';
11
11
  import pullCommand from './commands/pull.mjs';
12
12
  import generateCommand from './commands/generate.mjs';
13
+ import blueprintsCommand from './commands/blueprints.mjs';
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
 
15
16
  const program = yargs(hideBin(process.argv));
@@ -26,5 +27,6 @@ createCommand(program)
26
27
  pushCommand(program)
27
28
  pullCommand(program)
28
29
  generateCommand(program)
30
+ blueprintsCommand(program)
29
31
 
30
32
  program.help().argv;
@@ -0,0 +1,22 @@
1
+ import blueprintsController from "../controllers/blueprints.mjs";
2
+
3
+ export default function blueprintsCommand(program) {
4
+ program
5
+ .command('blueprints generate [path]', 'generate new blueprints from actual database tables/collections',
6
+ (yargs) => {
7
+ return yargs
8
+ .positional('path', {
9
+ describe: 'Path to store the pulled resources.',
10
+ type: 'string',
11
+ default: './blueprints',
12
+ required: false
13
+ })
14
+ .option('uri', {
15
+ describe: 'The URI of the database. Defaults to mongodb://localhost:27017/db',
16
+ type: 'string',
17
+ required: false,
18
+ default: 'mongodb://localhost:27017/db'
19
+ })
20
+ },
21
+ blueprintsController)
22
+ }
@@ -0,0 +1,336 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MongoClient } from "mongodb";
4
+ import { logger } from "../services/logger.mjs";
5
+
6
+ const SUPPORTED_PROTOCOL = /^mongodb:\/\//i;
7
+ const SAMPLE_SIZE = 50;
8
+
9
+ export default async function blueprintsController({ uri, path: targetPath }) {
10
+ const connectionUri = uri || "mongodb://localhost:27017/db";
11
+
12
+ if (!SUPPORTED_PROTOCOL.test(connectionUri)) {
13
+ logger.error("Only mongodb:// URIs are supported at the moment.");
14
+ process.exit(1);
15
+ }
16
+
17
+ const targetDir = path.join(process.cwd(), targetPath);
18
+ ensureDirectory(targetDir);
19
+
20
+ const client = new MongoClient(connectionUri, {
21
+ serverSelectionTimeoutMS: 10_000,
22
+ });
23
+
24
+ try {
25
+ await client.connect();
26
+ const dbName = getDatabaseName(connectionUri);
27
+ const db = client.db(dbName);
28
+
29
+ logger.section(`Connected to MongoDB database: ${db.databaseName}`);
30
+
31
+ const collections = await db.listCollections().toArray();
32
+ const filteredCollections = collections.filter(
33
+ (collection) => !collection.name.startsWith("system.")
34
+ );
35
+
36
+ if (filteredCollections.length === 0) {
37
+ logger.warning("No collections found to generate blueprints from.");
38
+ return;
39
+ }
40
+
41
+ for (const collection of filteredCollections) {
42
+ await generateBlueprintForCollection(db, collection.name, targetDir);
43
+ }
44
+
45
+ logger.success(
46
+ `Generated ${filteredCollections.length} blueprint file(s) in ${targetDir}`
47
+ );
48
+ } catch (error) {
49
+ logger.error("Failed to generate blueprints", error);
50
+ process.exit(1);
51
+ } finally {
52
+ await client.close().catch(() => {});
53
+ }
54
+ }
55
+
56
+ function ensureDirectory(targetDir) {
57
+ if (!fs.existsSync(targetDir)) {
58
+ fs.mkdirSync(targetDir, { recursive: true });
59
+ logger.info(`Created output directory at ${targetDir}`);
60
+ }
61
+ }
62
+
63
+ async function generateBlueprintForCollection(db, collectionName, targetDir) {
64
+ logger.step(`Analyzing collection: ${collectionName}`);
65
+ try {
66
+ const collection = db.collection(collectionName);
67
+ const documents = await sampleCollectionDocuments(collection);
68
+ const properties = buildProperties(collectionName, documents);
69
+
70
+ const blueprint = createBlueprintPayload(collectionName, properties);
71
+ const filePath = path.join(
72
+ targetDir,
73
+ `${blueprint.identifier}.blueprint.json`
74
+ );
75
+
76
+ fs.writeFileSync(filePath, JSON.stringify(blueprint, null, 2));
77
+ logger.success(`Blueprint generated: ${filePath}`);
78
+ } catch (error) {
79
+ logger.error(`Failed to process collection ${collectionName}`, error);
80
+ }
81
+ }
82
+
83
+ function buildProperties(collectionName, documents) {
84
+ const properties = {};
85
+ let processedDocuments = 0;
86
+
87
+ for (const doc of documents) {
88
+ if (processedDocuments >= SAMPLE_SIZE) {
89
+ break;
90
+ }
91
+ processedDocuments += 1;
92
+ if (!doc || typeof doc !== "object") continue;
93
+
94
+ for (const [key, value] of Object.entries(doc)) {
95
+ if (shouldSkipField(key) || properties[key]) continue;
96
+ properties[key] = createPropertyDescriptor(key, value, collectionName);
97
+ }
98
+ }
99
+
100
+ if (Object.keys(properties).length === 0) {
101
+ logger.warning(
102
+ `No properties detected for collection ${collectionName}. Generated blueprint will contain empty properties.`
103
+ );
104
+ }
105
+
106
+ return properties;
107
+ }
108
+
109
+ function createPropertyDescriptor(key, sampleValue, collectionName) {
110
+ const { normalizedValue, multi } = normalizeSampleValue(sampleValue);
111
+ const type = detectBlueprintType(normalizedValue);
112
+
113
+ const descriptor = {
114
+ title: formatTitle(key),
115
+ type,
116
+ description: "",
117
+ required: false,
118
+ };
119
+
120
+ if (multi) {
121
+ descriptor.multi = true;
122
+ }
123
+
124
+ if (type === "object") {
125
+ descriptor.schema = buildObjectSchema(normalizedValue);
126
+ }
127
+
128
+ return descriptor;
129
+ }
130
+
131
+ function normalizeSampleValue(value) {
132
+ if (Array.isArray(value)) {
133
+ const firstValue = value.find(
134
+ (item) => item !== null && item !== undefined
135
+ );
136
+ return { normalizedValue: firstValue ?? null, multi: true };
137
+ }
138
+
139
+ return { normalizedValue: value, multi: false };
140
+ }
141
+
142
+ function detectBlueprintType(value) {
143
+ if (value === null || value === undefined) {
144
+ return "string";
145
+ }
146
+
147
+ if (value instanceof Date) {
148
+ return "datetime";
149
+ }
150
+
151
+ if (typeof value === "number") {
152
+ return "number";
153
+ }
154
+
155
+ if (typeof value === "boolean") {
156
+ return "boolean";
157
+ }
158
+
159
+ if (typeof value === "object") {
160
+ if (value?._bsontype === "ObjectId") {
161
+ return "string";
162
+ }
163
+ return "object";
164
+ }
165
+
166
+ return "string";
167
+ }
168
+
169
+ function createBlueprintPayload(collectionName, properties) {
170
+ const singularName = ensureSingular(collectionName);
171
+ return {
172
+ identifier: toIdentifier(singularName),
173
+ name: formatTitle(singularName),
174
+ description: `Auto-generated blueprint for MongoDB collection "${collectionName}"`,
175
+ entityIdentifierMechanism: "objectid",
176
+ permissions: createDefaultPermissions(),
177
+ permissionScope: "workspace",
178
+ properties,
179
+ relations: [],
180
+ dispatchers: {
181
+ create: false,
182
+ update: false,
183
+ delete: false,
184
+ },
185
+ limitations: [],
186
+ };
187
+ }
188
+
189
+ function createDefaultPermissions() {
190
+ const operations = ["create", "read", "update", "delete"];
191
+ return operations.map((operation) => ({
192
+ scope: "workspace",
193
+ operation,
194
+ guest: false,
195
+ roleBased: ["*"],
196
+ workspaceRoleBased: ["*"],
197
+ workspaceLabelsBased: ["*"],
198
+ }));
199
+ }
200
+
201
+ function toIdentifier(collectionName) {
202
+ const sanitized = collectionName
203
+ .trim()
204
+ .toLowerCase()
205
+ .replace(/[^a-z0-9]+/g, "_")
206
+ .replace(/^_+|_+$/g, "");
207
+
208
+ return sanitized || `collection_${Date.now()}`;
209
+ }
210
+
211
+ function formatTitle(value) {
212
+ return value
213
+ .replace(/[_-]+/g, " ")
214
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
215
+ .replace(/\s+/g, " ")
216
+ .trim()
217
+ .replace(/\b\w/g, (match) => match.toUpperCase());
218
+ }
219
+
220
+ async function sampleCollectionDocuments(collection) {
221
+ try {
222
+ return await collection
223
+ .aggregate([{ $sample: { size: SAMPLE_SIZE } }])
224
+ .toArray();
225
+ } catch (error) {
226
+ logger.debug(
227
+ `Falling back to sequential sampling for ${collection.collectionName}: ${error.message}`
228
+ );
229
+ return collection.find({}).limit(SAMPLE_SIZE).toArray();
230
+ }
231
+ }
232
+
233
+ function buildObjectSchema(sample, depth = 0) {
234
+ const MAX_SCHEMA_DEPTH = 3;
235
+ if (!sample || typeof sample !== "object" || depth >= MAX_SCHEMA_DEPTH) {
236
+ return { type: "object" };
237
+ }
238
+
239
+ const properties = {};
240
+
241
+ for (const [key, value] of Object.entries(sample)) {
242
+ if (key === "_id" || value === undefined) continue;
243
+ properties[key] = buildSchemaFromValue(value, depth + 1);
244
+ }
245
+
246
+ if (Object.keys(properties).length === 0) {
247
+ return { type: "object" };
248
+ }
249
+
250
+ return {
251
+ type: "object",
252
+ properties,
253
+ };
254
+ }
255
+
256
+ function buildSchemaFromValue(value, depth) {
257
+ if (Array.isArray(value)) {
258
+ const arraySample = value.find(
259
+ (item) => item !== null && item !== undefined
260
+ );
261
+ const itemsSchema = arraySample
262
+ ? buildSchemaFromValue(arraySample, depth + 1)
263
+ : { type: "string" };
264
+ return {
265
+ type: "array",
266
+ items: itemsSchema,
267
+ };
268
+ }
269
+
270
+ const valueType = detectBlueprintType(value);
271
+
272
+ if (valueType === "object" && value && typeof value === "object") {
273
+ return buildObjectSchema(value, depth + 1);
274
+ }
275
+
276
+ return {
277
+ type: mapBlueprintTypeToJsonSchema(valueType),
278
+ };
279
+ }
280
+
281
+ function mapBlueprintTypeToJsonSchema(type) {
282
+ switch (type) {
283
+ case "number":
284
+ return "number";
285
+ case "boolean":
286
+ return "boolean";
287
+ case "object":
288
+ return "object";
289
+ default:
290
+ return "string";
291
+ }
292
+ }
293
+
294
+ function shouldSkipField(key) {
295
+ if (!key) return true;
296
+ const normalized = key.toLowerCase();
297
+ if (
298
+ normalized === "_id" ||
299
+ normalized === "id" ||
300
+ normalized === "user" ||
301
+ normalized === "userid" ||
302
+ normalized === "workspace" ||
303
+ normalized === "workspaceid"
304
+ ) {
305
+ return true;
306
+ }
307
+ return key.startsWith("__");
308
+ }
309
+
310
+ function ensureSingular(value = "") {
311
+ const normalized = value.trim();
312
+ const lower = normalized.toLowerCase();
313
+
314
+ if (lower.endsWith("ies")) {
315
+ return normalized.slice(0, -3) + normalized.slice(-3).replace(/ies$/i, "y");
316
+ }
317
+
318
+ if (/(sses|xes|zes|ches|shes)$/i.test(lower)) {
319
+ return normalized.slice(0, -2);
320
+ }
321
+
322
+ if (lower.endsWith("s") && !lower.endsWith("ss")) {
323
+ return normalized.slice(0, -1);
324
+ }
325
+
326
+ return normalized;
327
+ }
328
+
329
+ function getDatabaseName(connectionUri) {
330
+ try {
331
+ const parsed = new URL(connectionUri);
332
+ return parsed.pathname.replace(/^\//, "") || undefined;
333
+ } catch {
334
+ return undefined;
335
+ }
336
+ }
@@ -2,7 +2,7 @@ import follow from "follow-redirects";
2
2
  import cliSelect from "cli-select";
3
3
  import { blue } from "../utils/colors.mjs";
4
4
  import DecompressZip from "decompress-zip";
5
- import { join } from "path";
5
+ import { join } from "node:path";
6
6
  import { rimraf } from "rimraf";
7
7
  import ProgressBar from "../utils/progress-bar.mjs";
8
8
  import * as readline from "node:readline";
@@ -1,7 +1,5 @@
1
1
  import { generateRules } from '../services/generate-rules.mjs';
2
2
  import { logger } from '../services/logger.mjs';
3
- import path from 'node:path';
4
- import fs from 'node:fs';
5
3
 
6
4
  export default async function generateController({ type }) {
7
5
  try {
@@ -17,10 +17,16 @@ export default async function pushController({ type, path: sourcePath }) {
17
17
  process.exit(1);
18
18
  }
19
19
 
20
- // Validate path is a directory
21
- if (!fs.statSync(sourcePath).isDirectory()) {
22
- logger.error(`Path is not a directory: ${sourcePath}`);
23
- logger.info('Please provide a directory path, not a file');
20
+ const stat = fs.statSync(sourcePath);
21
+ let basePath = sourcePath;
22
+ let targetFile = null;
23
+
24
+ if (stat.isFile()) {
25
+ basePath = path.dirname(sourcePath);
26
+ targetFile = path.basename(sourcePath);
27
+ logger.info(`Detected file path. Only pushing ${targetFile}`);
28
+ } else if (!stat.isDirectory()) {
29
+ logger.error(`Path must be a file or directory: ${sourcePath}`);
24
30
  process.exit(1);
25
31
  }
26
32
 
@@ -28,6 +34,10 @@ export default async function pushController({ type, path: sourcePath }) {
28
34
 
29
35
  // Handle "all" or "*" type
30
36
  if (type === 'all' || type === '*') {
37
+ if (targetFile) {
38
+ logger.error('Cannot push "all" using a single file. Please provide a directory path.');
39
+ process.exit(1);
40
+ }
31
41
  logger.section(`Pushing all resources from ${sourcePath}`);
32
42
 
33
43
  const types = [
@@ -39,7 +49,7 @@ export default async function pushController({ type, path: sourcePath }) {
39
49
  ];
40
50
 
41
51
  for (const { name, fn } of types) {
42
- const typePath = path.join(sourcePath, name);
52
+ const typePath = path.join(basePath, name);
43
53
 
44
54
  // Skip if directory doesn't exist
45
55
  if (!fs.existsSync(typePath)) {
@@ -60,18 +70,18 @@ export default async function pushController({ type, path: sourcePath }) {
60
70
  return;
61
71
  }
62
72
 
63
- logger.section(`Pushing ${type} from ${sourcePath}`);
73
+ logger.section(`Pushing ${type} from ${targetFile ? `${basePath} (${targetFile})` : basePath}`);
64
74
 
65
75
  if (type === 'components') {
66
- await pushComponents(sdk, sourcePath);
76
+ await pushComponents(sdk, basePath, { targetFile });
67
77
  } else if (type === 'blueprints') {
68
- await pushBlueprints(sdk, sourcePath);
78
+ await pushBlueprints(sdk, basePath, { targetFile });
69
79
  } else if (type === 'plugins') {
70
- await pushPlugins(sdk, sourcePath);
80
+ await pushPlugins(sdk, basePath, { targetFile });
71
81
  } else if (type === 'blocks') {
72
- await pushBlocks(sdk, sourcePath);
82
+ await pushBlocks(sdk, basePath, { targetFile });
73
83
  } else if (type === 'config' || type === 'configs' || type === 'configuration') {
74
- await pushConfigurations(sdk, sourcePath);
84
+ await pushConfigurations(sdk, basePath, { targetFile });
75
85
  } else {
76
86
  logger.error(`Unknown type: ${type}`);
77
87
  logger.info('Supported types: components, blueprints, plugins, blocks, config, configs, configuration, all');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qelos/plugins-cli",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "CLI to manage QELOS plugins",
5
5
  "main": "cli.mjs",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "decompress-zip": "^0.3.3",
21
21
  "follow-redirects": "^1.15.11",
22
22
  "jiti": "^2.6.1",
23
+ "mongodb": "^7.0.0",
23
24
  "rimraf": "^6.0.1",
24
25
  "yargs": "^18.0.0",
25
26
  "zx": "^8.8.5"
@@ -17,12 +17,18 @@ function toKebabCase(str) {
17
17
  * @param {Object} sdk - Initialized SDK instance
18
18
  * @param {string} path - Path to blocks directory
19
19
  */
20
- export async function pushBlocks(sdk, path) {
21
- const files = fs.readdirSync(path);
20
+ export async function pushBlocks(sdk, path, options = {}) {
21
+ const { targetFile } = options;
22
+ const directoryFiles = fs.readdirSync(path);
23
+ const files = targetFile ? [targetFile] : directoryFiles;
22
24
  const blockFiles = files.filter(f => f.endsWith('.html'));
23
25
 
24
26
  if (blockFiles.length === 0) {
25
- logger.warning(`No .html files found in ${path}`);
27
+ if (targetFile) {
28
+ logger.warning(`File ${targetFile} is not an .html block. Skipping.`);
29
+ } else {
30
+ logger.warning(`No .html files found in ${path}`);
31
+ }
26
32
  return;
27
33
  }
28
34
 
@@ -7,12 +7,18 @@ import { logger } from './logger.mjs';
7
7
  * @param {Object} sdk - Initialized SDK instance
8
8
  * @param {string} path - Path to blueprints directory
9
9
  */
10
- export async function pushBlueprints(sdk, path) {
11
- const files = fs.readdirSync(path);
10
+ export async function pushBlueprints(sdk, path, options = {}) {
11
+ const { targetFile } = options;
12
+ const directoryFiles = fs.readdirSync(path);
13
+ const files = targetFile ? [targetFile] : directoryFiles;
12
14
  const blueprintFiles = files.filter(f => f.endsWith('.blueprint.json'));
13
15
 
14
16
  if (blueprintFiles.length === 0) {
15
- logger.warning(`No blueprint files (*.blueprint.json) found in ${path}`);
17
+ if (targetFile) {
18
+ logger.warning(`File ${targetFile} is not a .blueprint.json file. Skipping.`);
19
+ } else {
20
+ logger.warning(`No blueprint files (*.blueprint.json) found in ${path}`);
21
+ }
16
22
  return;
17
23
  }
18
24
 
@@ -7,12 +7,18 @@ import { logger } from './logger.mjs';
7
7
  * @param {Object} sdk - Initialized SDK instance
8
8
  * @param {string} path - Path to components directory
9
9
  */
10
- export async function pushComponents(sdk, path) {
11
- const files = fs.readdirSync(path);
10
+ export async function pushComponents(sdk, path, options = {}) {
11
+ const { targetFile } = options;
12
+ const directoryFiles = fs.readdirSync(path);
13
+ const files = targetFile ? [targetFile] : directoryFiles;
12
14
  const vueFiles = files.filter(f => f.endsWith('.vue'));
13
15
 
14
16
  if (vueFiles.length === 0) {
15
- logger.warning(`No .vue files found in ${path}`);
17
+ if (targetFile) {
18
+ logger.warning(`File ${targetFile} is not a .vue component. Skipping.`);
19
+ } else {
20
+ logger.warning(`No .vue files found in ${path}`);
21
+ }
16
22
  return;
17
23
  }
18
24
 
@@ -30,7 +36,7 @@ export async function pushComponents(sdk, path) {
30
36
 
31
37
  const existingComponents = await sdk.components.getList();
32
38
 
33
- await Promise.all(files.map(async (file) => {
39
+ await Promise.all(vueFiles.map(async (file) => {
34
40
  if (file.endsWith('.vue')) {
35
41
  const componentName = file.replace('.vue', '');
36
42
  const info = componentsJson[componentName] || {};
@@ -8,12 +8,18 @@ import { appUrl } from './sdk.mjs';
8
8
  * @param {Object} sdk - Initialized SDK instance
9
9
  * @param {string} path - Path to configurations directory
10
10
  */
11
- export async function pushConfigurations(sdk, path) {
12
- const files = fs.readdirSync(path);
11
+ export async function pushConfigurations(sdk, path, options = {}) {
12
+ const { targetFile } = options;
13
+ const directoryFiles = fs.readdirSync(path);
14
+ const files = targetFile ? [targetFile] : directoryFiles;
13
15
  const configFiles = files.filter(f => f.endsWith('.config.json'));
14
16
 
15
17
  if (configFiles.length === 0) {
16
- logger.warning(`No configuration files (*.config.json) found in ${path}`);
18
+ if (targetFile) {
19
+ logger.warning(`File ${targetFile} is not a .config.json file. Skipping.`);
20
+ } else {
21
+ logger.warning(`No configuration files (*.config.json) found in ${path}`);
22
+ }
17
23
  return;
18
24
  }
19
25
 
@@ -9,12 +9,18 @@ import { extractMicroFrontendStructures, resolveMicroFrontendStructures } from '
9
9
  * @param {Object} sdk - Initialized SDK instance
10
10
  * @param {string} path - Path to plugins directory
11
11
  */
12
- export async function pushPlugins(sdk, path) {
13
- const files = fs.readdirSync(path);
12
+ export async function pushPlugins(sdk, path, options = {}) {
13
+ const { targetFile } = options;
14
+ const directoryFiles = fs.readdirSync(path);
15
+ const files = targetFile ? [targetFile] : directoryFiles;
14
16
  const pluginFiles = files.filter(f => f.endsWith('.plugin.json'));
15
17
 
16
18
  if (pluginFiles.length === 0) {
17
- logger.warning(`No plugin files (*.plugin.json) found in ${path}`);
19
+ if (targetFile) {
20
+ logger.warning(`File ${targetFile} is not a .plugin.json file. Skipping.`);
21
+ } else {
22
+ logger.warning(`No plugin files (*.plugin.json) found in ${path}`);
23
+ }
18
24
  return;
19
25
  }
20
26