@vibingview/cli 0.1.3 → 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 +374 -20
- 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 = [];
|
|
@@ -122,11 +144,17 @@ function createClient(base, token) {
|
|
|
122
144
|
response = await fetch(`${base}${route}`, { ...init, redirect: "error", signal: AbortSignal.timeout(init.timeout ?? 120000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...init.headers } });
|
|
123
145
|
} catch (error) { throw new CliError(redact(`Could not reach ${base}: ${error.cause?.message ?? error.message}`)); }
|
|
124
146
|
if (response.status === 204) return {};
|
|
125
|
-
|
|
147
|
+
let body = {}, unreadable = false;
|
|
148
|
+
const text = await response.text().catch(() => { unreadable = true; return ""; });
|
|
149
|
+
if (text) { try { body = JSON.parse(text); } catch { unreadable = true; } }
|
|
150
|
+
// A success whose body was cut off (e.g. the connection dropped) must not read as "empty": callers would treat an
|
|
151
|
+
// unread app list as "no apps" and create a duplicate.
|
|
152
|
+
if (response.ok && unreadable)
|
|
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).`);
|
|
126
154
|
if (!response.ok) {
|
|
127
155
|
let message = typeof body.error === "string" ? body.error : body.error?.message ?? `HTTP ${response.status}`;
|
|
128
|
-
if (response.status === 401 && token) message += " — the token may be expired or revoked, or lacks the permission this command needs (
|
|
129
|
-
throw Object.assign(new CliError(redact(message)), { status: response.status });
|
|
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.";
|
|
157
|
+
throw Object.assign(new CliError(redact(message)), { status: response.status, code: typeof body.code === "string" ? body.code : undefined });
|
|
130
158
|
}
|
|
131
159
|
return body;
|
|
132
160
|
};
|
|
@@ -315,7 +343,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
315
343
|
// ----- lookups -------------------------------------------------------------
|
|
316
344
|
const cwd = process.cwd();
|
|
317
345
|
const project = await readProjectConfig(cwd);
|
|
318
|
-
const workspacesList = async () =>
|
|
346
|
+
const workspacesList = async () => { const { workspaces } = await call("/api/workspaces"); if (!Array.isArray(workspaces)) throw new CliError("The server did not send the list of workspaces. Nothing was changed; try again."); return workspaces; };
|
|
319
347
|
const isRunning = workspace => String(workspace.status).toUpperCase() === "RUNNING";
|
|
320
348
|
async function resolveWorkspace(reference, { running = true } = {}) {
|
|
321
349
|
const all = await workspacesList();
|
|
@@ -345,6 +373,41 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
345
373
|
return matches[0];
|
|
346
374
|
}
|
|
347
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
|
+
|
|
348
411
|
// ----- listing ---------------------------------------------------------------
|
|
349
412
|
if (command === "workspaces") {
|
|
350
413
|
const rows = await workspacesList();
|
|
@@ -352,12 +415,24 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
352
415
|
return 0;
|
|
353
416
|
}
|
|
354
417
|
if (command === "apps") {
|
|
355
|
-
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;
|
|
356
421
|
if (flags.json) out(JSON.stringify(applications, null, 2)); else table(applications, [["id", "ID"], ["name", "NAME"], ["status", "STATUS"], ["url", "URL"]]);
|
|
357
422
|
return 0;
|
|
358
423
|
}
|
|
359
424
|
|
|
360
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
|
+
}
|
|
361
436
|
if (command === "deploy") {
|
|
362
437
|
if (args.length === 3 && args[2].toLowerCase().endsWith(".zip")) return legacyDeploy(call, args);
|
|
363
438
|
const directory = path.resolve(args[0] ?? ".");
|
|
@@ -371,9 +446,22 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
371
446
|
reportNotableSkips(pkg.notable ?? []);
|
|
372
447
|
if (isIdenticalDeploy(linked, pkg.sha256, flags.force)) { out("No changes since the last deploy. Use --force to redeploy anyway."); return 0; }
|
|
373
448
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (linked.applicationId &&
|
|
449
|
+
if (linked.controlPlane && linked.controlPlane !== base) warn(`This folder was deployed to ${linked.controlPlane}; you are now using ${base}.`);
|
|
450
|
+
const appList = async () => { const { applications } = await call("/api/apps"); if (!Array.isArray(applications)) throw new CliError("The server did not send the list of applications. Nothing was changed; try again."); return applications; };
|
|
451
|
+
if (linked.applicationId && flags.name && flags.name !== linked.name && !flags.force) throw new CliError(`This folder is linked to the application "${linked.name}", but --name says "${flags.name}". Drop --name to redeploy it, use --force to create another app, or remove .vibingview/config.json.`);
|
|
452
|
+
const useLinked = linked.applicationId && (!flags.name || flags.name === linked.name);
|
|
453
|
+
let app = useLinked ? (await appList()).find(item => item.id === linked.applicationId) : undefined;
|
|
454
|
+
if (useLinked && !app) {
|
|
455
|
+
// One answer without the app must never create a duplicate: ask for the app itself before assuming it was deleted.
|
|
456
|
+
const deleted = await call(`/api/apps/${encodeURIComponent(linked.applicationId)}/settings`).then(() => false, error => {
|
|
457
|
+
if (error.status === 404 || error.code === "apps.not_found") return true;
|
|
458
|
+
throw new CliError(`Could not confirm that the linked application ${linked.name ?? linked.applicationId} was deleted (${error.message}). Nothing was created; try again.`);
|
|
459
|
+
});
|
|
460
|
+
if (!deleted) {
|
|
461
|
+
app = (await appList()).find(item => item.id === linked.applicationId);
|
|
462
|
+
if (!app) throw new CliError(`The linked application ${linked.name ?? linked.applicationId} exists but was missing from the server's list. Nothing was created; try again in a moment.`);
|
|
463
|
+
} else warn("The linked application was deleted; creating a new one.");
|
|
464
|
+
}
|
|
377
465
|
const catalog = await call("/api/catalog");
|
|
378
466
|
const suffixes = catalog.domains ?? [];
|
|
379
467
|
let suffix = linked.domainSuffix;
|
|
@@ -416,7 +504,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
416
504
|
if (port !== undefined) form.set("port", String(port));
|
|
417
505
|
if (builder) form.set("builder", builder);
|
|
418
506
|
form.set("file", new Blob([pkg.archive], { type: "application/zip" }), `${name}.zip`);
|
|
419
|
-
|
|
507
|
+
// A large upload must not time out client-side while the server finishes creating the app (that made a second app on retry).
|
|
508
|
+
app = (await call("/api/apps", { method: "POST", body: form, timeout: 120000 + Math.ceil(pkg.archive.length / 1_000_000) * 4000 })).application;
|
|
420
509
|
deploymentId = app.lastDeploymentId;
|
|
421
510
|
}
|
|
422
511
|
const state = { controlPlane: base, workspaceId, applicationId: app.id, name: app.name, url: app.url, domainSuffix: suffix, customDomain: customDomain ?? linked.customDomain, lastHash: linked.lastHash, deployedAt: new Date().toISOString() };
|
|
@@ -460,6 +549,19 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
460
549
|
const kind = flags.build || legacyKind === "build" || args[0] === "build" ? "build" : "runtime";
|
|
461
550
|
const max = Math.min(1000, Math.max(1, Number(flags.limit ?? 200) || 200));
|
|
462
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
|
+
}
|
|
463
565
|
if (flags.ai) {
|
|
464
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`);
|
|
465
567
|
for (const line of logs) out(`${line.recorded_at ? new Date(line.recorded_at).toISOString() + " " : ""}${line.stream ? `[${line.stream}] ` : ""}${clean(line.message)}`);
|
|
@@ -493,10 +595,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
493
595
|
if (sub === "get" || sub === "list") {
|
|
494
596
|
const wanted = args[1];
|
|
495
597
|
if (flags.reveal) {
|
|
496
|
-
warn("
|
|
497
|
-
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 }) ?? {};
|
|
498
600
|
const selected = variables.filter(variable => !wanted || variable.name === wanted);
|
|
499
601
|
if (wanted && !selected.length) throw new CliError(`${wanted} is not set`);
|
|
602
|
+
if (!selected.length) warn("No environment variables.");
|
|
500
603
|
for (const variable of selected) out(`${variable.name}=${variable.value}`);
|
|
501
604
|
return 0;
|
|
502
605
|
}
|
|
@@ -515,8 +618,20 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
515
618
|
const environment = {};
|
|
516
619
|
const pairs = args.slice(1);
|
|
517
620
|
if (flags.stdin) {
|
|
518
|
-
|
|
519
|
-
|
|
621
|
+
// One name: the whole input is its value. No name: one NAME=value per line (.env style), so secrets never sit in argv.
|
|
622
|
+
if (pairs.length > 1) throw new CliError("Usage: vibingview env set NAME --stdin, or env set --stdin with NAME=value lines on stdin");
|
|
623
|
+
const input = await readStdin();
|
|
624
|
+
if (pairs.length === 1) environment[pairs[0]] = input;
|
|
625
|
+
else for (const line of input.split(/\r?\n/)) {
|
|
626
|
+
const text = line.trim();
|
|
627
|
+
if (!text || text.startsWith("#")) continue;
|
|
628
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(text);
|
|
629
|
+
if (!match) throw new CliError(`Not a NAME=value line: ${text.slice(0, 40)}`);
|
|
630
|
+
let value = match[2];
|
|
631
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
632
|
+
environment[match[1]] = value;
|
|
633
|
+
}
|
|
634
|
+
if (!Object.keys(environment).length) throw new CliError("No NAME=value lines were read from stdin.");
|
|
520
635
|
} else {
|
|
521
636
|
if (!pairs.length) throw new CliError("Usage: vibingview env set NAME=value [NAME=value…] or NAME --stdin");
|
|
522
637
|
for (const pair of pairs) {
|
|
@@ -559,9 +674,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
559
674
|
for (const item of databases) rows.push({ ...item, workspace: workspace.name, workspaceId: workspace.id });
|
|
560
675
|
}
|
|
561
676
|
if (flags.reveal) {
|
|
562
|
-
warn("
|
|
677
|
+
warn("Showing a connection string needs your approval in the browser, one database at a time. Keep the output private.");
|
|
563
678
|
for (const row of rows) {
|
|
564
|
-
const { connectionString } = await
|
|
679
|
+
const { connectionString } = await approvedAction("db.reveal", { workspaceId: row.workspaceId, databaseId: row.id }) ?? {};
|
|
565
680
|
out(`${row.name} (${row.engine}, ${row.status}): ${connectionString}`);
|
|
566
681
|
}
|
|
567
682
|
return 0;
|
|
@@ -593,7 +708,246 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
593
708
|
out(`${sub === "link" ? `Linked database to ${app.name} as DATABASE_URL` : `Unlinked database from ${app.name}`}. ${queuedNote(result)}`);
|
|
594
709
|
return 0;
|
|
595
710
|
}
|
|
596
|
-
|
|
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;
|
|
597
951
|
}
|
|
598
952
|
|
|
599
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") {
|