@tolinax/ayoune-cli 2026.6.1 → 2026.7.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.
@@ -1,24 +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
- }
12
- function getSourceUri(opts) {
13
- const uri = opts.from || process.env.MONGO_CONNECTSTRING || process.env.MONGODB_URI;
14
- if (!uri) {
15
- cliError("No source database configured. Set MONGO_CONNECTSTRING env var or use --from <uri>", EXIT_MISUSE);
16
- }
17
- if (!uri.startsWith("mongodb")) {
18
- cliError("Source URI must be a MongoDB URI (mongodb:// or mongodb+srv://)", EXIT_MISUSE);
19
- }
20
- return uri;
21
- }
7
+ import { apiCallHandler } from "../api/apiCallHandler.js";
8
+ import { writeToTarget, getDbStats } from "../db/copyEngine.js";
22
9
  function formatBytes(bytes) {
23
10
  if (bytes === 0)
24
11
  return "0 B";
@@ -26,239 +13,222 @@ function formatBytes(bytes) {
26
13
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
27
14
  return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
28
15
  }
16
+ function maskUri(uri) {
17
+ return uri.replace(/:\/\/([^:]+):([^@]+)@/, "://***:***@");
18
+ }
29
19
  export function createDbCommand(program) {
30
20
  const db = program
31
21
  .command("db")
32
- .description("MongoDB database operations (copy, stats, scheduled replication)")
22
+ .description("Copy query results to external databases, manage targets")
33
23
  .addHelpText("after", `
34
- Source is automatically resolved from MONGO_CONNECTSTRING env var.
35
-
36
24
  Examples:
37
- ay db copy --to "mongodb://backup-host/ayoune" --collections "users,orders"
38
- ay db copy --to "mongodb://..." --all --schedule "0 */6 * * *"
39
- ay db copy --from "mongodb://other/db" --to "mongodb://..." --collections "logs"
40
- ay db schedules list
41
- ay db stats`);
42
- // ─── ay db copy ─────────────────────────────────────────────────
43
- db.command("copy")
44
- .description("Copy data from aYOUne database to another MongoDB instance")
45
- .requiredOption("--to <uri>", "Target MongoDB connection URI")
46
- .option("--from <uri>", "Source URI override (default: MONGO_CONNECTSTRING env var)")
47
- .option("--collections <list>", "Comma-separated collection names")
48
- .option("--all", "Copy all collections")
49
- .option("--query <json>", "JSON filter query for source documents")
50
- .option("--drop", "Drop target collection before copying")
51
- .option("--upsert", "Upsert documents by _id (idempotent, safe to re-run)")
52
- .option("--batch-size <number>", "Documents per batch", parseInt, 1000)
53
- .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)")
54
36
  .addHelpText("after", `
55
- Source defaults to MONGO_CONNECTSTRING env var (the aYOUne database).
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.
56
39
 
57
40
  Examples:
58
- ay db copy --to "mongodb://backup-host/ayoune" --collections "users,orders"
59
- ay db copy --to "mongodb://..." --all --drop
60
- ay db copy --to "mongodb://..." --collections "logs" --query '{"status":"active"}'
61
- ay db copy --to "mongodb://..." --all --upsert --schedule "0 */6 * * *"
62
- ay db copy --from "mongodb://other/db" --to "mongodb://..." --collections "data"`)
63
- .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;
64
47
  try {
65
48
  const opts = { ...program.opts(), ...options };
66
- // Validate input
67
- if (!opts.collections && !opts.all) {
68
- cliError("Provide --collections or --all", EXIT_MISUSE);
49
+ // Step 1: Resolve target
50
+ let targetData;
51
+ if (target) {
52
+ targetData = await resolveTarget(target);
69
53
  }
70
- if (opts.collections && opts.all) {
71
- cliError("Use either --collections or --all, not both", EXIT_MISUSE);
54
+ else {
55
+ if (!process.stdin.isTTY)
56
+ cliError("Provide target slug or use interactive mode", EXIT_MISUSE);
57
+ targetData = await promptTarget();
72
58
  }
73
- const fromUri = getSourceUri(opts);
74
- if (!opts.to.startsWith("mongodb")) {
75
- 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);
76
63
  }
77
- let query;
78
- if (opts.query) {
79
- try {
80
- query = JSON.parse(opts.query);
81
- }
82
- catch (_a) {
83
- cliError("--query must be valid JSON", EXIT_MISUSE);
84
- }
64
+ else {
65
+ if (!process.stdin.isTTY)
66
+ cliError("Provide query slug or use interactive mode", EXIT_MISUSE);
67
+ queryData = await promptQuery();
85
68
  }
86
- const collections = opts.all ? ["*"] : opts.collections.split(",").map((c) => c.trim());
87
- // Schedule mode
88
- if (opts.schedule) {
89
- const cronError = validateCron(opts.schedule);
90
- if (cronError)
91
- cliError(`Invalid cron expression: ${cronError}`, EXIT_MISUSE);
92
- const config = {
93
- id: generateId(),
94
- createdAt: new Date().toISOString(),
95
- from: maskUri(fromUri),
96
- to: maskUri(opts.to),
97
- fromUri: fromUri, // Will be encrypted by addCopyConfig
98
- toUri: opts.to,
99
- collections,
100
- query,
101
- drop: opts.drop || false,
102
- upsert: opts.upsert || false,
103
- batchSize: opts.batchSize,
104
- schedule: opts.schedule,
105
- };
106
- addCopyConfig(config);
107
- const nextRun = getNextRun(opts.schedule);
108
- console.log(chalk.green(`\n Scheduled copy saved (ID: ${config.id})`));
109
- console.log(chalk.dim(` Source: ${config.from}`));
110
- console.log(chalk.dim(` Target: ${config.to}`));
111
- console.log(chalk.dim(` Collections: ${collections.join(", ")}`));
112
- console.log(chalk.dim(` Schedule: ${opts.schedule}`));
113
- if (nextRun)
114
- console.log(chalk.dim(` Next run: ${nextRun.toISOString()}`));
115
- console.log();
116
- console.log(chalk.yellow(" Set up a cron job to run scheduled copies:"));
117
- console.log(chalk.dim(" Linux/macOS: crontab -e → */5 * * * * ay db schedules run"));
118
- console.log(chalk.dim(" Windows: schtasks /create /tn ayoune-db-sync /tr \"ay db schedules run\" /sc minute /mo 5"));
119
- 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" });
120
77
  return;
121
78
  }
122
- // Execute copy
123
- spinner.start({ text: `Copying from ${maskUri(fromUri)} to ${maskUri(opts.to)}...`, color: "cyan" });
124
- const summary = await executeCopy(fromUri, opts.to, collections, {
125
- query,
126
- drop: opts.drop,
127
- upsert: opts.upsert,
128
- batchSize: opts.batchSize,
129
- }, (progress) => {
130
- spinner.update({
131
- text: `${progress.collection}: ${progress.copied}/${progress.total} docs${progress.errors ? chalk.red(` (${progress.errors} errors)`) : ""}`,
132
- });
133
- });
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);
134
86
  spinner.stop();
135
- // 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
136
100
  const wrapped = {
137
- payload: summary,
138
- meta: {
139
- totalCopied: summary.totalCopied,
140
- totalErrors: summary.totalErrors,
141
- 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,
142
109
  },
110
+ meta: { written: result.written, errors: result.errors },
143
111
  };
144
112
  if (opts.responseFormat === "json" || opts.responseFormat === "yaml") {
145
113
  handleResponseFormatOptions(opts, wrapped);
146
114
  }
147
115
  else {
148
- console.log(chalk.cyan.bold("\n Copy Summary\n"));
149
- for (const col of summary.collections) {
150
- const icon = col.errors === 0 ? chalk.green("●") : chalk.yellow("●");
151
- console.log(` ${icon} ${col.name}: ${col.copied} docs copied${col.errors ? chalk.red(` (${col.errors} errors)`) : ""} ${chalk.dim(`(${col.duration}ms)`)}`);
152
- }
153
- console.log();
154
- console.log(` ${chalk.green(summary.totalCopied + " total")}${summary.totalErrors ? ` ${chalk.red(summary.totalErrors + " errors")}` : ""} ${chalk.dim(`in ${summary.duration}ms`)}`);
155
- 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`);
156
118
  }
157
- if (summary.totalErrors > 0)
119
+ if (result.errors > 0)
158
120
  process.exit(EXIT_GENERAL_ERROR);
159
121
  }
160
122
  catch (e) {
161
123
  cliError(e.message || "Copy failed", EXIT_GENERAL_ERROR);
162
124
  }
163
125
  });
164
- // ─── ay db schedules ────────────────────────────────────────────
165
- const schedules = db.command("schedules").alias("sched").description("Manage scheduled copy jobs");
166
- // ay db schedules list
167
- schedules
126
+ // ─── ay db targets ─────────────────────────────────────────────
127
+ const targets = db.command("targets").alias("t").description("Manage database targets");
128
+ // ay db targets list
129
+ targets
168
130
  .command("list")
169
131
  .alias("ls")
170
- .description("List all scheduled copy jobs")
132
+ .description("List configured database targets")
133
+ .option("-l, --limit <number>", "Limit results", parseInt, 50)
171
134
  .action(async (options) => {
135
+ var _a, _b, _c, _d, _e;
172
136
  try {
173
137
  const opts = { ...program.opts(), ...options };
174
- const configs = loadCopyConfigs();
175
- if (configs.length === 0) {
176
- console.log(chalk.dim("\n No scheduled copies configured.\n"));
177
- return;
178
- }
179
- const wrapped = { payload: configs.map(displayConfig), meta: { total: configs.length } };
180
- 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` });
181
147
  }
182
148
  catch (e) {
183
- cliError(e.message || "Failed to list schedules", EXIT_GENERAL_ERROR);
149
+ cliError(e.message || "Failed to list targets", EXIT_GENERAL_ERROR);
184
150
  }
185
151
  });
