@tolinax/ayoune-cli 2026.4.0 → 2026.6.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.
@@ -28,6 +28,7 @@ const MODULE_HOST_OVERRIDES = {
28
28
  general: "https://api-v1.ayoune.app/api/general",
29
29
  usersettings: "https://api-v1.ayoune.app/api/usersettings",
30
30
  auth: "https://auth.ayoune.app",
31
+ aggregation: "https://aggregation.ayoune.app",
31
32
  };
32
33
  export function getModuleBaseUrl(module) {
33
34
  return MODULE_HOST_OVERRIDES[module] || `https://${module}-api.ayoune.app`;
@@ -0,0 +1,656 @@
1
+ import chalk from "chalk";
2
+ import inquirer from "inquirer";
3
+ import { apiCallHandler } from "../api/apiCallHandler.js";
4
+ import { handleResponseFormatOptions } from "../helpers/handleResponseFormatOptions.js";
5
+ import { spinner } from "../../index.js";
6
+ import { EXIT_GENERAL_ERROR, EXIT_MISUSE } from "../exitCodes.js";
7
+ import { cliError } from "../helpers/cliError.js";
8
+ import { readPipelineInput } from "../helpers/readPipelineInput.js";
9
+ function wrapAggResult(res) {
10
+ if (Array.isArray(res)) {
11
+ return { payload: res, meta: { resultCount: res.length } };
12
+ }
13
+ if ((res === null || res === void 0 ? void 0 : res.payload) !== undefined)
14
+ return res;
15
+ return { payload: res, meta: {} };
16
+ }
17
+ export function createAggregateCommand(program) {
18
+ const agg = program
19
+ .command("aggregate")
20
+ .alias("agg")
21
+ .description("Run MongoDB aggregation pipelines, manage saved queries, and explore models")
22
+ .addHelpText("after", `
23
+ Examples:
24
+ ay agg run consumers --pipeline '[{"$count":"total"}]'
25
+ ay agg wizard
26
+ ay agg exec monthly-revenue -r table
27
+ ay agg list
28
+ ay agg save --name "Active Users" --model consumers --pipeline '[{"$match":{"status":"active"}}]'
29
+ ay agg validate --pipeline '[{"$group":{"_id":"$type","count":{"$sum":1}}}]'
30
+ ay agg models consumers`);
31
+ // ─── ay agg run <model> ─────────────────────────────────────────
32
+ agg
33
+ .command("run <model>")
34
+ .description("Execute an aggregation pipeline on a model")
35
+ .option("--pipeline <json>", "Pipeline as JSON array string")
36
+ .option("--file <path>", "Read pipeline from a JSON file")
37
+ .option("--global", "Skip tenant filter (superuser only)")
38
+ .option("--replace", "Enable template variable replacement")
39
+ .option("--resultAsObject", "Return result as single object")
40
+ .option("--random", "Return one random result")
41
+ .addHelpText("after", `
42
+ Examples:
43
+ ay agg run consumers --pipeline '[{"$match":{"status":"active"}},{"$count":"total"}]'
44
+ ay agg run orders --file pipeline.json -r table
45
+ cat pipeline.json | ay agg run products`)
46
+ .action(async (model, options) => {
47
+ var _a, _b;
48
+ try {
49
+ const opts = { ...program.opts(), ...options };
50
+ const pipeline = await readPipelineInput(opts);
51
+ spinner.start({ text: `Executing pipeline on ${model}...`, color: "magenta" });
52
+ const params = {};
53
+ if (opts.global)
54
+ params.global = true;
55
+ if (opts.replace)
56
+ params.replace = true;
57
+ if (opts.resultAsObject)
58
+ params.resultAsObject = true;
59
+ if (opts.random)
60
+ params.random = true;
61
+ const res = await apiCallHandler("aggregation", model, "post", pipeline, params);
62
+ const wrapped = wrapAggResult(res);
63
+ handleResponseFormatOptions(opts, wrapped);
64
+ spinner.success({ text: `Pipeline returned ${(_b = (_a = wrapped.meta) === null || _a === void 0 ? void 0 : _a.resultCount) !== null && _b !== void 0 ? _b : "?"} results` });
65
+ }
66
+ catch (e) {
67
+ cliError(e.message || "Pipeline execution failed", EXIT_GENERAL_ERROR);
68
+ }
69
+ });
70
+ // ─── ay agg exec <slugOrId> ─────────────────────────────────────
71
+ agg
72
+ .command("exec <slugOrId>")
73
+ .description("Execute a saved query by slug name or ObjectId")
74
+ .addHelpText("after", `
75
+ Examples:
76
+ ay agg exec monthly-revenue -r table
77
+ ay agg exec 507f1f77bcf86cd799439011 -r json --quiet`)
78
+ .action(async (slugOrId, options) => {
79
+ var _a, _b, _c, _d;
80
+ try {
81
+ const opts = { ...program.opts(), ...options };
82
+ const isObjectId = /^[a-f0-9]{24}$/i.test(slugOrId);
83
+ spinner.start({ text: `Executing query "${slugOrId}"...`, color: "magenta" });
84
+ let queryId = slugOrId;
85
+ if (!isObjectId) {
86
+ // Resolve slug to ID via config-api
87
+ const lookup = await apiCallHandler("config", "queries", "get", null, {
88
+ slug: slugOrId,
89
+ limit: 1,
90
+ responseFormat: "json",
91
+ });
92
+ const entries = lookup === null || lookup === void 0 ? void 0 : lookup.payload;
93
+ if (!entries || (Array.isArray(entries) && entries.length === 0)) {
94
+ cliError(`No query found with slug "${slugOrId}"`, EXIT_GENERAL_ERROR);
95
+ }
96
+ queryId = Array.isArray(entries) ? entries[0]._id : entries._id;
97
+ }
98
+ const res = await apiCallHandler("aggregation", `queries/execute/${queryId}`, "post");
99
+ const wrapped = wrapAggResult(res);
100
+ handleResponseFormatOptions(opts, wrapped);
101
+ const count = (_d = (_b = (_a = wrapped.meta) === null || _a === void 0 ? void 0 : _a.resultCount) !== null && _b !== void 0 ? _b : (_c = wrapped.payload) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : "?";
102
+ spinner.success({ text: `Query returned ${count} results` });
103
+ }
104
+ catch (e) {
105
+ cliError(e.message || "Query execution failed", EXIT_GENERAL_ERROR);
106
+ }
107
+ });
108
+ // ─── ay agg list ────────────────────────────────────────────────
109
+ agg
110
+ .command("list")
111
+ .alias("ls")
112
+ .description("List saved aggregation queries")
113
+ .option("-l, --limit <number>", "Limit results", parseInt, 50)
114
+ .option("-p, --page <number>", "Page number", parseInt, 1)
115
+ .option("--search <term>", "Search by name")
116
+ .action(async (options) => {
117
+ var _a, _b, _c, _d, _e;
118
+ try {
119
+ const opts = { ...program.opts(), ...options };
120
+ spinner.start({ text: "Fetching queries...", color: "magenta" });
121
+ const params = {
122
+ page: opts.page,
123
+ limit: opts.limit,
124
+ responseFormat: opts.responseFormat,
125
+ verbosity: opts.verbosity,
126
+ "dataSource.source": "aggregation",
127
+ };
128
+ if (opts.search)
129
+ params.q = opts.search;
130
+ const res = await apiCallHandler("config", "queries", "get", null, params);
131
+ handleResponseFormatOptions(opts, res);
132
+ const total = (_e = (_c = (_b = (_a = res.meta) === null || _a === void 0 ? void 0 : _a.pageInfo) === null || _b === void 0 ? void 0 : _b.totalEntries) !== null && _c !== void 0 ? _c : (_d = res.payload) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0;
133
+ spinner.success({ text: `Found ${total} queries` });
134
+ }
135
+ catch (e) {
136
+ cliError(e.message || "Failed to list queries", EXIT_GENERAL_ERROR);
137
+ }
138
+ });
139
+ // ─── ay agg save ────────────────────────────────────────────────
140
+ agg
141
+ .command("save")
142
+ .description("Save a pipeline as a reusable query")
143
+ .requiredOption("--name <name>", "Query name")
144
+ .requiredOption("--model <model>", "Aggregation model name")
145
+ .option("--pipeline <json>", "Pipeline as JSON array string")
146
+ .option("--file <path>", "Read pipeline from a JSON file")
147
+ .addHelpText("after", `
148
+ Examples:
149
+ ay agg save --name "Monthly Revenue" --model orders --pipeline '[{"$group":{"_id":null,"total":{"$sum":"$amount"}}}]'
150
+ ay agg save --name "Active Users" --model consumers --file pipeline.json`)
151
+ .action(async (options) => {
152
+ var _a, _b;
153
+ try {
154
+ const opts = { ...program.opts(), ...options };
155
+ const pipeline = await readPipelineInput(opts);
156
+ spinner.start({ text: "Saving query...", color: "magenta" });
157
+ const body = {
158
+ name: opts.name,
159
+ dataSource: {
160
+ source: "aggregation",
161
+ aggregationModel: opts.model,
162
+ aggregationPipeline: JSON.stringify(pipeline),
163
+ },
164
+ cache: { enabled: false },
165
+ };
166
+ const res = await apiCallHandler("config", "queries", "post", body, {
167
+ responseFormat: opts.responseFormat,
168
+ });
169
+ handleResponseFormatOptions(opts, res);
170
+ const slug = ((_a = res.payload) === null || _a === void 0 ? void 0 : _a.slug) || ((_b = res.payload) === null || _b === void 0 ? void 0 : _b.name) || opts.name;
171
+ spinner.success({ text: `Query saved — run with: ay agg exec ${slug}` });
172
+ }
173
+ catch (e) {
174
+ cliError(e.message || "Failed to save query", EXIT_GENERAL_ERROR);
175
+ }
176
+ });
177
+ // ─── ay agg validate ───────────────────────────────────────────
178
+ agg
179
+ .command("validate")
180
+ .alias("check")
181
+ .description("Validate a pipeline without executing")
182
+ .option("--pipeline <json>", "Pipeline as JSON array string")
183
+ .option("--file <path>", "Read pipeline from a JSON file")
184
+ .action(async (options) => {
185
+ var _a, _b, _c;
186
+ try {
187
+ const opts = { ...program.opts(), ...options };
188
+ const pipeline = await readPipelineInput(opts);
189
+ spinner.start({ text: "Validating pipeline...", color: "magenta" });
190
+ const res = await apiCallHandler("aggregation", "models/validate", "post", { pipeline });
191
+ spinner.stop();
192
+ const v = res.payload || res;
193
+ const icon = v.valid ? chalk.green("✓") : chalk.red("✗");
194
+ console.log(`\n ${icon} Pipeline is ${v.valid ? "valid" : "invalid"} (${(_a = v.stageCount) !== null && _a !== void 0 ? _a : pipeline.length} stages)`);
195
+ if ((_b = v.errors) === null || _b === void 0 ? void 0 : _b.length) {
196
+ for (const err of v.errors)
197
+ console.log(chalk.red(` - ${err}`));
198
+ }
199
+ if ((_c = v.warnings) === null || _c === void 0 ? void 0 : _c.length) {
200
+ for (const w of v.warnings)
201
+ console.log(chalk.yellow(` ! ${w}`));
202
+ }
203
+ console.log();
204
+ if (!v.valid)
205
+ process.exit(EXIT_GENERAL_ERROR);
206
+ }
207
+ catch (e) {
208
+ cliError(e.message || "Validation failed", EXIT_GENERAL_ERROR);
209
+ }
210
+ });
211
+ // ─── ay agg models [model] ─────────────────────────────────────
212
+ agg
213
+ .command("models [model]")
214
+ .description("List available models, or show fields for a specific model")
215
+ .addHelpText("after", `
216
+ Examples:
217
+ ay agg models List all available models
218
+ ay agg models consumers Show fields for Consumers`)
219
+ .action(async (model, options) => {
220
+ var _a, _b, _c, _d, _e;
221
+ try {
222
+ const opts = { ...program.opts(), ...options };
223
+ if (model) {
224
+ spinner.start({ text: `Loading fields for ${model}...`, color: "magenta" });
225
+ const res = await apiCallHandler("aggregation", `models/${model}/fields`, "get");
226
+ handleResponseFormatOptions(opts, res);
227
+ const fieldCount = (_c = (_b = (_a = res.payload) === null || _a === void 0 ? void 0 : _a.fields) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0;
228
+ spinner.success({ text: `${model}: ${fieldCount} fields` });
229
+ }
230
+ else {
231
+ spinner.start({ text: "Loading models...", color: "magenta" });
232
+ const res = await apiCallHandler("aggregation", "models", "get");
233
+ handleResponseFormatOptions(opts, res);
234
+ const count = (_e = (_d = res.payload) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0;
235
+ spinner.success({ text: `${count} models available` });
236
+ }
237
+ }
238
+ catch (e) {
239
+ cliError(e.message || "Failed to load models", EXIT_GENERAL_ERROR);
240
+ }
241
+ });
242
+ // ─── ay agg wizard ─────────────────────────────────────────────
243
+ agg
244
+ .command("wizard")
245
+ .alias("wiz")
246
+ .description("Interactive pipeline builder wizard")
247
+ .action(async (options) => {
248
+ var _a, _b, _c, _d, _e;
249
+ try {
250
+ const opts = { ...program.opts(), ...options };
251
+ if (!process.stdin.isTTY) {
252
+ cliError("Wizard requires an interactive terminal (TTY)", EXIT_MISUSE);
253
+ }
254
+ // Step 1: Select model
255
+ spinner.start({ text: "Loading models...", color: "magenta" });
256
+ const modelsRes = await apiCallHandler("aggregation", "models", "get");
257
+ spinner.stop();
258
+ const modelChoices = (modelsRes.payload || []).map((m) => ({
259
+ name: `${m.name} ${chalk.dim(`(${m.module})`)}`,
260
+ value: m.name,
261
+ }));
262
+ const { selectedModel } = await inquirer.prompt([
263
+ {
264
+ type: "search-list",
265
+ name: "selectedModel",
266
+ message: "Select a model:",
267
+ choices: modelChoices,
268
+ },
269
+ ]);
270
+ // Step 2: Fetch and display fields
271
+ spinner.start({ text: `Loading fields for ${selectedModel}...`, color: "magenta" });
272
+ const fieldsRes = await apiCallHandler("aggregation", `models/${selectedModel}/fields`, "get");
273
+ spinner.stop();
274
+ const fields = ((_a = fieldsRes.payload) === null || _a === void 0 ? void 0 : _a.fields) || [];
275
+ const fieldNames = fields.map((f) => f.field);
276
+ console.log(chalk.cyan(`\n Fields for ${selectedModel}:\n`));
277
+ for (const f of fields) {
278
+ const ref = f.ref ? chalk.dim(` -> ${f.ref}`) : "";
279
+ const req = f.required ? chalk.yellow(" *") : "";
280
+ console.log(` ${chalk.white(f.field)} ${chalk.dim(`(${f.type})`)}${ref}${req}`);
281
+ }
282
+ console.log();
283
+ // Step 3: Build stages iteratively
284
+ const pipeline = [];
285
+ let addMore = true;
286
+ while (addMore) {
287
+ const { stageType } = await inquirer.prompt([
288
+ {
289
+ type: "list",
290
+ name: "stageType",
291
+ message: `Add stage ${pipeline.length + 1}:`,
292
+ choices: [
293
+ { name: "$match — Filter documents", value: "$match" },
294
+ { name: "$group — Group and aggregate", value: "$group" },
295
+ { name: "$sort — Sort results", value: "$sort" },
296
+ { name: "$project — Include/exclude fields", value: "$project" },
297
+ { name: "$unwind — Deconstruct array field", value: "$unwind" },
298
+ { name: "$lookup — Join with another collection", value: "$lookup" },
299
+ { name: "$limit — Limit results", value: "$limit" },
300
+ { name: "$skip — Skip results", value: "$skip" },
301
+ { name: "$count — Count documents", value: "$count" },
302
+ { name: "$addFields — Add computed fields", value: "$addFields" },
303
+ new inquirer.Separator(),
304
+ { name: "Raw JSON stage", value: "$raw" },
305
+ new inquirer.Separator(),
306
+ { name: chalk.green("Done — preview and execute"), value: "$done" },
307
+ ],
308
+ },
309
+ ]);
310
+ if (stageType === "$done") {
311
+ addMore = false;
312
+ break;
313
+ }
314
+ const stage = await buildStage(stageType, fieldNames, fields);
315
+ if (stage) {
316
+ pipeline.push(stage);
317
+ console.log(chalk.dim(` Added: ${JSON.stringify(stage)}\n`));
318
+ }
319
+ }
320
+ if (pipeline.length === 0) {
321
+ cliError("Pipeline is empty — add at least one stage", EXIT_MISUSE);
322
+ }
323
+ // Step 4: Preview
324
+ console.log(chalk.cyan("\n Pipeline:\n"));
325
+ console.log(chalk.dim(" " + JSON.stringify(pipeline, null, 2).split("\n").join("\n ")));
326
+ console.log();
327
+ spinner.start({ text: "Running preview (first 5 results)...", color: "magenta" });
328
+ const previewPipeline = [...pipeline, { $limit: 5 }];
329
+ const previewRes = await apiCallHandler("aggregation", selectedModel, "post", previewPipeline);
330
+ spinner.stop();
331
+ const previewWrapped = wrapAggResult(previewRes);
332
+ handleResponseFormatOptions({ ...opts, responseFormat: "yaml" }, previewWrapped);
333
+ // Step 5: Action choice
334
+ const { action } = await inquirer.prompt([
335
+ {
336
+ type: "list",
337
+ name: "action",
338
+ message: "What would you like to do?",
339
+ choices: [
340
+ { name: "Execute full pipeline", value: "execute" },
341
+ { name: "Save as reusable query", value: "save" },
342
+ { name: "Execute and save", value: "both" },
343
+ { name: "Print pipeline JSON", value: "print" },
344
+ { name: "Cancel", value: "cancel" },
345
+ ],
346
+ },
347
+ ]);
348
+ if (action === "execute" || action === "both") {
349
+ spinner.start({ text: "Executing full pipeline...", color: "magenta" });
350
+ const res = await apiCallHandler("aggregation", selectedModel, "post", pipeline);
351
+ const wrapped = wrapAggResult(res);
352
+ handleResponseFormatOptions(opts, wrapped);
353
+ spinner.success({ text: `Pipeline returned ${(_c = (_b = wrapped.meta) === null || _b === void 0 ? void 0 : _b.resultCount) !== null && _c !== void 0 ? _c : "?"} results` });
354
+ }
355
+ if (action === "save" || action === "both") {
356
+ const { queryName } = await inquirer.prompt([
357
+ {
358
+ type: "input",
359
+ name: "queryName",
360
+ message: "Query name:",
361
+ validate: (v) => v.length > 0 || "Name is required",
362
+ },
363
+ ]);
364
+ spinner.start({ text: "Saving query...", color: "magenta" });
365
+ const body = {
366
+ name: queryName,
367
+ dataSource: {
368
+ source: "aggregation",
369
+ aggregationModel: selectedModel,
370
+ aggregationPipeline: JSON.stringify(pipeline),
371
+ },
372
+ cache: { enabled: false },
373
+ };
374
+ const saveRes = await apiCallHandler("config", "queries", "post", body, {
375
+ responseFormat: "json",
376
+ });
377
+ const slug = ((_d = saveRes.payload) === null || _d === void 0 ? void 0 : _d.slug) || ((_e = saveRes.payload) === null || _e === void 0 ? void 0 : _e.name) || queryName;
378
+ spinner.success({ text: `Query saved — run with: ay agg exec ${slug}` });
379
+ }
380
+ if (action === "print") {
381
+ console.log(JSON.stringify(pipeline, null, 2));
382
+ }
383
+ }
384
+ catch (e) {
385
+ cliError(e.message || "Wizard failed", EXIT_GENERAL_ERROR);
386
+ }
387
+ });
388
+ }
389
+ // ─── Stage Builders ────────────────────────────────────────────────
390
+ async function buildStage(stageType, fieldNames, fields) {
391
+ switch (stageType) {
392
+ case "$match":
393
+ return buildMatchStage(fieldNames, fields);
394
+ case "$group":
395
+ return buildGroupStage(fieldNames);
396
+ case "$sort":
397
+ return buildSortStage(fieldNames);
398
+ case "$project":
399
+ return buildProjectStage(fieldNames);
400
+ case "$unwind":
401
+ return buildUnwindStage(fieldNames);
402
+ case "$lookup":
403
+ return buildLookupStage();
404
+ case "$limit":
405
+ return buildNumberStage("$limit", "Maximum documents to return:");
406
+ case "$skip":
407
+ return buildNumberStage("$skip", "Documents to skip:");
408
+ case "$count":
409
+ return buildCountStage();
410
+ case "$addFields":
411
+ return buildRawStage("$addFields");
412
+ case "$raw":
413
+ return buildFullRawStage();
414
+ default:
415
+ return null;
416
+ }
417
+ }
418
+ async function buildMatchStage(fieldNames, fields) {
419
+ const match = {};
420
+ let addMore = true;
421
+ while (addMore) {
422
+ const { field } = await inquirer.prompt([
423
+ { type: "search-list", name: "field", message: "Field to match:", choices: fieldNames },
424
+ ]);
425
+ const fieldMeta = fields.find((f) => f.field === field);
426
+ const isNumber = (fieldMeta === null || fieldMeta === void 0 ? void 0 : fieldMeta.type) === "Number";
427
+ const isDate = (fieldMeta === null || fieldMeta === void 0 ? void 0 : fieldMeta.type) === "Date";
428
+ const isBool = (fieldMeta === null || fieldMeta === void 0 ? void 0 : fieldMeta.type) === "Boolean";
429
+ const { operator } = await inquirer.prompt([
430
+ {
431
+ type: "list",
432
+ name: "operator",
433
+ message: "Operator:",
434
+ choices: [
435
+ { name: "equals", value: "eq" },
436
+ { name: "not equals", value: "$ne" },
437
+ { name: "greater than", value: "$gt" },
438
+ { name: "less than", value: "$lt" },
439
+ { name: "greater or equal", value: "$gte" },
440
+ { name: "less or equal", value: "$lte" },
441
+ { name: "in (comma-separated)", value: "$in" },
442
+ { name: "not in", value: "$nin" },
443
+ { name: "regex", value: "$regex" },
444
+ { name: "exists", value: "$exists" },
445
+ ],
446
+ },
447
+ ]);
448
+ let value;
449
+ if (operator === "$exists") {
450
+ const { exists } = await inquirer.prompt([
451
+ { type: "confirm", name: "exists", message: "Field should exist?", default: true },
452
+ ]);
453
+ value = exists;
454
+ match[field] = { $exists: value };
455
+ }
456
+ else if (isBool) {
457
+ const { boolVal } = await inquirer.prompt([
458
+ { type: "confirm", name: "boolVal", message: `${field} =`, default: true },
459
+ ]);
460
+ match[field] = operator === "eq" ? boolVal : { [operator]: boolVal };
461
+ }
462
+ else if (operator === "$in" || operator === "$nin") {
463
+ const { vals } = await inquirer.prompt([
464
+ { type: "input", name: "vals", message: "Values (comma-separated):" },
465
+ ]);
466
+ const arr = vals.split(",").map((v) => coerceValue(v.trim(), isNumber, isDate));
467
+ match[field] = { [operator]: arr };
468
+ }
469
+ else {
470
+ const { val } = await inquirer.prompt([
471
+ { type: "input", name: "val", message: "Value:" },
472
+ ]);
473
+ const coerced = coerceValue(val, isNumber, isDate);
474
+ match[field] = operator === "eq" ? coerced : { [operator]: coerced };
475
+ }
476
+ const { more } = await inquirer.prompt([
477
+ { type: "confirm", name: "more", message: "Add another match condition?", default: false },
478
+ ]);
479
+ addMore = more;
480
+ }
481
+ return { $match: match };
482
+ }
483
+ async function buildGroupStage(fieldNames) {
484
+ const { idType } = await inquirer.prompt([
485
+ {
486
+ type: "list",
487
+ name: "idType",
488
+ message: "Group by:",
489
+ choices: [
490
+ { name: "Single field", value: "single" },
491
+ { name: "Multiple fields", value: "multi" },
492
+ { name: "null (aggregate all)", value: "null" },
493
+ ],
494
+ },
495
+ ]);
496
+ let _id = null;
497
+ if (idType === "single") {
498
+ const { field } = await inquirer.prompt([
499
+ { type: "search-list", name: "field", message: "Group by field:", choices: fieldNames },
500
+ ]);
501
+ _id = `$${field}`;
502
+ }
503
+ else if (idType === "multi") {
504
+ const { selected } = await inquirer.prompt([
505
+ { type: "checkbox", name: "selected", message: "Select fields:", choices: fieldNames },
506
+ ]);
507
+ _id = {};
508
+ for (const f of selected)
509
+ _id[f] = `$${f}`;
510
+ }
511
+ const group = { _id };
512
+ let addAcc = true;
513
+ while (addAcc) {
514
+ const { outputName } = await inquirer.prompt([
515
+ { type: "input", name: "outputName", message: "Output field name:", validate: (v) => v.length > 0 },
516
+ ]);
517
+ const { accumulator } = await inquirer.prompt([
518
+ {
519
+ type: "list",
520
+ name: "accumulator",
521
+ message: "Accumulator:",
522
+ choices: ["$sum", "$avg", "$min", "$max", "$count", "$push", "$first", "$last"],
523
+ },
524
+ ]);
525
+ if (accumulator === "$count") {
526
+ group[outputName] = { $count: {} };
527
+ }
528
+ else {
529
+ const { srcField } = await inquirer.prompt([
530
+ { type: "search-list", name: "srcField", message: "Source field:", choices: ["1 (constant)", ...fieldNames] },
531
+ ]);
532
+ group[outputName] = { [accumulator]: srcField === "1 (constant)" ? 1 : `$${srcField}` };
533
+ }
534
+ const { more } = await inquirer.prompt([
535
+ { type: "confirm", name: "more", message: "Add another accumulator?", default: false },
536
+ ]);
537
+ addAcc = more;
538
+ }
539
+ return { $group: group };
540
+ }
541
+ async function buildSortStage(fieldNames) {
542
+ const sort = {};
543
+ let addMore = true;
544
+ while (addMore) {
545
+ const { field } = await inquirer.prompt([
546
+ { type: "search-list", name: "field", message: "Sort by field:", choices: fieldNames },
547
+ ]);
548
+ const { direction } = await inquirer.prompt([
549
+ {
550
+ type: "list",
551
+ name: "direction",
552
+ message: "Direction:",
553
+ choices: [
554
+ { name: "Ascending (1)", value: 1 },
555
+ { name: "Descending (-1)", value: -1 },
556
+ ],
557
+ },
558
+ ]);
559
+ sort[field] = direction;
560
+ const { more } = await inquirer.prompt([
561
+ { type: "confirm", name: "more", message: "Add another sort field?", default: false },
562
+ ]);
563
+ addMore = more;
564
+ }
565
+ return { $sort: sort };
566
+ }
567
+ async function buildProjectStage(fieldNames) {
568
+ const { mode } = await inquirer.prompt([
569
+ {
570
+ type: "list",
571
+ name: "mode",
572
+ message: "Projection mode:",
573
+ choices: [
574
+ { name: "Include selected fields (1)", value: "include" },
575
+ { name: "Exclude selected fields (0)", value: "exclude" },
576
+ ],
577
+ },
578
+ ]);
579
+ const { selected } = await inquirer.prompt([
580
+ {
581
+ type: "checkbox",
582
+ name: "selected",
583
+ message: `Fields to ${mode}:`,
584
+ choices: fieldNames,
585
+ validate: (v) => v.length > 0 || "Select at least one field",
586
+ },
587
+ ]);
588
+ const project = {};
589
+ for (const f of selected)
590
+ project[f] = mode === "include" ? 1 : 0;
591
+ return { $project: project };
592
+ }
593
+ async function buildUnwindStage(fieldNames) {
594
+ const { field } = await inquirer.prompt([
595
+ { type: "search-list", name: "field", message: "Array field to unwind:", choices: fieldNames },
596
+ ]);
597
+ return { $unwind: `$${field}` };
598
+ }
599
+ async function buildLookupStage() {
600
+ const answers = await inquirer.prompt([
601
+ { type: "input", name: "from", message: "Foreign collection (lowercase):" },
602
+ { type: "input", name: "localField", message: "Local field:" },
603
+ { type: "input", name: "foreignField", message: "Foreign field:", default: "_id" },
604
+ { type: "input", name: "as", message: "Output array field name:" },
605
+ ]);
606
+ return { $lookup: answers };
607
+ }
608
+ async function buildNumberStage(op, message) {
609
+ const { num } = await inquirer.prompt([
610
+ { type: "input", name: "num", message, validate: (v) => !isNaN(parseInt(v)) || "Must be a number" },
611
+ ]);
612
+ return { [op]: parseInt(num) };
613
+ }
614
+ async function buildCountStage() {
615
+ const { field } = await inquirer.prompt([
616
+ { type: "input", name: "field", message: "Count output field name:", default: "count" },
617
+ ]);
618
+ return { $count: field };
619
+ }
620
+ async function buildRawStage(op) {
621
+ const { json } = await inquirer.prompt([
622
+ { type: "input", name: "json", message: `${op} body (JSON):` },
623
+ ]);
624
+ try {
625
+ return { [op]: JSON.parse(json) };
626
+ }
627
+ catch (_a) {
628
+ console.log(chalk.red(" Invalid JSON — stage skipped"));
629
+ return null;
630
+ }
631
+ }
632
+ async function buildFullRawStage() {
633
+ const { json } = await inquirer.prompt([
634
+ { type: "input", name: "json", message: "Full stage (JSON object):" },
635
+ ]);
636
+ try {
637
+ return JSON.parse(json);
638
+ }
639
+ catch (_a) {
640
+ console.log(chalk.red(" Invalid JSON — stage skipped"));
641
+ return null;
642
+ }
643
+ }
644
+ function coerceValue(val, isNumber, isDate) {
645
+ if (val === "true")
646
+ return true;
647
+ if (val === "false")
648
+ return false;
649
+ if (val === "null")
650
+ return null;
651
+ if (isNumber && !isNaN(Number(val)))
652
+ return Number(val);
653
+ if (isDate && !isNaN(Date.parse(val)))
654
+ return { $date: val };
655
+ return val;
656
+ }