@vibingview/cli 0.1.4 → 0.2.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.
- package/package.json +1 -1
- package/src/cli.mjs +333 -11
- package/src/mcp.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibingview/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "VibingView command-line interface and stdio MCP bridge: pair with browser approval, deploy apps, read logs, manage env vars and databases, and connect AI editors.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { ensureGitignore, inferAppName, isIdenticalDeploy, packageProject, readProjectConfig, writeProjectConfig } from "./lib/project.mjs";
|
|
11
11
|
import { defaultTransport, installMcpConfig, maskToken, maskedEntry, mcpConfigPath, MCP_CLIENTS, NPM_PACKAGE } from "./lib/mcp-install.mjs";
|
|
12
12
|
|
|
13
|
-
export const VERSION = "0.
|
|
13
|
+
export const VERSION = "0.2.0";
|
|
14
14
|
/** Production control plane used when no --url, VIBINGVIEW_URL or saved login selects another. */
|
|
15
15
|
export const DEFAULT_URL = "https://vibingview.com";
|
|
16
16
|
const USAGE = `VibingView CLI ${VERSION}
|
|
@@ -47,14 +47,36 @@ One-click apps
|
|
|
47
47
|
vibingview app create <slug> [--name <app>] [--workspace <id|name>] [--domain <suffix>] [--yes]
|
|
48
48
|
vibingview app status [app]
|
|
49
49
|
|
|
50
|
+
Servers (actions marked * need your approval in the browser; the link shows exactly what happens and its price)
|
|
51
|
+
vibingview server list | info [server] | plans [server]
|
|
52
|
+
vibingview server reboot|start|clean [server]
|
|
53
|
+
vibingview server shutdown [server]*
|
|
54
|
+
vibingview server upgrade <plan> [--server <s>] [--at "YYYY-MM-DD HH:MM"]* upgrade cancel|now [--server <s>]
|
|
55
|
+
vibingview server renew* | billing <1|6|12>* | auto-renew on|off* | reinstall* | delete* [--server <s>]
|
|
56
|
+
|
|
57
|
+
Apps
|
|
58
|
+
vibingview apps [--workspace <id|name>]
|
|
59
|
+
vibingview app deployments [app] | rollback [app] --to <revision|id> | settings [app]
|
|
60
|
+
vibingview app set [app] [--port n] [--root-dir d] [--install-command c] [--build-command c] [--start-command c] [--output-dir d] [--technique 1-5] [--dockerfile <file>]
|
|
61
|
+
vibingview app delete [app]*
|
|
62
|
+
vibingview deploy --git <repository-url> [--branch main] --name <app> --port <n> [--workspace <s>]
|
|
63
|
+
|
|
64
|
+
Domains
|
|
65
|
+
vibingview domains [app] | add <host> | check <host> | remove <host>* [--app <app>]
|
|
66
|
+
vibingview domains owned | connect <domain> --app <app>* | disconnect <domain>*
|
|
67
|
+
vibingview nameservers [app] | connect <domain> | check <domain> | disconnect <domain>* [--app <app>]
|
|
68
|
+
|
|
69
|
+
Billing
|
|
70
|
+
vibingview balance | payments | topup <euros> [--method card|crypto]
|
|
71
|
+
|
|
50
72
|
Other
|
|
51
|
-
vibingview workspaces |
|
|
73
|
+
vibingview workspaces | logs … --follow | logs … --json | db logs <db> | db reveal <db>* | db delete <db>* | env get --reveal*
|
|
52
74
|
|
|
53
75
|
Control plane: --url <origin> on any command, else VIBINGVIEW_URL, else the instance you last logged in to,
|
|
54
76
|
else ${DEFAULT_URL}. VIBINGVIEW_TOKEN overrides the saved token.`;
|
|
55
77
|
|
|
56
|
-
const BOOLEAN_FLAGS = new Set(["force", "yes", "reveal", "show", "ai", "build", "runtime", "no-wait", "no-browser", "dry-run", "json", "help", "stdin", "local-bridge"]);
|
|
57
|
-
const VALUE_FLAGS = new Set(["workspace", "name", "port", "domain", "app", "limit", "engine", "client", "transport", "timeout", "url", "token", "technique", "builder"]);
|
|
78
|
+
const BOOLEAN_FLAGS = new Set(["force", "yes", "reveal", "show", "ai", "build", "runtime", "no-wait", "no-browser", "dry-run", "json", "help", "stdin", "local-bridge", "follow"]);
|
|
79
|
+
const VALUE_FLAGS = new Set(["workspace", "server", "name", "port", "domain", "app", "limit", "engine", "client", "transport", "timeout", "url", "token", "technique", "builder", "to", "at", "method", "git", "branch", "root-dir", "install-command", "build-command", "start-command", "output-dir", "dockerfile"]);
|
|
58
80
|
|
|
59
81
|
export function parseArgs(argv) {
|
|
60
82
|
const positional = [];
|
|
@@ -131,7 +153,7 @@ function createClient(base, token) {
|
|
|
131
153
|
throw new CliError(`Could not read the server's answer to ${route.split("?")[0]}. Nothing was assumed; try again (after a deploy, check vibingview status first).`);
|
|
132
154
|
if (!response.ok) {
|
|
133
155
|
let message = typeof body.error === "string" ? body.error : body.error?.message ?? `HTTP ${response.status}`;
|
|
134
|
-
if (response.status === 401 && token) message += " — the token may be expired or revoked, or lacks the permission this command needs (
|
|
156
|
+
if (response.status === 401 && token) message += " — the token may be expired or revoked, or lacks the permission this command needs (sign in again with vibingview login). Run vibingview login or use an appropriately scoped VIBINGVIEW_TOKEN.";
|
|
135
157
|
throw Object.assign(new CliError(redact(message)), { status: response.status, code: typeof body.code === "string" ? body.code : undefined });
|
|
136
158
|
}
|
|
137
159
|
return body;
|
|
@@ -351,6 +373,41 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
351
373
|
return matches[0];
|
|
352
374
|
}
|
|
353
375
|
|
|
376
|
+
async function resolveDatabase(reference) {
|
|
377
|
+
const workspaces = flags.workspace || flags.server || project.workspaceId ? [await resolveWorkspace(flags.workspace ?? flags.server, { running: false })] : await workspacesList();
|
|
378
|
+
const found = [];
|
|
379
|
+
for (const workspace of workspaces) {
|
|
380
|
+
const { databases = [] } = await call(`/api/workspaces/${encodeURIComponent(workspace.id)}/databases`);
|
|
381
|
+
for (const item of databases) if (item.id === reference || item.name === reference) found.push({ ...item, workspaceId: workspace.id, workspace: workspace.name });
|
|
382
|
+
}
|
|
383
|
+
if (!found.length) throw new CliError(`Database ${reference} not found`);
|
|
384
|
+
if (found.length > 1) throw new CliError(`Several databases are named ${reference}; pass --workspace or the database ID.`);
|
|
385
|
+
return found[0];
|
|
386
|
+
}
|
|
387
|
+
const euros = cents => `€${(Number(cents) / 100).toFixed(2)}`;
|
|
388
|
+
/**
|
|
389
|
+
* Server actions go through /api/cli/actions. Everyday ones run at once; paying, deleting and revealing secrets
|
|
390
|
+
* answer with a link the account owner approves in the browser (it shows exactly what happens and the price).
|
|
391
|
+
*/
|
|
392
|
+
async function approvedAction(action, params) {
|
|
393
|
+
const answer = await call("/api/cli/actions", json("POST", { action, params }));
|
|
394
|
+
if (answer.status === "done") return answer.result ?? {};
|
|
395
|
+
const price = answer.summary && "chargeCents" in answer.summary ? ` It costs ${euros(answer.summary.chargeCents)}.` : "";
|
|
396
|
+
out(`Approval needed: open this link and click Approve (it shows exactly what will happen).${price}\n ${answer.approveUrl}`);
|
|
397
|
+
if (!flags["no-browser"] && new URL(answer.approveUrl).origin === new URL(base).origin) openBrowser(answer.approveUrl);
|
|
398
|
+
out("Waiting for your approval in the browser (up to 10 minutes)…");
|
|
399
|
+
const deadline = Date.parse(answer.expiresAt) || Date.now() + 600000;
|
|
400
|
+
while (Date.now() < deadline + 5000) {
|
|
401
|
+
await sleep(3000);
|
|
402
|
+
const state = await call(`/api/cli/actions/${encodeURIComponent(answer.id)}`).catch(error => { if (error.status === 404) throw error; return { status: "pending" }; });
|
|
403
|
+
if (state.status === "done") return state.result ?? {};
|
|
404
|
+
if (state.status === "denied") throw new CliError("Denied in the browser. Nothing was changed.");
|
|
405
|
+
if (state.status === "expired") throw new CliError("The approval request expired. Run the command again.");
|
|
406
|
+
if (state.status === "failed") throw new CliError(`It did not work: ${state.error ?? "unknown error"}`);
|
|
407
|
+
}
|
|
408
|
+
throw new CliError("The approval request expired. Run the command again.");
|
|
409
|
+
}
|
|
410
|
+
|
|
354
411
|
// ----- listing ---------------------------------------------------------------
|
|
355
412
|
if (command === "workspaces") {
|
|
356
413
|
const rows = await workspacesList();
|
|
@@ -358,12 +415,24 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
358
415
|
return 0;
|
|
359
416
|
}
|
|
360
417
|
if (command === "apps") {
|
|
361
|
-
const
|
|
418
|
+
const filter = flags.workspace ?? flags.server ? await resolveWorkspace(flags.workspace ?? flags.server, { running: false }) : null;
|
|
419
|
+
const { applications: all = [] } = await call("/api/apps");
|
|
420
|
+
const applications = filter ? all.filter(app => app.workspaceId === filter.id) : all;
|
|
362
421
|
if (flags.json) out(JSON.stringify(applications, null, 2)); else table(applications, [["id", "ID"], ["name", "NAME"], ["status", "STATUS"], ["url", "URL"]]);
|
|
363
422
|
return 0;
|
|
364
423
|
}
|
|
365
424
|
|
|
366
425
|
// ----- deploy ----------------------------------------------------------------
|
|
426
|
+
if (command === "deploy" && typeof flags.git === "string") {
|
|
427
|
+
// A new app built from a Git repository (later pushes redeploy it once the repository is linked in the dashboard).
|
|
428
|
+
if (!flags.name) throw new CliError("Usage: vibingview deploy --git <repository-url> --name <app> --port <n> [--branch main] [--workspace <s>]");
|
|
429
|
+
const port = Number(flags.port ?? 3000);
|
|
430
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError("--port must be 1-65535");
|
|
431
|
+
const workspace = await resolveWorkspace(flags.workspace ?? flags.server);
|
|
432
|
+
const { application } = await call("/api/apps", json("POST", { workspaceId: workspace.id, name: flags.name, gitUrl: flags.git, branch: flags.branch ?? "main", port }));
|
|
433
|
+
out(`${application.name} (${application.id}) is building from ${flags.git}${application.url ? ` → ${application.url}` : ""}.\nFollow it with vibingview logs ${application.name} --build or vibingview status ${application.name}.`);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
367
436
|
if (command === "deploy") {
|
|
368
437
|
if (args.length === 3 && args[2].toLowerCase().endsWith(".zip")) return legacyDeploy(call, args);
|
|
369
438
|
const directory = path.resolve(args[0] ?? ".");
|
|
@@ -480,6 +549,19 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
480
549
|
const kind = flags.build || legacyKind === "build" || args[0] === "build" ? "build" : "runtime";
|
|
481
550
|
const max = Math.min(1000, Math.max(1, Number(flags.limit ?? 200) || 200));
|
|
482
551
|
const { logs = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/logs?kind=${kind}&limit=${max}`);
|
|
552
|
+
if (flags.json) { out(JSON.stringify(logs, null, 2)); return 0; }
|
|
553
|
+
if (flags.follow) {
|
|
554
|
+
// Prints new lines every 3 seconds until Ctrl+C (lines are matched on time and text, so restarts are handled).
|
|
555
|
+
const key = line => `${line.recorded_at ?? ""}|${line.message}`;
|
|
556
|
+
let seen = new Set(logs.map(key));
|
|
557
|
+
for (const line of logs) out(clean(line.message));
|
|
558
|
+
for (;;) {
|
|
559
|
+
await sleep(3000);
|
|
560
|
+
const { logs: next = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/logs?kind=${kind}&limit=200`).catch(() => ({ logs: [] }));
|
|
561
|
+
for (const line of next) if (!seen.has(key(line))) out(clean(line.message));
|
|
562
|
+
seen = new Set([...seen, ...next.map(key)].slice(-5000));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
483
565
|
if (flags.ai) {
|
|
484
566
|
out(`## VibingView ${kind} logs: ${app.name}\n\n- Application: ${app.name} (${app.id})\n- Status: ${app.status}\n- URL: ${app.url ?? "private"}\n- Lines: last ${logs.length}\n\n\`\`\`text`);
|
|
485
567
|
for (const line of logs) out(`${line.recorded_at ? new Date(line.recorded_at).toISOString() + " " : ""}${line.stream ? `[${line.stream}] ` : ""}${clean(line.message)}`);
|
|
@@ -513,10 +595,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
513
595
|
if (sub === "get" || sub === "list") {
|
|
514
596
|
const wanted = args[1];
|
|
515
597
|
if (flags.reveal) {
|
|
516
|
-
warn("
|
|
517
|
-
const { variables = [] } = await
|
|
598
|
+
warn("Showing secret values needs your approval in the browser. Keep the output private.");
|
|
599
|
+
const { variables = [] } = await approvedAction("env.reveal", { applicationId: app.id }) ?? {};
|
|
518
600
|
const selected = variables.filter(variable => !wanted || variable.name === wanted);
|
|
519
601
|
if (wanted && !selected.length) throw new CliError(`${wanted} is not set`);
|
|
602
|
+
if (!selected.length) warn("No environment variables.");
|
|
520
603
|
for (const variable of selected) out(`${variable.name}=${variable.value}`);
|
|
521
604
|
return 0;
|
|
522
605
|
}
|
|
@@ -591,9 +674,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
591
674
|
for (const item of databases) rows.push({ ...item, workspace: workspace.name, workspaceId: workspace.id });
|
|
592
675
|
}
|
|
593
676
|
if (flags.reveal) {
|
|
594
|
-
warn("
|
|
677
|
+
warn("Showing a connection string needs your approval in the browser, one database at a time. Keep the output private.");
|
|
595
678
|
for (const row of rows) {
|
|
596
|
-
const { connectionString } = await
|
|
679
|
+
const { connectionString } = await approvedAction("db.reveal", { workspaceId: row.workspaceId, databaseId: row.id }) ?? {};
|
|
597
680
|
out(`${row.name} (${row.engine}, ${row.status}): ${connectionString}`);
|
|
598
681
|
}
|
|
599
682
|
return 0;
|
|
@@ -625,7 +708,246 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
625
708
|
out(`${sub === "link" ? `Linked database to ${app.name} as DATABASE_URL` : `Unlinked database from ${app.name}`}. ${queuedNote(result)}`);
|
|
626
709
|
return 0;
|
|
627
710
|
}
|
|
628
|
-
|
|
711
|
+
if (sub === "reveal" || sub === "delete" || sub === "logs") {
|
|
712
|
+
if (!args[1]) throw new CliError(`Usage: vibingview db ${sub} <database-id|name> [--workspace <id|name>]`);
|
|
713
|
+
const found = await resolveDatabase(args[1]);
|
|
714
|
+
if (sub === "logs") {
|
|
715
|
+
const { logs = [] } = await call(`/api/workspaces/${encodeURIComponent(found.workspaceId)}/databases?logs=${encodeURIComponent(found.id)}`);
|
|
716
|
+
if (flags.json) out(JSON.stringify(logs, null, 2)); else for (const line of logs) out(clean(line.message));
|
|
717
|
+
if (!logs.length) warn("No database logs yet.");
|
|
718
|
+
return 0;
|
|
719
|
+
}
|
|
720
|
+
if (sub === "reveal") { const { connectionString } = await approvedAction("db.reveal", { workspaceId: found.workspaceId, databaseId: found.id }) ?? {}; out(connectionString); return 0; }
|
|
721
|
+
await approvedAction("db.delete", { workspaceId: found.workspaceId, databaseId: found.id });
|
|
722
|
+
out(`Deleting ${found.name}. Its data is erased once the server confirms.`);
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
throw new CliError("Usage: vibingview db list|create|link|unlink|logs|reveal|delete");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ----- servers -------------------------------------------------------------------
|
|
729
|
+
if (command === "server" || command === "servers") {
|
|
730
|
+
const sub = command === "servers" ? "list" : (args[0] ?? "list");
|
|
731
|
+
const serverRef = flags.server ?? flags.workspace;
|
|
732
|
+
if (sub === "list") {
|
|
733
|
+
const rows = await workspacesList();
|
|
734
|
+
if (flags.json) out(JSON.stringify(rows, null, 2)); else table(rows, [["id", "ID"], ["name", "NAME"], ["status", "STATUS"], ["planId", "PLAN"], ["publicIp", "IP"]]);
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
const server = await resolveWorkspace(serverRef ?? (["info", "plans", "reboot", "start", "clean", "shutdown", "renew", "reinstall", "delete"].includes(sub) ? args[1] : undefined), { running: false });
|
|
738
|
+
const id = encodeURIComponent(server.id);
|
|
739
|
+
if (sub === "info") {
|
|
740
|
+
const [life, metrics] = await Promise.all([call(`/api/workspaces/${id}/lifecycle`), call(`/api/workspaces/${id}/metrics`).catch(() => ({}))]);
|
|
741
|
+
const last = (metrics.metrics ?? []).at(-1);
|
|
742
|
+
if (flags.json) { out(JSON.stringify({ server, lifecycle: life, latestMetrics: last ?? null, agentLastSeenAt: metrics.agentLastSeenAt ?? null }, null, 2)); return 0; }
|
|
743
|
+
const gb = bytes => `${(Number(bytes) / 1e9).toFixed(1)} GB`;
|
|
744
|
+
out(`${server.name} (${server.id})\n Status: ${String(life.status ?? server.status).toLowerCase()}\n Plan: ${server.planId} · ${euros(life.monthlyPriceCents ?? 0)}/month, billed every ${life.billingMonths ?? 1} month(s)\n IP: ${server.publicIp ?? "(pending)"}\n Renews: ${life.renewsAt ? new Date(life.renewsAt).toISOString().slice(0, 16).replace("T", " ") + " UTC" : "—"} (auto-renew ${life.autoRenew ? "on" : "off"})`);
|
|
745
|
+
if (life.bandwidth) out(` Traffic: ${gb(life.bandwidth.usedBytes)} of ${gb(life.bandwidth.quotaBytes)} this period`);
|
|
746
|
+
if (last) out(` Now: CPU ${Number(last.cpu_percent).toFixed(1)}% · RAM ${gb(last.memory_used_bytes)} · disk ${gb(last.disk_used_bytes)}`);
|
|
747
|
+
if (metrics.agentLastSeenAt) out(` Agent seen: ${new Date(metrics.agentLastSeenAt).toISOString().slice(0, 19).replace("T", " ")} UTC`);
|
|
748
|
+
if (life.upgrade && ["scheduled", "resizing", "needs_review"].includes(life.upgrade.status)) out(` Upgrade: to ${life.upgrade.toPlanName}, ${life.upgrade.status}${life.upgrade.status === "scheduled" ? ` for ${new Date(life.upgrade.scheduledFor).toISOString().slice(0, 16).replace("T", " ")} UTC` : ""}`);
|
|
749
|
+
for (const task of life.pendingTasks ?? []) out(` Task: ${task.label} (${task.status})`);
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
if (sub === "plans") {
|
|
753
|
+
const plans = await call("/api/plans");
|
|
754
|
+
const current = (Array.isArray(plans) ? plans : []).find(plan => plan.id === server.planId);
|
|
755
|
+
const rows = (Array.isArray(plans) ? plans : []).map(plan => ({ ...plan, price: `${euros(plan.monthlyPriceCents)}/mo`, size: `${plan.cpu} vCPU · ${plan.ramGb} GB RAM · ${plan.storageGb} GB disk · ${plan.trafficTb} TB`, current: plan.id === server.planId ? "current" : current && plan.ramGb > current.ramGb ? "upgrade" : "" }));
|
|
756
|
+
if (flags.json) out(JSON.stringify(rows, null, 2)); else table(rows, [["id", "PLAN"], ["name", "NAME"], ["size", "SIZE"], ["price", "PRICE"], ["current", ""]]);
|
|
757
|
+
out("Upgrade with: vibingview server upgrade <plan> --server " + server.name + " (the exact charge is shown when you approve).");
|
|
758
|
+
return 0;
|
|
759
|
+
}
|
|
760
|
+
const simple = { reboot: "server.reboot", start: "server.start", clean: "server.clean", shutdown: "server.shutdown", renew: "server.renew", reinstall: "server.reinstall", delete: "server.delete" };
|
|
761
|
+
if (simple[sub]) {
|
|
762
|
+
const result = await approvedAction(simple[sub], { workspaceId: server.id });
|
|
763
|
+
out(result?.message ?? `${sub} requested for ${server.name}.`);
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
if (sub === "billing") {
|
|
767
|
+
const months = Number(args[1]);
|
|
768
|
+
if (![1, 6, 12].includes(months)) throw new CliError("Usage: vibingview server billing <1|6|12> [--server <s>]");
|
|
769
|
+
const result = await approvedAction("server.billing", { workspaceId: server.id, months });
|
|
770
|
+
out(result?.message ?? `${server.name} is now billed every ${months} month(s) from the next renewal.`);
|
|
771
|
+
return 0;
|
|
772
|
+
}
|
|
773
|
+
if (sub === "auto-renew") {
|
|
774
|
+
if (!["on", "off"].includes(args[1])) throw new CliError("Usage: vibingview server auto-renew on|off [--server <s>]");
|
|
775
|
+
await approvedAction("server.auto_renew", { workspaceId: server.id, enabled: args[1] === "on" });
|
|
776
|
+
out(`Automatic renewal is ${args[1]} for ${server.name}.`);
|
|
777
|
+
return 0;
|
|
778
|
+
}
|
|
779
|
+
if (sub === "upgrade") {
|
|
780
|
+
if (args[1] === "cancel" || args[1] === "now") {
|
|
781
|
+
const result = await approvedAction(args[1] === "cancel" ? "server.upgrade_cancel" : "server.upgrade_now", { workspaceId: server.id });
|
|
782
|
+
out(result?.message ?? "Done.");
|
|
783
|
+
return 0;
|
|
784
|
+
}
|
|
785
|
+
if (!args[1]) throw new CliError("Usage: vibingview server upgrade <plan> [--server <s>] [--at \"YYYY-MM-DD HH:MM\"] (see vibingview server plans)");
|
|
786
|
+
const plans = await call("/api/plans");
|
|
787
|
+
const plan = (Array.isArray(plans) ? plans : []).find(item => item.id === args[1] || item.name.toLowerCase() === args[1].toLowerCase());
|
|
788
|
+
if (!plan) throw new CliError(`Unknown plan ${args[1]}. Run vibingview server plans.`);
|
|
789
|
+
let scheduledFor;
|
|
790
|
+
if (flags.at) {
|
|
791
|
+
// Local time of this computer, like the dashboard's picker.
|
|
792
|
+
const at = new Date(String(flags.at).replace(" ", "T"));
|
|
793
|
+
if (Number.isNaN(at.getTime())) throw new CliError('--at must look like "2026-11-02 03:00" (your local time)');
|
|
794
|
+
scheduledFor = at.toISOString();
|
|
795
|
+
}
|
|
796
|
+
const result = await approvedAction("server.upgrade", { workspaceId: server.id, planId: plan.id, when: scheduledFor ? "scheduled" : "now", ...(scheduledFor ? { scheduledFor } : {}) });
|
|
797
|
+
out(result?.message ?? `Upgrade of ${server.name} to ${plan.name} ${scheduledFor ? "scheduled" : "started"}.`);
|
|
798
|
+
out("Follow it with vibingview server info " + server.name);
|
|
799
|
+
return 0;
|
|
800
|
+
}
|
|
801
|
+
throw new CliError("Usage: vibingview server list|info|plans|reboot|start|clean|shutdown|upgrade|renew|billing|auto-renew|reinstall|delete");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ----- app management ---------------------------------------------------------------
|
|
805
|
+
if (command === "app" && ["deployments", "rollback", "settings", "set", "delete"].includes(args[0])) {
|
|
806
|
+
const sub = args[0];
|
|
807
|
+
const app = await resolveApp(args[1]);
|
|
808
|
+
const id = encodeURIComponent(app.id);
|
|
809
|
+
if (sub === "deployments") {
|
|
810
|
+
const { deployments = [] } = await call(`/api/apps/${id}/deployments`);
|
|
811
|
+
if (flags.json) out(JSON.stringify(deployments, null, 2)); else table(deployments.map(d => ({ ...d, finished: d.finished_at ?? "" })), [["revision", "REV"], ["status", "STATUS"], ["trigger", "TRIGGER"], ["created_at", "CREATED"], ["finished", "FINISHED"], ["id", "ID"]]);
|
|
812
|
+
return 0;
|
|
813
|
+
}
|
|
814
|
+
if (sub === "rollback") {
|
|
815
|
+
if (!flags.to) throw new CliError("Usage: vibingview app rollback [app] --to <revision|deployment-id> (see vibingview app deployments)");
|
|
816
|
+
const { deployments = [] } = await call(`/api/apps/${id}/deployments`);
|
|
817
|
+
const target = deployments.find(d => d.id === flags.to || String(d.revision) === String(flags.to));
|
|
818
|
+
if (!target) throw new CliError(`Deployment ${flags.to} not found for ${app.name}`);
|
|
819
|
+
if (target.status !== "succeeded") throw new CliError(`Revision ${target.revision} did not succeed; choose a working one.`);
|
|
820
|
+
if (!(await confirm(`Roll ${app.name} back to revision ${target.revision}? The database and settings are not rolled back.`, flags))) return 1;
|
|
821
|
+
const result = await call(`/api/apps/${id}/actions`, json("POST", { action: "rollback", deploymentId: target.id }));
|
|
822
|
+
out(`Rollback to revision ${target.revision} queued${result.deploymentId ? ` (deployment ${result.deploymentId})` : ""}. Check vibingview status ${app.name}.`);
|
|
823
|
+
return 0;
|
|
824
|
+
}
|
|
825
|
+
if (sub === "settings") {
|
|
826
|
+
const current = await call(`/api/apps/${id}/settings`);
|
|
827
|
+
const settings = current.application?.build_settings ?? {};
|
|
828
|
+
if (flags.json) { out(JSON.stringify(settings, null, 2)); return 0; }
|
|
829
|
+
for (const [key, value] of Object.entries(settings)) if (key !== "dockerfile") out(`${key}: ${value === "" ? "(automatic)" : value}`);
|
|
830
|
+
if (settings.dockerfile) out("dockerfile: (custom, set)");
|
|
831
|
+
return 0;
|
|
832
|
+
}
|
|
833
|
+
if (sub === "set") {
|
|
834
|
+
const current = await call(`/api/apps/${id}/settings`);
|
|
835
|
+
const settings = { ...(current.application?.build_settings ?? {}) };
|
|
836
|
+
const map = { "root-dir": "rootDirectory", "install-command": "installCommand", "build-command": "buildCommand", "start-command": "startCommand", "output-dir": "outputDirectory" };
|
|
837
|
+
let changed = 0;
|
|
838
|
+
for (const [flag, key] of Object.entries(map)) if (typeof flags[flag] === "string") { settings[key] = flags[flag]; changed++; }
|
|
839
|
+
if (flags.port !== undefined) { const port = Number(flags.port); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError("--port must be 1-65535"); settings.port = port; changed++; }
|
|
840
|
+
if (flags.technique !== undefined) { const t = String(flags.technique); if (!["1", "2", "3", "4", "5"].includes(t)) throw new CliError("--technique must be 1, 2, 3, 4 or 5"); settings.builder = t === "1" ? "auto" : `technique-${t}`; changed++; }
|
|
841
|
+
if (typeof flags.dockerfile === "string") { settings.dockerfile = flags.dockerfile === "auto" ? "" : await readFile(path.resolve(flags.dockerfile), "utf8"); changed++; }
|
|
842
|
+
if (!changed) throw new CliError("Nothing to change. Pass --root-dir, --install-command, --build-command, --start-command, --output-dir, --port, --technique or --dockerfile <file|auto>.");
|
|
843
|
+
const result = await call(`/api/apps/${id}/settings`, json("PATCH", { settings }));
|
|
844
|
+
out(`Settings saved for ${app.name}. ${queuedNote(result)}`);
|
|
845
|
+
return 0;
|
|
846
|
+
}
|
|
847
|
+
await approvedAction("app.delete", { applicationId: app.id });
|
|
848
|
+
out(`${app.name} is being deleted.`);
|
|
849
|
+
return 0;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ----- domains ---------------------------------------------------------------------
|
|
853
|
+
if (command === "domains" || command === "domain") {
|
|
854
|
+
const sub = args[0];
|
|
855
|
+
if (sub === "owned") {
|
|
856
|
+
const { domains = [] } = await call("/api/domains");
|
|
857
|
+
if (flags.json) out(JSON.stringify(domains, null, 2)); else table(domains, [["name", "DOMAIN"], ["status", "STATUS"], ["expiresAt", "EXPIRES"], ["autoRenew", "AUTO-RENEW"], ["id", "ID"]]);
|
|
858
|
+
return 0;
|
|
859
|
+
}
|
|
860
|
+
if (sub === "connect" || sub === "disconnect") {
|
|
861
|
+
if (!args[1]) throw new CliError(`Usage: vibingview domains ${sub} <domain>${sub === "connect" ? " --app <app>" : ""}`);
|
|
862
|
+
const { domains = [] } = await call("/api/domains");
|
|
863
|
+
const owned = domains.find(item => item.id === args[1] || item.name === args[1].toLowerCase());
|
|
864
|
+
if (!owned) throw new CliError(`${args[1]} is not one of your bought domains (vibingview domains owned)`);
|
|
865
|
+
if (sub === "connect") { const app = await resolveApp(); await approvedAction("domain.connect", { domainId: owned.id, applicationId: app.id }); out(`${owned.name} now points to ${app.name}. HTTPS is ready in a few minutes.`); }
|
|
866
|
+
else { await approvedAction("domain.disconnect", { domainId: owned.id }); out(`${owned.name} is disconnected.`); }
|
|
867
|
+
return 0;
|
|
868
|
+
}
|
|
869
|
+
const app = await resolveApp(sub === undefined || sub === "list" ? args[1] : undefined);
|
|
870
|
+
const id = encodeURIComponent(app.id);
|
|
871
|
+
const { domains = [] } = await call(`/api/apps/${id}/domains`);
|
|
872
|
+
const find = host => { const found = domains.find(item => item.hostname === String(host).toLowerCase() || item.id === host); if (!found) throw new CliError(`${host} is not a domain of ${app.name} (vibingview domains)`); return found; };
|
|
873
|
+
if (sub === undefined || sub === "list") {
|
|
874
|
+
if (flags.json) out(JSON.stringify(domains, null, 2)); else table(domains.map(d => ({ ...d, verified: d.verified_at ? "yes" : "waiting", https: d.certificate_status })), [["hostname", "DOMAIN"], ["type", "TYPE"], ["verified", "VERIFIED"], ["https", "HTTPS"]]);
|
|
875
|
+
return 0;
|
|
876
|
+
}
|
|
877
|
+
if (sub === "add") {
|
|
878
|
+
if (!args[1]) throw new CliError("Usage: vibingview domains add <hostname> [--app <app>]");
|
|
879
|
+
const result = await call(`/api/apps/${id}/domains`, json("POST", { hostname: args[1] }));
|
|
880
|
+
const added = new Set((result.domains ?? []).map(item => item.hostname));
|
|
881
|
+
const { domains: now = [] } = await call(`/api/apps/${id}/domains`);
|
|
882
|
+
out(`Added ${[...added].join(" and ") || args[1]} to ${app.name}. Create these DNS records at your registrar (VibingView then connects the domain by itself within minutes; vibingview domains check ${args[1]} checks now):`);
|
|
883
|
+
for (const item of now.filter(item => added.has(item.hostname))) for (const record of item.records ?? []) out(` ${record.type ?? ""} ${record.name ?? record.host ?? ""} → ${record.value ?? ""}`);
|
|
884
|
+
return 0;
|
|
885
|
+
}
|
|
886
|
+
if (sub === "check") {
|
|
887
|
+
const result = await call(`/api/apps/${id}/domains`, json("PATCH", { domainId: find(args[1]).id }));
|
|
888
|
+
out(result.verified ? `${args[1]} is connected. HTTPS is issued automatically within minutes.` : result.message ?? `${args[1]} is not ready yet.`);
|
|
889
|
+
return result.verified ? 0 : 1;
|
|
890
|
+
}
|
|
891
|
+
if (sub === "remove") {
|
|
892
|
+
await approvedAction("domain.remove", { applicationId: app.id, domainId: find(args[1]).id });
|
|
893
|
+
out(`${args[1]} was removed from ${app.name}.`);
|
|
894
|
+
return 0;
|
|
895
|
+
}
|
|
896
|
+
throw new CliError("Usage: vibingview domains [list|add|check|remove|owned|connect|disconnect]");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (command === "nameservers") {
|
|
900
|
+
const sub = args[0];
|
|
901
|
+
const app = await resolveApp(sub === undefined || sub === "list" ? args[1] : undefined);
|
|
902
|
+
const id = encodeURIComponent(app.id);
|
|
903
|
+
const { zones = [] } = await call(`/api/apps/${id}/nameservers`);
|
|
904
|
+
const find = domain => { const found = zones.find(zone => zone.domain === String(domain).toLowerCase().replace(/^www\./, "") || zone.id === domain); if (!found) throw new CliError(`${domain} is not connected with nameservers on ${app.name}`); return found; };
|
|
905
|
+
if (sub === undefined || sub === "list") {
|
|
906
|
+
if (flags.json) out(JSON.stringify(zones, null, 2)); else table(zones.map(zone => ({ ...zone, ns: zone.nameservers.join(", ") })), [["domain", "DOMAIN"], ["status", "STATUS"], ["ns", "NAMESERVERS"]]);
|
|
907
|
+
return 0;
|
|
908
|
+
}
|
|
909
|
+
if (sub === "connect") {
|
|
910
|
+
if (!args[1]) throw new CliError("Usage: vibingview nameservers connect <domain> [--app <app>]");
|
|
911
|
+
const result = await call(`/api/apps/${id}/nameservers`, json("POST", { domain: args[1] }));
|
|
912
|
+
const zone = result.zone ?? result;
|
|
913
|
+
out(`Set these nameservers at your registrar for ${zone.domain ?? args[1]}:\n${(zone.nameservers ?? []).map(ns => ` ${ns}`).join("\n")}\nVibingView checks automatically; run vibingview nameservers check ${args[1]} to check now.`);
|
|
914
|
+
return 0;
|
|
915
|
+
}
|
|
916
|
+
if (sub === "check") {
|
|
917
|
+
const result = await call(`/api/apps/${id}/nameservers`, json("PATCH", { zoneId: find(args[1]).id, action: "check" }));
|
|
918
|
+
const status = typeof result.status === "string" ? result.status : result.status?.status ?? result.zone?.status ?? "checked";
|
|
919
|
+
out(`${args[1]}: ${status}`);
|
|
920
|
+
return 0;
|
|
921
|
+
}
|
|
922
|
+
if (sub === "disconnect") {
|
|
923
|
+
await approvedAction("nameservers.disconnect", { applicationId: app.id, zoneId: find(args[1]).id });
|
|
924
|
+
out(`${args[1]} was disconnected. Set your nameservers back at your registrar.`);
|
|
925
|
+
return 0;
|
|
926
|
+
}
|
|
927
|
+
throw new CliError("Usage: vibingview nameservers [list|connect|check|disconnect]");
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ----- billing ---------------------------------------------------------------------
|
|
931
|
+
if (command === "balance") {
|
|
932
|
+
const { balanceCents = 0 } = await call("/api/account/balance");
|
|
933
|
+
if (flags.json) out(JSON.stringify({ balanceCents }, null, 2)); else out(`Wallet balance: ${euros(balanceCents)}`);
|
|
934
|
+
return 0;
|
|
935
|
+
}
|
|
936
|
+
if (command === "payments") {
|
|
937
|
+
const { payments = [], entries = [] } = await call("/api/payments/activity");
|
|
938
|
+
if (flags.json) { out(JSON.stringify({ payments, entries }, null, 2)); return 0; }
|
|
939
|
+
table(payments.map(p => ({ ...p, amount: euros(p.amount_cents), date: String(p.created_at).slice(0, 10) })), [["date", "DATE"], ["amount", "AMOUNT"], ["provider", "METHOD"], ["status", "STATUS"]]);
|
|
940
|
+
return 0;
|
|
941
|
+
}
|
|
942
|
+
if (command === "topup") {
|
|
943
|
+
const amount = Number(String(args[0] ?? "").replace(",", "."));
|
|
944
|
+
if (!(amount >= 5 && amount <= 1000)) throw new CliError("Usage: vibingview topup <euros 5-1000> [--method card|crypto]");
|
|
945
|
+
const method = flags.method ?? "card";
|
|
946
|
+
if (!["card", "crypto"].includes(method)) throw new CliError("--method must be card or crypto");
|
|
947
|
+
const result = await approvedAction("wallet.topup", { amountCents: Math.round(amount * 100), method });
|
|
948
|
+
out(`Pay here (the wallet is credited when the payment is confirmed):\n ${result.checkoutUrl}`);
|
|
949
|
+
if (!flags["no-browser"]) openBrowser(result.checkoutUrl);
|
|
950
|
+
return 0;
|
|
629
951
|
}
|
|
630
952
|
|
|
631
953
|
// ----- catalog apps ------------------------------------------------------------
|
package/src/mcp.mjs
CHANGED
|
@@ -46,7 +46,7 @@ async function handle(message) {
|
|
|
46
46
|
const params = message.params && typeof message.params === "object" && !Array.isArray(message.params) ? message.params : {};
|
|
47
47
|
if (message.method === "initialize") {
|
|
48
48
|
const protocolVersion = SUPPORTED_VERSIONS.includes(params.protocolVersion) ? params.protocolVersion : SUPPORTED_VERSIONS[0];
|
|
49
|
-
return { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "vibingview", version: "0.
|
|
49
|
+
return { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "vibingview", version: "0.2.0" }, instructions: INSTRUCTIONS };
|
|
50
50
|
}
|
|
51
51
|
if (message.method === "ping") return {};
|
|
52
52
|
if (message.method === "tools/list") {
|