@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.
- package/lib/commands/createDbCommand.js +233 -208
- package/lib/db/copyEngine.js +28 -64
- package/lib/helpers/updateNotifier.js +47 -24
- package/package.json +1 -2
|
@@ -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 {
|
|
7
|
-
import {
|
|
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("
|
|
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
|
|
38
|
-
ay db copy
|
|
39
|
-
ay db copy
|
|
40
|
-
ay db
|
|
41
|
-
ay db
|
|
42
|
-
|
|
43
|
-
db
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
.option("--
|
|
47
|
-
.option("--
|
|
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
|
-
|
|
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
|
|
59
|
-
ay db copy
|
|
60
|
-
ay db copy
|
|
61
|
-
ay db copy
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
49
|
+
// Step 1: Resolve target
|
|
50
|
+
let targetData;
|
|
51
|
+
if (target) {
|
|
52
|
+
targetData = await resolveTarget(target);
|
|
69
53
|
}
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
59
|
+
// Step 2: Resolve query
|
|
60
|
+
let queryData;
|
|
61
|
+
if (query) {
|
|
62
|
+
queryData = await resolveQuery(query);
|
|
76
63
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
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:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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 (
|
|
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
|
|
165
|
-
const
|
|
166
|
-
// ay db
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
handleResponseFormatOptions(opts,
|
|
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
|
|
149
|
+
cliError(e.message || "Failed to list targets", EXIT_GENERAL_ERROR);
|
|
184
150
|
}
|
|
185
151
|
});
|
|
186
|
-
// ay db
|
|
187
|
-
|
|
188
|
-
.command("
|
|
189
|
-
.
|
|
190
|
-
.
|
|
191
|
-
.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
cliError(
|
|
197
|
+
catch (e) {
|
|
198
|
+
cliError(e.message || "Failed to create target", EXIT_GENERAL_ERROR);
|
|
198
199
|
}
|
|
199
200
|
});
|
|
200
|
-
// ay db
|
|
201
|
-
|
|
202
|
-
.command("
|
|
203
|
-
.
|
|
204
|
-
.
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 || "
|
|
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
|
|
221
|
+
Defaults to MONGO_CONNECTSTRING when no URI is given.
|
|
251
222
|
|
|
252
223
|
Examples:
|
|
253
|
-
ay db stats
|
|
254
|
-
ay db stats "mongodb://other/db"
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
}
|
package/lib/db/copyEngine.js
CHANGED
|
@@ -1,67 +1,37 @@
|
|
|
1
1
|
import { MongoClient } from "mongodb";
|
|
2
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
const client = new MongoClient(targetUri);
|
|
10
|
+
let written = 0;
|
|
11
|
+
let errors = 0;
|
|
7
12
|
try {
|
|
8
|
-
await
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
8
|
-
*
|
|
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
|
-
//
|
|
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.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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.
|
|
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",
|