@tolinax/ayoune-cli 2026.6.0 → 2026.7.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.
@@ -1,14 +1,11 @@
1
1
  import chalk from "chalk";
2
+ import inquirer from "inquirer";
2
3
  import { spinner } from "../../index.js";
3
4
  import { EXIT_GENERAL_ERROR, EXIT_MISUSE } from "../exitCodes.js";
4
5
  import { cliError } from "../helpers/cliError.js";
5
6
  import { handleResponseFormatOptions } from "../helpers/handleResponseFormatOptions.js";
6
- import { executeCopy, getDbStats } from "../db/copyEngine.js";
7
- import { addCopyConfig, loadCopyConfigs, removeCopyConfig, getDecryptedConfig, updateCopyConfigLastRun, maskUri, } from "../db/copyConfigStore.js";
8
- import { isCronDue, getNextRun, validateCron } from "../db/cronMatcher.js";
9
- function generateId() {
10
- return Math.random().toString(36).substring(2, 10) + Date.now().toString(36);
11
- }
7
+ import { apiCallHandler } from "../api/apiCallHandler.js";
8
+ import { writeToTarget, getDbStats } from "../db/copyEngine.js";
12
9
  function formatBytes(bytes) {
13
10
  if (bytes === 0)
14
11
  return "0 B";
@@ -16,242 +13,232 @@ function formatBytes(bytes) {
16
13
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
17
14
  return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
18
15
  }
16
+ function maskUri(uri) {
17
+ return uri.replace(/:\/\/([^:]+):([^@]+)@/, "://***:***@");
18
+ }
19
19
  export function createDbCommand(program) {
20
20
  const db = program
21
21
  .command("db")
22
- .description("MongoDB database operations (copy, stats, scheduled replication)")
22
+ .description("Copy query results to external databases, manage targets")
23
23
  .addHelpText("after", `
24
24
  Examples:
25
- ay db copy --from "mongodb://..." --to "mongodb://..." --collections "users,orders"
26
- ay db copy --from "mongodb://..." --to "mongodb://..." --all --schedule "0 */6 * * *"
27
- ay db schedules list
28
- ay db stats "mongodb+srv://..."`);
29
- // ─── ay db copy ─────────────────────────────────────────────────
30
- db.command("copy")
31
- .description("Copy data between MongoDB instances")
32
- .requiredOption("--from <uri>", "Source MongoDB connection URI")
33
- .requiredOption("--to <uri>", "Target MongoDB connection URI")
34
- .option("--collections <list>", "Comma-separated collection names")
35
- .option("--all", "Copy all collections")
36
- .option("--query <json>", "JSON filter query for source documents")
37
- .option("--drop", "Drop target collection before copying")
38
- .option("--upsert", "Upsert documents by _id (idempotent, safe to re-run)")
39
- .option("--batch-size <number>", "Documents per batch", parseInt, 1000)
40
- .option("--schedule <cron>", "Save as scheduled copy (cron expression) instead of executing")
25
+ ay db copy Interactive: pick target, pick query
26
+ ay db copy my-local-db Pick query for target "my-local-db"
27
+ ay db copy my-local-db active-users Fully autonomous: run query, write to target
28
+ ay db targets list List configured targets
29
+ ay db targets add Add a new target
30
+ ay db stats Show aYOUne database stats`);
31
+ // ─── ay db copy [target] [query] ───────────────────────────────
32
+ db.command("copy [target] [query]")
33
+ .description("Run a saved query and write results to a target database")
34
+ .option("--collection <name>", "Override target collection name")
35
+ .option("--write-mode <mode>", "Override write mode (insert, upsert, replace)")
41
36
  .addHelpText("after", `
37
+ Runs a saved aggregation query and writes the results to a pre-configured target.
38
+ Both target and query are resolved by slug. If omitted, you'll be prompted to select.
39
+
42
40
  Examples:
43
- ay db copy --from "mongodb://..." --to "mongodb://..." --collections "users,orders"
44
- ay db copy --from "mongodb://..." --to "mongodb://..." --all --drop
45
- ay db copy --from "mongodb://..." --to "mongodb://..." --collections "logs" --query '{"status":"active"}'
46
- ay db copy --from "mongodb://..." --to "mongodb://..." --all --upsert --schedule "0 */6 * * *"`)
47
- .action(async (options) => {
41
+ ay db copy Interactive mode
42
+ ay db copy my-local-db Select query interactively
43
+ ay db copy my-local-db active-users Fully autonomous
44
+ ay db copy my-local-db active-users --collection results --write-mode upsert`)
45
+ .action(async (target, query, options) => {
46
+ var _a;
48
47
  try {
49
48
  const opts = { ...program.opts(), ...options };
50
- // Validate input
51
- if (!opts.collections && !opts.all) {
52
- cliError("Provide --collections or --all", EXIT_MISUSE);
49
+ // Step 1: Resolve target
50
+ let targetData;
51
+ if (target) {
52
+ targetData = await resolveTarget(target);
53
53
  }
54
- if (opts.collections && opts.all) {
55
- cliError("Use either --collections or --all, not both", EXIT_MISUSE);
56
- }
57
- if (!opts.from.startsWith("mongodb")) {
58
- cliError("--from must be a MongoDB URI (mongodb:// or mongodb+srv://)", EXIT_MISUSE);
54
+ else {
55
+ if (!process.stdin.isTTY)
56
+ cliError("Provide target slug or use interactive mode", EXIT_MISUSE);
57
+ targetData = await promptTarget();
59
58
  }
60
- if (!opts.to.startsWith("mongodb")) {
61
- cliError("--to must be a MongoDB URI (mongodb:// or mongodb+srv://)", EXIT_MISUSE);
59
+ // Step 2: Resolve query
60
+ let queryData;
61
+ if (query) {
62
+ queryData = await resolveQuery(query);
62
63
  }
63
- let query;
64
- if (opts.query) {
65
- try {
66
- query = JSON.parse(opts.query);
67
- }
68
- catch (_a) {
69
- cliError("--query must be valid JSON", EXIT_MISUSE);
70
- }
64
+ else {
65
+ if (!process.stdin.isTTY)
66
+ cliError("Provide query slug or use interactive mode", EXIT_MISUSE);
67
+ queryData = await promptQuery();
71
68
  }
72
- const collections = opts.all ? ["*"] : opts.collections.split(",").map((c) => c.trim());
73
- // Schedule mode
74
- if (opts.schedule) {
75
- const cronError = validateCron(opts.schedule);
76
- if (cronError)
77
- cliError(`Invalid cron expression: ${cronError}`, EXIT_MISUSE);
78
- const config = {
79
- id: generateId(),
80
- createdAt: new Date().toISOString(),
81
- from: maskUri(opts.from),
82
- to: maskUri(opts.to),
83
- fromUri: opts.from, // Will be encrypted by addCopyConfig
84
- toUri: opts.to,
85
- collections,
86
- query,
87
- drop: opts.drop || false,
88
- upsert: opts.upsert || false,
89
- batchSize: opts.batchSize,
90
- schedule: opts.schedule,
91
- };
92
- addCopyConfig(config);
93
- const nextRun = getNextRun(opts.schedule);
94
- console.log(chalk.green(`\n Scheduled copy saved (ID: ${config.id})`));
95
- console.log(chalk.dim(` Source: ${config.from}`));
96
- console.log(chalk.dim(` Target: ${config.to}`));
97
- console.log(chalk.dim(` Collections: ${collections.join(", ")}`));
98
- console.log(chalk.dim(` Schedule: ${opts.schedule}`));
99
- if (nextRun)
100
- console.log(chalk.dim(` Next run: ${nextRun.toISOString()}`));
101
- console.log();
102
- console.log(chalk.yellow(" Set up a cron job to run scheduled copies:"));
103
- console.log(chalk.dim(" Linux/macOS: crontab -e → */5 * * * * ay db schedules run"));
104
- console.log(chalk.dim(" Windows: schtasks /create /tn ayoune-db-sync /tr \"ay db schedules run\" /sc minute /mo 5"));
105
- console.log();
69
+ // Step 3: Execute query via aggregation API
70
+ const queryId = queryData._id;
71
+ spinner.start({ text: `Running query "${queryData.name}"...`, color: "magenta" });
72
+ const aggResult = await apiCallHandler("aggregation", `queries/execute/${queryId}`, "post");
73
+ spinner.stop();
74
+ const docs = Array.isArray(aggResult) ? aggResult : ((aggResult === null || aggResult === void 0 ? void 0 : aggResult.payload) || []);
75
+ if (!docs.length) {
76
+ spinner.success({ text: "Query returned 0 results — nothing to write" });
106
77
  return;
107
78
  }
108
- // Execute copy
109
- spinner.start({ text: `Copying from ${maskUri(opts.from)} to ${maskUri(opts.to)}...`, color: "cyan" });
110
- const summary = await executeCopy(opts.from, opts.to, collections, {
111
- query,
112
- drop: opts.drop,
113
- upsert: opts.upsert,
114
- batchSize: opts.batchSize,
115
- }, (progress) => {
116
- spinner.update({
117
- text: `${progress.collection}: ${progress.copied}/${progress.total} docs${progress.errors ? chalk.red(` (${progress.errors} errors)`) : ""}`,
118
- });
119
- });
79
+ console.log(chalk.dim(` Query returned ${docs.length} documents\n`));
80
+ // Step 4: Write to target
81
+ const collection = opts.collection || targetData.targetCollection || ((_a = queryData.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().replace(/\s+/g, "_")) || "query_results";
82
+ const writeMode = opts.writeMode || targetData.writeMode || "insert";
83
+ const batchSize = targetData.batchSize || 1000;
84
+ spinner.start({ text: `Writing ${docs.length} docs to ${maskUri(targetData.connectionUri)}/${collection}...`, color: "cyan" });
85
+ const result = await writeToTarget(targetData.connectionUri, targetData.database, collection, docs, writeMode, batchSize);
120
86
  spinner.stop();
121
- // Display summary
87
+ // Step 5: Update target status via config-api
88
+ try {
89
+ await apiCallHandler("config", `datatargets/${targetData._id}`, "put", {
90
+ lastUsed: new Date().toISOString(),
91
+ lastStatus: result.errors > 0 ? "failed" : "success",
92
+ lastResultCount: result.written,
93
+ lastError: result.errors > 0 ? `${result.errors} write errors` : undefined,
94
+ });
95
+ }
96
+ catch (_b) {
97
+ // Non-critical — don't fail the copy if status update fails
98
+ }
99
+ // Output
122
100
  const wrapped = {
123
- payload: summary,
124
- meta: {
125
- totalCopied: summary.totalCopied,
126
- totalErrors: summary.totalErrors,
127
- duration: summary.duration,
101
+ payload: {
102
+ target: targetData.name,
103
+ query: queryData.name,
104
+ collection,
105
+ writeMode,
106
+ written: result.written,
107
+ errors: result.errors,
108
+ duration: result.duration,
128
109
  },
110
+ meta: { written: result.written, errors: result.errors },
129
111
  };
130
112
  if (opts.responseFormat === "json" || opts.responseFormat === "yaml") {
131
113
  handleResponseFormatOptions(opts, wrapped);
132
114
  }
133
115
  else {
134
- console.log(chalk.cyan.bold("\n Copy Summary\n"));
135
- for (const col of summary.collections) {
136
- const icon = col.errors === 0 ? chalk.green("●") : chalk.yellow("●");
137
- console.log(` ${icon} ${col.name}: ${col.copied} docs copied${col.errors ? chalk.red(` (${col.errors} errors)`) : ""} ${chalk.dim(`(${col.duration}ms)`)}`);
138
- }
139
- console.log();
140
- console.log(` ${chalk.green(summary.totalCopied + " total")}${summary.totalErrors ? ` ${chalk.red(summary.totalErrors + " errors")}` : ""} ${chalk.dim(`in ${summary.duration}ms`)}`);
141
- console.log();
116
+ const icon = result.errors === 0 ? chalk.green("●") : chalk.yellow("●");
117
+ console.log(`\n ${icon} ${result.written} docs written to ${chalk.cyan(collection)}${result.errors ? chalk.red(` (${result.errors} errors)`) : ""} ${chalk.dim(`(${result.duration}ms)`)}\n`);
142
118
  }
143
- if (summary.totalErrors > 0)
119
+ if (result.errors > 0)
144
120
  process.exit(EXIT_GENERAL_ERROR);
145
121
  }
146
122
  catch (e) {
147
123
  cliError(e.message || "Copy failed", EXIT_GENERAL_ERROR);
148
124
  }
149
125
  });
150
- // ─── ay db schedules ────────────────────────────────────────────
151
- const schedules = db.command("schedules").alias("sched").description("Manage scheduled copy jobs");
152
- // ay db schedules list
153
- schedules
126
+ // ─── ay db targets ─────────────────────────────────────────────
127
+ const targets = db.command("targets").alias("t").description("Manage database targets");
128
+ // ay db targets list
129
+ targets
154
130
  .command("list")
155
131
  .alias("ls")
156
- .description("List all scheduled copy jobs")
132
+ .description("List configured database targets")
133
+ .option("-l, --limit <number>", "Limit results", parseInt, 50)
157
134
  .action(async (options) => {
135
+ var _a, _b, _c, _d, _e;
158
136
  try {
159
137
  const opts = { ...program.opts(), ...options };
160
- const configs = loadCopyConfigs();
161
- if (configs.length === 0) {
162
- console.log(chalk.dim("\n No scheduled copies configured.\n"));
163
- return;
164
- }
165
- const wrapped = { payload: configs.map(displayConfig), meta: { total: configs.length } };
166
- handleResponseFormatOptions(opts, wrapped);
138
+ spinner.start({ text: "Fetching targets...", color: "cyan" });
139
+ const res = await apiCallHandler("config", "datatargets", "get", null, {
140
+ limit: opts.limit,
141
+ responseFormat: opts.responseFormat,
142
+ verbosity: opts.verbosity,
143
+ });
144
+ handleResponseFormatOptions(opts, res);
145
+ 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;
146
+ spinner.success({ text: `${total} targets configured` });
167
147
  }
168
148
  catch (e) {
169
- cliError(e.message || "Failed to list schedules", EXIT_GENERAL_ERROR);
149
+ cliError(e.message || "Failed to list targets", EXIT_GENERAL_ERROR);
170
150
  }
171
151
  });
172
- // ay db schedules remove <id>
173
- schedules
174
- .command("remove <id>")
175
- .alias("rm")
176
- .description("Remove a scheduled copy job")
177
- .action(async (id) => {
178
- const removed = removeCopyConfig(id);
179
- if (removed) {
180
- spinner.success({ text: `Schedule ${id} removed` });
152
+ // ay db targets add
153
+ targets
154
+ .command("add")
155
+ .description("Add a new database target")
156
+ .option("--name <name>", "Target name")
157
+ .option("--uri <uri>", "MongoDB connection URI")
158
+ .option("--database <db>", "Database name")
159
+ .option("--collection <col>", "Default target collection name")
160
+ .option("--write-mode <mode>", "Write mode: insert, upsert, replace", "insert")
161
+ .action(async (options) => {
162
+ var _a;
163
+ try {
164
+ const opts = { ...program.opts(), ...options };
165
+ let { name, uri, database, collection, writeMode } = opts;
166
+ // Interactive prompts for missing fields
167
+ if (!name || !uri) {
168
+ if (!process.stdin.isTTY)
169
+ cliError("Provide --name and --uri in non-interactive mode", EXIT_MISUSE);
170
+ const answers = await inquirer.prompt([
171
+ ...(!name ? [{ type: "input", name: "name", message: "Target name:", validate: (v) => v.length > 0 || "Required" }] : []),
172
+ ...(!uri ? [{ type: "input", name: "uri", message: "MongoDB connection URI:", validate: (v) => v.startsWith("mongodb") || "Must be a MongoDB URI" }] : []),
173
+ ...(!database ? [{ type: "input", name: "database", message: "Database name (leave empty for URI default):" }] : []),
174
+ ...(!collection ? [{ type: "input", name: "collection", message: "Default collection name (leave empty to derive from query):" }] : []),
175
+ ]);
176
+ name = name || answers.name;
177
+ uri = uri || answers.uri;
178
+ database = database || answers.database || undefined;
179
+ collection = collection || answers.collection || undefined;
180
+ }
181
+ spinner.start({ text: "Creating target...", color: "cyan" });
182
+ const body = {
183
+ name,
184
+ type: "mongodb",
185
+ connectionUri: uri,
186
+ database: database || undefined,
187
+ targetCollection: collection || undefined,
188
+ writeMode: writeMode || "insert",
189
+ active: true,
190
+ };
191
+ const res = await apiCallHandler("config", "datatargets", "post", body, {
192
+ responseFormat: opts.responseFormat,
193
+ });
194
+ const slug = ((_a = res.payload) === null || _a === void 0 ? void 0 : _a.slug) || name;
195
+ spinner.success({ text: `Target "${slug}" created — use with: ay db copy ${slug}` });
181
196
  }
182
- else {
183
- cliError(`No schedule found with ID "${id}"`, EXIT_GENERAL_ERROR);
197
+ catch (e) {
198
+ cliError(e.message || "Failed to create target", EXIT_GENERAL_ERROR);
184
199
  }
185
200
  });
186
- // ay db schedules run
187
- schedules
188
- .command("run")
189
- .description("Execute all copy jobs that are due now")
190
- .option("--id <id>", "Run a specific schedule by ID")
191
- .action(async (options) => {
201
+ // ay db targets remove <slug>
202
+ targets
203
+ .command("remove <slug>")
204
+ .alias("rm")
205
+ .description("Remove a database target")
206
+ .action(async (slug, options) => {
192
207
  try {
193
- const configs = loadCopyConfigs();
194
- const now = new Date();
195
- const toRun = options.id
196
- ? configs.filter((c) => c.id === options.id)
197
- : configs.filter((c) => isCronDue(c.schedule, c.lastRun, now));
198
- if (toRun.length === 0) {
199
- console.log(chalk.dim("\n No copies due.\n"));
200
- return;
201
- }
202
- console.log(chalk.cyan(`\n Running ${toRun.length} scheduled copies...\n`));
203
- for (const config of toRun) {
204
- const { fromUri, toUri } = getDecryptedConfig(config);
205
- console.log(chalk.dim(` ${config.id}: ${config.from} → ${config.to}`));
206
- try {
207
- spinner.start({ text: `${config.id}: copying...`, color: "cyan" });
208
- const summary = await executeCopy(fromUri, toUri, config.collections, {
209
- query: config.query,
210
- drop: config.drop,
211
- upsert: config.upsert,
212
- batchSize: config.batchSize,
213
- }, (progress) => {
214
- spinner.update({
215
- text: `${config.id}: ${progress.collection} ${progress.copied}/${progress.total}`,
216
- });
217
- });
218
- updateCopyConfigLastRun(config.id, "success");
219
- spinner.success({ text: `${config.id}: ${summary.totalCopied} docs copied${summary.totalErrors ? `, ${summary.totalErrors} errors` : ""}` });
220
- }
221
- catch (e) {
222
- updateCopyConfigLastRun(config.id, "failed", e.message);
223
- spinner.error({ text: `${config.id}: ${e.message}` });
224
- }
225
- }
226
- console.log();
208
+ const target = await resolveTarget(slug);
209
+ spinner.start({ text: `Removing target "${slug}"...`, color: "cyan" });
210
+ await apiCallHandler("config", `datatargets/${target._id}`, "delete");
211
+ spinner.success({ text: `Target "${slug}" removed` });
227
212
  }
228
213
  catch (e) {
229
- cliError(e.message || "Scheduled run failed", EXIT_GENERAL_ERROR);
214
+ cliError(e.message || "Failed to remove target", EXIT_GENERAL_ERROR);
230
215
  }
231
216
  });
232
- // ─── ay db stats <uri> ──────────────────────────────────────────
233
- db.command("stats <uri>")
234
- .description("Show database statistics (collections, doc counts, sizes)")
217
+ // ─── ay db stats [uri] ─────────────────────────────────────────
218
+ db.command("stats [uri]")
219
+ .description("Show database statistics (defaults to aYOUne database)")
235
220
  .addHelpText("after", `
221
+ Defaults to MONGO_CONNECTSTRING when no URI is given.
222
+
236
223
  Examples:
237
- ay db stats "mongodb+srv://user:pass@cluster.mongodb.net/mydb"
238
- ay db stats "mongodb://localhost:27017/testdb" -r table`)
224
+ ay db stats
225
+ ay db stats "mongodb://other/db" -r table`)
239
226
  .action(async (uri, options) => {
240
227
  try {
241
228
  const opts = { ...program.opts(), ...options };
242
- if (!uri.startsWith("mongodb")) {
229
+ const dbUri = uri || process.env.MONGO_CONNECTSTRING || process.env.MONGODB_URI;
230
+ if (!dbUri) {
231
+ cliError("No database URI. Set MONGO_CONNECTSTRING env var or pass a URI.", EXIT_MISUSE);
232
+ }
233
+ if (!dbUri.startsWith("mongodb")) {
243
234
  cliError("URI must start with mongodb:// or mongodb+srv://", EXIT_MISUSE);
244
235
  }
245
- spinner.start({ text: `Connecting to ${maskUri(uri)}...`, color: "cyan" });
246
- const stats = await getDbStats(uri);
236
+ spinner.start({ text: `Connecting to ${maskUri(dbUri)}...`, color: "cyan" });
237
+ const stats = await getDbStats(dbUri);
247
238
  spinner.stop();
248
239
  const wrapped = {
249
240
  payload: stats,
250
- meta: {
251
- database: stats.database,
252
- collectionCount: stats.collections.length,
253
- totalSize: formatBytes(stats.totalSize),
254
- },
241
+ meta: { database: stats.database, collectionCount: stats.collections.length, totalSize: formatBytes(stats.totalSize) },
255
242
  };
256
243
  if (opts.responseFormat === "json" || opts.responseFormat === "yaml") {
257
244
  handleResponseFormatOptions(opts, wrapped);
@@ -271,16 +258,75 @@ Examples:
271
258
  }
272
259
  });
273
260
  }
274
- function displayConfig(config) {
275
- const nextRun = getNextRun(config.schedule);
276
- return {
277
- id: config.id,
278
- from: config.from,
279
- to: config.to,
280
- collections: config.collections.join(", "),
281
- schedule: config.schedule,
282
- nextRun: (nextRun === null || nextRun === void 0 ? void 0 : nextRun.toISOString()) || "—",
283
- lastRun: config.lastRun || "never",
284
- lastStatus: config.lastStatus || "",
285
- };
261
+ // ─── Helpers ───────────────────────────────────────────────────────
262
+ async function resolveTarget(slugOrId) {
263
+ const isObjectId = /^[a-f0-9]{24}$/i.test(slugOrId);
264
+ if (isObjectId) {
265
+ const res = await apiCallHandler("config", `datatargets/${slugOrId}`, "get");
266
+ if (!(res === null || res === void 0 ? void 0 : res.payload))
267
+ cliError(`Target not found: ${slugOrId}`, EXIT_GENERAL_ERROR);
268
+ return res.payload;
269
+ }
270
+ // Resolve by slug
271
+ const res = await apiCallHandler("config", "datatargets", "get", null, { slug: slugOrId, limit: 1 });
272
+ const entries = res === null || res === void 0 ? void 0 : res.payload;
273
+ if (!entries || (Array.isArray(entries) && entries.length === 0)) {
274
+ cliError(`No target found with slug "${slugOrId}"`, EXIT_GENERAL_ERROR);
275
+ }
276
+ return Array.isArray(entries) ? entries[0] : entries;
277
+ }
278
+ async function resolveQuery(slugOrId) {
279
+ const isObjectId = /^[a-f0-9]{24}$/i.test(slugOrId);
280
+ if (isObjectId) {
281
+ const res = await apiCallHandler("config", `queries/${slugOrId}`, "get");
282
+ if (!(res === null || res === void 0 ? void 0 : res.payload))
283
+ cliError(`Query not found: ${slugOrId}`, EXIT_GENERAL_ERROR);
284
+ return res.payload;
285
+ }
286
+ const res = await apiCallHandler("config", "queries", "get", null, { slug: slugOrId, limit: 1 });
287
+ const entries = res === null || res === void 0 ? void 0 : res.payload;
288
+ if (!entries || (Array.isArray(entries) && entries.length === 0)) {
289
+ cliError(`No query found with slug "${slugOrId}"`, EXIT_GENERAL_ERROR);
290
+ }
291
+ return Array.isArray(entries) ? entries[0] : entries;
292
+ }
293
+ async function promptTarget() {
294
+ spinner.start({ text: "Loading targets...", color: "cyan" });
295
+ const res = await apiCallHandler("config", "datatargets", "get", null, { limit: 100, active: true });
296
+ spinner.stop();
297
+ const targets = (res === null || res === void 0 ? void 0 : res.payload) || [];
298
+ if (targets.length === 0) {
299
+ cliError("No targets configured. Add one with: ay db targets add", EXIT_GENERAL_ERROR);
300
+ }
301
+ const choices = targets.map((t) => ({
302
+ name: `${t.name} ${chalk.dim(`(${maskUri(t.connectionUri)})`)}`,
303
+ value: t,
304
+ }));
305
+ const { selected } = await inquirer.prompt([
306
+ { type: "search-list", name: "selected", message: "Select target:", choices },
307
+ ]);
308
+ return selected;
309
+ }
310
+ async function promptQuery() {
311
+ spinner.start({ text: "Loading queries...", color: "magenta" });
312
+ const res = await apiCallHandler("config", "queries", "get", null, {
313
+ limit: 100,
314
+ "dataSource.source": "aggregation",
315
+ });
316
+ spinner.stop();
317
+ const queries = (res === null || res === void 0 ? void 0 : res.payload) || [];
318
+ if (queries.length === 0) {
319
+ cliError("No saved queries found. Create one with: ay agg save", EXIT_GENERAL_ERROR);
320
+ }
321
+ const choices = queries.map((q) => {
322
+ var _a;
323
+ return ({
324
+ name: `${q.name} ${chalk.dim(`(${((_a = q.dataSource) === null || _a === void 0 ? void 0 : _a.aggregationModel) || "?"})`)}`,
325
+ value: q,
326
+ });
327
+ });
328
+ const { selected } = await inquirer.prompt([
329
+ { type: "search-list", name: "selected", message: "Select query:", choices },
330
+ ]);
331
+ return selected;
286
332
  }
@@ -1,67 +1,37 @@
1
1
  import { MongoClient } from "mongodb";
2
- export async function executeCopy(fromUri, toUri, collections, options, onProgress) {
2
+ /**
3
+ * Writes aggregation query results to a target MongoDB collection.
4
+ * This is the core of the copy flow: query results from the aggregation API
5
+ * are written to an external MongoDB target.
6
+ */
7
+ export async function writeToTarget(targetUri, database, collection, docs, writeMode, batchSize = 1000) {
3
8
  const startTime = Date.now();
4
- const sourceClient = new MongoClient(fromUri);
5
- const targetClient = new MongoClient(toUri);
6
- const results = [];
9
+ const client = new MongoClient(targetUri);
10
+ let written = 0;
11
+ let errors = 0;
7
12
  try {
8
- await sourceClient.connect();
9
- await targetClient.connect();
10
- const sourceDb = sourceClient.db();
11
- const targetDb = targetClient.db();
12
- // Resolve collections if --all was used
13
- let collectionNames = collections;
14
- if (collections.length === 1 && collections[0] === "*") {
15
- const colls = await sourceDb.listCollections().toArray();
16
- collectionNames = colls
17
- .map((c) => c.name)
18
- .filter((n) => !n.startsWith("system."));
19
- }
20
- for (const name of collectionNames) {
21
- const colStart = Date.now();
22
- let copied = 0;
23
- let errors = 0;
24
- const sourceCol = sourceDb.collection(name);
25
- const targetCol = targetDb.collection(name);
26
- if (options.drop) {
27
- try {
28
- await targetCol.drop();
29
- }
30
- catch (_a) {
31
- // Collection may not exist — ignore
32
- }
33
- }
34
- const query = options.query || {};
35
- const total = await sourceCol.countDocuments(query);
36
- onProgress({ collection: name, copied: 0, total, errors: 0 });
37
- const cursor = sourceCol.find(query).batchSize(options.batchSize);
38
- let batch = [];
39
- for await (const doc of cursor) {
40
- batch.push(doc);
41
- if (batch.length >= options.batchSize) {
42
- const result = await writeBatch(targetCol, batch, options.upsert || false);
43
- copied += result.written;
44
- errors += result.errors;
45
- batch = [];
46
- onProgress({ collection: name, copied, total, errors });
47
- }
13
+ await client.connect();
14
+ const db = database ? client.db(database) : client.db();
15
+ const col = db.collection(collection);
16
+ if (writeMode === "replace") {
17
+ try {
18
+ await col.drop();
48
19
  }
49
- // Write remaining docs
50
- if (batch.length > 0) {
51
- const result = await writeBatch(targetCol, batch, options.upsert || false);
52
- copied += result.written;
53
- errors += result.errors;
54
- onProgress({ collection: name, copied, total, errors });
20
+ catch (_a) {
21
+ // Collection may not exist
55
22
  }
56
- results.push({ name, copied, errors, duration: Date.now() - colStart });
57
23
  }
58
- const totalCopied = results.reduce((s, r) => s + r.copied, 0);
59
- const totalErrors = results.reduce((s, r) => s + r.errors, 0);
60
- return { collections: results, totalCopied, totalErrors, duration: Date.now() - startTime };
24
+ // Write in batches
25
+ for (let i = 0; i < docs.length; i += batchSize) {
26
+ const batch = docs.slice(i, i + batchSize);
27
+ const result = await writeBatch(col, batch, writeMode === "upsert");
28
+ written += result.written;
29
+ errors += result.errors;
30
+ }
31
+ return { written, errors, duration: Date.now() - startTime };
61
32
  }
62
33
  finally {
63
- await sourceClient.close();
64
- await targetClient.close();
34
+ await client.close();
65
35
  }
66
36
  }
67
37
  async function writeBatch(collection, docs, upsert) {
@@ -84,10 +54,8 @@ async function writeBatch(collection, docs, upsert) {
84
54
  }
85
55
  }
86
56
  catch (e) {
87
- // Partial success on ordered:false — count what made it through
88
57
  const written = (_d = (_b = (_a = e.result) === null || _a === void 0 ? void 0 : _a.nInserted) !== null && _b !== void 0 ? _b : (_c = e.result) === null || _c === void 0 ? void 0 : _c.insertedCount) !== null && _d !== void 0 ? _d : 0;
89
- const errorCount = docs.length - written;
90
- return { written, errors: errorCount };
58
+ return { written, errors: docs.length - written };
91
59
  }
92
60
  }
93
61
  export async function getDbStats(uri) {
@@ -111,11 +79,7 @@ export async function getDbStats(uri) {
111
79
  }
112
80
  }
113
81
  collections.sort((a, b) => b.documents - a.documents);
114
- return {
115
- database: stats.db,
116
- collections,
117
- totalSize: stats.dataSize || 0,
118
- };
82
+ return { database: stats.db, collections, totalSize: stats.dataSize || 0 };
119
83
  }
120
84
  finally {
121
85
  await client.close();
@@ -1,43 +1,50 @@
1
- import { execSync } from "child_process";
1
+ import { exec } from "child_process";
2
2
  import chalk from "chalk";
3
3
  import { localStorage } from "./localStorage.js";
4
4
  const PKG_NAME = "@tolinax/ayoune-cli";
5
5
  const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
6
6
  /**
7
- * Non-blocking update check. Compares installed version against npm registry.
8
- * Caches the result to avoid hammering npm on every invocation.
9
- * Prints a notice to stderr if a newer version is available.
7
+ * Non-blocking update check. Fires an async npm view in the background.
8
+ * Shows cached notice immediately if one exists; refreshes the cache asynchronously.
10
9
  */
11
10
  export function checkForUpdates(currentVersion) {
12
11
  try {
13
- // Skip in CI environments
14
12
  if (process.env.CI || process.env.AYOUNE_NO_UPDATE_CHECK)
15
13
  return;
16
- // Throttle: only check every CHECK_INTERVAL_MS
14
+ // Show cached notice immediately (zero latency)
15
+ const cached = localStorage.getItem("updateCheckLatest");
16
+ if (cached && cached !== currentVersion && isNewerVersion(cached, currentVersion)) {
17
+ printUpdateNotice(currentVersion, cached);
18
+ }
19
+ // Throttle the actual npm check
17
20
  const lastCheck = localStorage.getItem("updateCheckTimestamp");
18
- if (lastCheck && Date.now() - parseInt(lastCheck, 10) < CHECK_INTERVAL_MS) {
19
- // Still show cached notice if there was one
20
- const cached = localStorage.getItem("updateCheckLatest");
21
- if (cached && cached !== currentVersion) {
22
- printUpdateNotice(currentVersion, cached);
23
- }
21
+ if (lastCheck && Date.now() - parseInt(lastCheck, 10) < CHECK_INTERVAL_MS)
24
22
  return;
25
- }
26
- // Run npm view in background-ish (sync but with short timeout)
27
- const latest = execSync(`npm view ${PKG_NAME} version`, {
28
- encoding: "utf-8",
29
- timeout: 5000,
30
- stdio: ["pipe", "pipe", "ignore"],
31
- }).trim();
32
- localStorage.setItem("updateCheckTimestamp", String(Date.now()));
33
- localStorage.setItem("updateCheckLatest", latest);
34
- if (latest && latest !== currentVersion) {
35
- printUpdateNotice(currentVersion, latest);
36
- }
23
+ // Fire-and-forget: async check, never blocks startup
24
+ exec(`npm view ${PKG_NAME} version`, { timeout: 5000 }, (err, stdout) => {
25
+ if (err)
26
+ return;
27
+ const latest = stdout.trim();
28
+ if (latest) {
29
+ localStorage.setItem("updateCheckTimestamp", String(Date.now()));
30
+ localStorage.setItem("updateCheckLatest", latest);
31
+ }
32
+ });
37
33
  }
38
34
  catch (_a) {
39
- // Silently ignore — network issues, npm not available, etc.
35
+ // Silently ignore
36
+ }
37
+ }
38
+ function isNewerVersion(latest, current) {
39
+ const l = latest.split(".").map(Number);
40
+ const c = current.split(".").map(Number);
41
+ for (let i = 0; i < Math.max(l.length, c.length); i++) {
42
+ if ((l[i] || 0) > (c[i] || 0))
43
+ return true;
44
+ if ((l[i] || 0) < (c[i] || 0))
45
+ return false;
40
46
  }
47
+ return false;
41
48
  }
42
49
  function printUpdateNotice(current, latest) {
43
50
  const msg = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tolinax/ayoune-cli",
3
- "version": "2026.6.0",
3
+ "version": "2026.7.0",
4
4
  "description": "CLI for the aYOUne Business-as-a-Service platform",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -119,7 +119,6 @@
119
119
  "chalk": "^5.3.0",
120
120
  "chalk-animation": "^2.0.3",
121
121
  "commander": "^12.0.0",
122
- "cron-parser": "^4.9.0",
123
122
  "figlet": "^1.7.0",
124
123
  "gradient-string": "^2.0.2",
125
124
  "inquirer": "^9.2.14",