186
- // ay db schedules remove <id>
187
- schedules
188
- .command("remove <id>")
189
- .alias("rm")
190
- .description("Remove a scheduled copy job")
191
- .action(async (id) => {
192
- const removed = removeCopyConfig(id);
193
- if (removed) {
194
- 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}` });
195
196
  }
196
- else {
197
- cliError(`No schedule found with ID "${id}"`, EXIT_GENERAL_ERROR);
197
+ catch (e) {
198
+ cliError(e.message || "Failed to create target", EXIT_GENERAL_ERROR);
198
199
  }
199
200
  });
200
- // ay db schedules run
201
- schedules
202
- .command("run")
203
- .description("Execute all copy jobs that are due now")
204
- .option("--id <id>", "Run a specific schedule by ID")
205
- .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) => {
206
207
  try {
207
- const configs = loadCopyConfigs();
208
- const now = new Date();
209
- const toRun = options.id
210
- ? configs.filter((c) => c.id === options.id)
211
- : configs.filter((c) => isCronDue(c.schedule, c.lastRun, now));
212
- if (toRun.length === 0) {
213
- console.log(chalk.dim("\n No copies due.\n"));
214
- return;
215
- }
216
- console.log(chalk.cyan(`\n Running ${toRun.length} scheduled copies...\n`));
217
- for (const config of toRun) {
218
- const { fromUri, toUri } = getDecryptedConfig(config);
219
- console.log(chalk.dim(` ${config.id}: ${config.from} → ${config.to}`));
220
- try {
221
- spinner.start({ text: `${config.id}: copying...`, color: "cyan" });
222
- const summary = await executeCopy(fromUri, toUri, config.collections, {
223
- query: config.query,
224
- drop: config.drop,
225
- upsert: config.upsert,
226
- batchSize: config.batchSize,
227
- }, (progress) => {
228
- spinner.update({
229
- text: `${config.id}: ${progress.collection} ${progress.copied}/${progress.total}`,
230
- });
231
- });
232
- updateCopyConfigLastRun(config.id, "success");
233
- spinner.success({ text: `${config.id}: ${summary.totalCopied} docs copied${summary.totalErrors ? `, ${summary.totalErrors} errors` : ""}` });
234
- }
235
- catch (e) {
236
- updateCopyConfigLastRun(config.id, "failed", e.message);
237
- spinner.error({ text: `${config.id}: ${e.message}` });
238
- }
239
- }
240
- 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` });
241
212
  }
