@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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +374 -20
  3. package/src/mcp.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibingview/cli",
3
- "version": "0.1.3",
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.1.3";
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 | apps
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
- const body = await response.json().catch(() => ({}));
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 (database create/reveal need a token with the 'manage' permission). Run vibingview login or use an appropriately scoped VIBINGVIEW_TOKEN.";
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 () => (await call("/api/workspaces")).workspaces ?? [];
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 { applications = [] } = await call("/api/apps");
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
- const { applications = [] } = await call("/api/apps");
375
- let app = linked.applicationId && (!flags.name || flags.name === linked.name) ? applications.find(item => item.id === linked.applicationId) : undefined;
376
- if (linked.applicationId && !app && !flags.name) warn("The linked application no longer exists; creating a new one.");
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
- app = (await call("/api/apps", { method: "POST", body: form })).application;
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("Revealing secret values. This is recorded in your account audit log; keep the output private.");
497
- const { variables = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, json("POST", { action: "reveal" }));
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
- if (pairs.length !== 1) throw new CliError("Usage: vibingview env set NAME --stdin");
519
- environment[pairs[0]] = await readStdin();
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("Revealing connection strings. Each reveal is audited; keep the output private. Requires a token with the 'manage' permission.");
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 call(`/api/workspaces/${encodeURIComponent(row.workspaceId)}/databases`, json("PATCH", { databaseId: row.id, action: "reveal" }));
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
- throw new CliError("Usage: vibingview db list|create|link|unlink");
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.1.3" }, instructions: INSTRUCTIONS };
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") {