242
213
  catch (e) {
243
- cliError(e.message || "Scheduled run failed", EXIT_GENERAL_ERROR);
214
+ cliError(e.message || "Failed to remove target", EXIT_GENERAL_ERROR);
244
215
  }
245
216
  });
246
- // ─── ay db stats [uri] ──────────────────────────────────────────
217
+ // ─── ay db stats [uri] ─────────────────────────────────────────
247
218
  db.command("stats [uri]")
248
219
  .description("Show database statistics (defaults to aYOUne database)")
249
220
  .addHelpText("after", `
250
- Defaults to MONGO_CONNECTSTRING (the aYOUne database) when no URI is given.
221
+ Defaults to MONGO_CONNECTSTRING when no URI is given.
251
222
 
252
223
  Examples:
253
- ay db stats Stats for aYOUne database
254
- ay db stats "mongodb://other/db" Stats for a different database
255
- ay db stats -r table`)
224
+ ay db stats
225
+ ay db stats "mongodb://other/db" -r table`)
256
226
  .action(async (uri, options) => {
257
227
  try {
258
228
  const opts = { ...program.opts(), ...options };
259
229
  const dbUri = uri || process.env.MONGO_CONNECTSTRING || process.env.MONGODB_URI;
260
230
  if (!dbUri) {
261
- cliError("No database URI. Set MONGO_CONNECTSTRING env var or pass a URI argument.", EXIT_MISUSE);
231
+ cliError("No database URI. Set MONGO_CONNECTSTRING env var or pass a URI.", EXIT_MISUSE);
262
232
  }
263
233
  if (!dbUri.startsWith("mongodb")) {
264
234
  cliError("URI must start with mongodb:// or mongodb+srv://", EXIT_MISUSE);
@@ -268,11 +238,7 @@ Examples:
268
238
  spinner.stop();
269
239
  const wrapped = {
270
240
  payload: stats,
271
- meta: {
272
- database: stats.database,
273
- collectionCount: stats.collections.length,
274
- totalSize: formatBytes(stats.totalSize),
275
- },
241
+ meta: { database: stats.database, collectionCount: stats.collections.length, totalSize: formatBytes(stats.totalSize) },
276
242
  };
277
243
  if (opts.responseFormat === "json" || opts.responseFormat === "yaml") {
278
244
  handleResponseFormatOptions(opts, wrapped);
@@ -292,16 +258,75 @@ Examples:
292
258
  }
293
259
  });
294
260
  }
295
- function displayConfig(config) {
296
- const nextRun = getNextRun(config.schedule);
297
- return {
298
- id: config.id,
299
- from: config.from,
300
- to: config.to,
301
- collections: config.collections.join(", "),
302
- schedule: config.schedule,
303
- nextRun: (nextRun === null || nextRun === void 0 ? void 0 : nextRun.toISOString()) || "—",
304
- lastRun: config.lastRun || "never",
305
- lastStatus: config.lastStatus || "",
306
- };
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;
307
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,66 @@
1
- import { execSync } from "child_process";
1
+ import { spawn } 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. Only uses cached data never spawns child processes
8
+ * during the CLI invocation. A detached background script refreshes the cache.
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();
23
+ // Mark as checked immediately to prevent duplicate spawns
32
24
  localStorage.setItem("updateCheckTimestamp", String(Date.now()));
33
- localStorage.setItem("updateCheckLatest", latest);
34
- if (latest && latest !== currentVersion) {
35
- printUpdateNotice(currentVersion, latest);
36
- }
25
+ // Spawn a fully detached node process that fetches the version and writes to localStorage.
26
+ // detached + unref + stdio:ignore ensures no file locks are held by the parent.
27
+ const storagePath = localStorage._location || "";
28
+ const script = `
29
+ const https = require("https");
30
+ const fs = require("fs");
31
+ const path = require("path");
32
+ const url = "https://registry.npmjs.org/${PKG_NAME}/latest";
33
+ https.get(url, { headers: { "Accept": "application/json" }, timeout: 5000 }, (res) => {
34
+ let data = "";
35
+ res.on("data", (c) => data += c);
36
+ res.on("end", () => {
37
+ try {
38
+ const ver = JSON.parse(data).version;
39
+ if (ver) fs.writeFileSync(path.join("${storagePath.replace(/\\/g, "\\\\")}", "updateCheckLatest"), ver);
40
+ } catch {}
41
+ });
42
+ }).on("error", () => {});
43
+ `;
44
+ const child = spawn(process.execPath, ["-e", script], {
45
+ detached: true,
46
+ stdio: "ignore",
47
+ });
48
+ child.unref();
37
49
  }
38
50
  catch (_a) {
39
- // Silently ignore — network issues, npm not available, etc.
51
+ // Silently ignore
52
+ }
53
+ }
54
+ function isNewerVersion(latest, current) {
55
+ const l = latest.split(".").map(Number);
56
+ const c = current.split(".").map(Number);
57
+ for (let i = 0; i < Math.max(l.length, c.length); i++) {
58
+ if ((l[i] || 0) > (c[i] || 0))
59
+ return true;
60
+ if ((l[i] || 0) < (c[i] || 0))
61
+ return false;
40
62
  }
63
+ return false;
41
64
  }
42
65
  function printUpdateNotice(current, latest) {
43
66
  const msg = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tolinax/ayoune-cli",
3
- "version": "2026.6.1",
3
+ "version": "2026.7.1",
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",