@vibingview/cli 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VibingView
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # @vibingview/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command-line interface and stdio MCP bridge for [VibingView](https://vibingview.com): pair your terminal with browser approval, deploy a project directory to your VPS workspace, follow builds, read logs, manage environment variables and databases, and connect AI editors through MCP.
4
+
5
+ - Node.js 18 or newer. No runtime dependencies.
6
+ - Commands: `vibingview` and `vibingview-mcp`.
7
+
8
+ ```bash
9
+ npx @vibingview/cli login
10
+ npx @vibingview/cli deploy
11
+ npx @vibingview/cli mcp install
12
+ ```
13
+
14
+ Or install it globally: `npm install -g @vibingview/cli`, then run `vibingview …`.
15
+
16
+ ## Choosing the control plane
17
+
18
+ By default the CLI talks to `https://vibingview.com`. To use another VibingView instance, pass `--url <origin>` (any command) or set `VIBINGVIEW_URL`. `login` stores the origin together with its token, so later commands use the instance you logged in to. Plain `http://` is accepted only for `localhost`.
19
+
20
+ ## Sign in
21
+
22
+ ```bash
23
+ vibingview login # prints a verification link and pairing code, opens your browser
24
+ vibingview login --no-browser
25
+ vibingview whoami # account and token permissions
26
+ vibingview logout # revokes the token on the server and forgets it locally
27
+ ```
28
+
29
+ `login` uses device pairing: it shows a link and a short code, and nothing is authorized until you open the link while signed in, check that the code matches, and explicitly approve it. The token itself is never printed. `vibingview token` shows a masked token; `--show` prints it in full with a warning.
30
+
31
+ ## Deploy
32
+
33
+ ```bash
34
+ vibingview deploy [dir] [--workspace <id|name>] [--name <app>] [--port <n>] [--domain <suffix|hostname>] [--force] [--yes]
35
+ vibingview status [app]
36
+ vibingview logs [app] [--build|--runtime] [--limit <n>] [--ai]
37
+ vibingview restart [app]
38
+ vibingview rebuild [app]
39
+ vibingview workspaces
40
+ vibingview apps
41
+ ```
42
+
43
+ `deploy` packages the directory in memory, excluding `.git`, `node_modules`, build output, virtualenvs, caches, `.env*` files, credentials, private keys, archives, binaries, local databases and large media, and refusing symlinks that escape the project. It skips an unchanged project unless `--force`, uploads it, follows the build until it succeeds or fails, and links the directory in `.vibingview/config.json` (added to `.gitignore`; it never contains your token).
44
+
45
+ ## Environment variables, ports, databases and catalog apps
46
+
47
+ ```bash
48
+ vibingview env get [NAME] [--reveal] # names only unless --reveal (audited)
49
+ vibingview env set NAME=value [...] # or: vibingview env set NAME --stdin
50
+ vibingview env remove NAME [...]
51
+ vibingview port set 8080
52
+ vibingview db list [--reveal]
53
+ vibingview db create <name> --engine postgres|mysql|redis|mongodb
54
+ vibingview db link <database-id|name>
55
+ vibingview db unlink
56
+ vibingview app list
57
+ vibingview app create <slug> [--name <app>]
58
+ vibingview app status [app]
59
+ ```
60
+
61
+ `--app <app>` selects an application; otherwise the linked project in the current directory is used.
62
+
63
+ ## MCP for AI editors
64
+
65
+ ```bash
66
+ vibingview mcp install [--client cursor|vscode|claude-code|claude-desktop] [--transport http|stdio] [--dry-run]
67
+ ```
68
+
69
+ `mcp install` merges a `vibingview` server entry into your user-level client configuration, keeps your other servers, backs up the existing file and writes it with owner-only permissions. Cursor, VS Code and Claude Code connect to the remote HTTP endpoint (`<origin>/api/mcp`). Claude Desktop's configuration file only launches local servers, so it gets a stdio entry that runs:
70
+
71
+ ```bash
72
+ npx -y @vibingview/cli mcp
73
+ ```
74
+
75
+ `vibingview mcp` (also available as `vibingview-mcp`) is a thin stdio bridge: it forwards tool calls to the same remote endpoint, so both transports expose identical tools and server-side permission checks. It reads `VIBINGVIEW_URL` and `VIBINGVIEW_TOKEN`; when they are not set, `vibingview mcp` uses the instance and token saved by `vibingview login`.
76
+
77
+ Your AI client acts with the token's permissions. The CLI token can request restarts/rebuilds and change environment variables and database links. For read-only access, create a read-only token in account settings and set `VIBINGVIEW_MCP_TOKEN` before running `mcp install`. Mutating tools are queued, not completed instantly, and no tool returns secret values.
78
+
79
+ ## Security notes
80
+
81
+ - The token is stored in `~/.vibingview/config.json` (directory `0700`, file `0600`). `VIBINGVIEW_TOKEN` overrides it.
82
+ - `login` issues a revocable 90-day token with `read`, `deploy` and `apps:manage`. `db create` and `db list --reveal` need a token with `manage`.
83
+ - MCP client configuration files contain your token. Keep them in your user profile, never in a shared repository.
84
+ - Secret values are printed only with `--reveal`, and every reveal is recorded in your account audit log.
85
+ - Requests do not follow redirects, and errors redact the token.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Standalone stdio MCP bridge; equivalent to `vibingview mcp` but configured only through
3
+ // VIBINGVIEW_URL / VIBINGVIEW_TOKEN environment variables.
4
+ import "../src/mcp.mjs";
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.mjs";
3
+ run();
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@vibingview/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "vibingview": "./bin/vibingview.mjs",
9
+ "vibingview-mcp": "./bin/vibingview-mcp.mjs"
10
+ },
11
+ "exports": {
12
+ ".": "./src/cli.mjs",
13
+ "./mcp": "./src/mcp.mjs",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "src/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "vibingview",
27
+ "cli",
28
+ "deploy",
29
+ "hosting",
30
+ "vps",
31
+ "mcp",
32
+ "model-context-protocol"
33
+ ],
34
+ "homepage": "https://vibingview.com",
35
+ "bugs": {
36
+ "url": "https://vibingview.com/dashboard/support",
37
+ "email": "support@vibingview.com"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,660 @@
1
+ // VibingView CLI (@vibingview/cli). Zero runtime dependencies; Node.js 18 or newer.
2
+ // Entry points: packages/cli/bin/vibingview.mjs (npm) and the repository's
3
+ // bin/vibingview.mjs / legacy bin/launchdeck.mjs wrappers all call run().
4
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
5
+ import { execFile } from "node:child_process";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import readline from "node:readline/promises";
9
+ import { fileURLToPath } from "node:url";
10
+ import { ensureGitignore, inferAppName, isIdenticalDeploy, packageProject, readProjectConfig, writeProjectConfig } from "./lib/project.mjs";
11
+ import { defaultTransport, installMcpConfig, maskToken, maskedEntry, mcpConfigPath, MCP_CLIENTS, NPM_PACKAGE } from "./lib/mcp-install.mjs";
12
+
13
+ export const VERSION = "0.1.0";
14
+ /** Production control plane used when no --url, VIBINGVIEW_URL or saved login selects another. */
15
+ export const DEFAULT_URL = "https://vibingview.com";
16
+ const USAGE = `VibingView CLI ${VERSION}
17
+
18
+ Account
19
+ vibingview login [--url <origin>] [--no-browser] Pair this terminal through browser approval
20
+ vibingview login --token <key> [--url <origin>] Sign in with the account key from your dashboard
21
+ vibingview logout Revoke the current token and forget it locally
22
+ vibingview whoami Show the signed-in account and token permissions
23
+ vibingview token [--show] Show the masked token (--show prints it in full)
24
+ vibingview mcp install [--client ${MCP_CLIENTS.join("|")}] [--transport http|stdio] [--local-bridge] [--dry-run]
25
+ vibingview mcp Run the stdio MCP bridge (used by AI clients)
26
+
27
+ Deploy
28
+ vibingview deploy [dir] [--workspace <id|name>] [--name <app>] [--port <n>] [--domain <suffix|hostname>] [--force] [--yes] [--no-wait] [--timeout <s>]
29
+ vibingview status [app] Application status and recent deployments
30
+ vibingview logs [app] [--build|--runtime] [--limit <n>] [--ai]
31
+ vibingview restart|rebuild [app]
32
+
33
+ Configuration (app defaults to the linked project in .vibingview/config.json)
34
+ vibingview env get [NAME] [--app <app>] [--reveal] Names only unless --reveal
35
+ vibingview env set NAME=value [...] [--app <app>] or: env set NAME --stdin
36
+ vibingview env remove NAME [...] [--app <app>]
37
+ vibingview port set <port> [--app <app>]
38
+
39
+ Databases
40
+ vibingview db list [--workspace <id|name>] [--reveal]
41
+ vibingview db create <name> --engine postgres|mysql|redis|mongodb [--workspace <id|name>]
42
+ vibingview db link <database-id|name> [--app <app>]
43
+ vibingview db unlink [--app <app>] [--yes]
44
+
45
+ One-click apps
46
+ vibingview app list Catalog recipes
47
+ vibingview app create <slug> [--name <app>] [--workspace <id|name>] [--domain <suffix>] [--yes]
48
+ vibingview app status [app]
49
+
50
+ Other
51
+ vibingview workspaces | apps
52
+
53
+ Control plane: --url <origin> on any command, else VIBINGVIEW_URL, else the instance you last logged in to,
54
+ else ${DEFAULT_URL}. VIBINGVIEW_TOKEN overrides the saved token.`;
55
+
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"]);
58
+
59
+ export function parseArgs(argv) {
60
+ const positional = [];
61
+ const flags = {};
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const arg = argv[i];
64
+ if (arg === "--") { positional.push(...argv.slice(i + 1)); break; }
65
+ if (arg === "-y") { flags.yes = true; continue; }
66
+ if (arg === "-h") { flags.help = true; continue; }
67
+ if (arg.startsWith("--")) {
68
+ const [key, inline] = arg.slice(2).split(/=(.*)/s, 2);
69
+ if (BOOLEAN_FLAGS.has(key)) { flags[key] = true; continue; }
70
+ if (!VALUE_FLAGS.has(key)) throw new Error(`Unknown option --${key}`);
71
+ const value = inline ?? argv[++i];
72
+ if (value === undefined || (inline === undefined && value.startsWith("--"))) throw new Error(`--${key} requires a value`);
73
+ flags[key] = value;
74
+ continue;
75
+ }
76
+ positional.push(arg);
77
+ }
78
+ return { positional, flags };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Credentials
83
+ // ---------------------------------------------------------------------------
84
+ const home = () => os.homedir();
85
+ const configDir = () => path.join(home(), ".vibingview");
86
+ const configFile = () => path.join(configDir(), "config.json");
87
+ const legacyFiles = () => [path.join(home(), ".config", "vibingview", "credentials.json"), path.join(home(), ".config", "launchdeck", "credentials.json")];
88
+
89
+ async function readJson(file) {
90
+ try { const value = JSON.parse(await readFile(file, "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } catch { return {}; }
91
+ }
92
+ async function loadConfig() {
93
+ const config = await readJson(configFile());
94
+ return { defaultUrl: typeof config.defaultUrl === "string" ? config.defaultUrl : undefined, servers: config.servers && typeof config.servers === "object" ? config.servers : {} };
95
+ }
96
+ async function saveConfig(config) {
97
+ await mkdir(configDir(), { recursive: true, mode: 0o700 });
98
+ await chmod(configDir(), 0o700).catch(() => {});
99
+ const temporary = `${configFile()}.${process.pid}.tmp`;
100
+ await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
101
+ await chmod(temporary, 0o600);
102
+ await rename(temporary, configFile());
103
+ }
104
+ function validBase(value) {
105
+ let url;
106
+ try { url = new URL(value); } catch { return false; }
107
+ if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) return false;
108
+ return url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // HTTP
113
+ // ---------------------------------------------------------------------------
114
+ class CliError extends Error {}
115
+ function createClient(base, token) {
116
+ const redact = text => (token ? String(text).split(token).join("[REDACTED]") : String(text));
117
+ return async function call(route, init = {}) {
118
+ let response;
119
+ try {
120
+ response = await fetch(`${base}${route}`, { ...init, redirect: "error", signal: AbortSignal.timeout(init.timeout ?? 120000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...init.headers } });
121
+ } catch (error) { throw new CliError(redact(`Could not reach ${base}: ${error.cause?.message ?? error.message}`)); }
122
+ if (response.status === 204) return {};
123
+ const body = await response.json().catch(() => ({}));
124
+ if (!response.ok) {
125
+ let message = typeof body.error === "string" ? body.error : body.error?.message ?? `HTTP ${response.status}`;
126
+ 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.";
127
+ throw Object.assign(new CliError(redact(message)), { status: response.status });
128
+ }
129
+ return body;
130
+ };
131
+ }
132
+ const json = (method, body) => ({ method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Output helpers
136
+ // ---------------------------------------------------------------------------
137
+ const out = text => process.stdout.write(`${text}\n`);
138
+ const warn = text => process.stderr.write(`${text}\n`);
139
+ function table(rows, columns) {
140
+ if (!rows.length) return out("(none)");
141
+ const widths = columns.map(([key, label]) => Math.min(60, Math.max(label.length, ...rows.map(row => String(row[key] ?? "").length))));
142
+ out(columns.map(([, label], i) => label.padEnd(widths[i])).join(" "));
143
+ for (const row of rows) out(columns.map(([key], i) => String(row[key] ?? "").slice(0, 60).padEnd(widths[i])).join(" "));
144
+ }
145
+ async function confirm(question, flags) {
146
+ if (flags.yes) return true;
147
+ if (!process.stdin.isTTY) throw new CliError(`${question} Re-run with --yes to confirm non-interactively.`);
148
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
149
+ try { return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim()); } finally { rl.close(); }
150
+ }
151
+ async function choose(label, items, describe) {
152
+ if (!process.stdin.isTTY) throw new CliError(`Several ${label}s are available; choose one with --${label}:\n${items.map(item => ` ${describe(item)}`).join("\n")}`);
153
+ items.forEach((item, index) => warn(` ${index + 1}) ${describe(item)}`));
154
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
155
+ try {
156
+ const answer = Number((await rl.question(`Choose a ${label} [1-${items.length}]: `)).trim());
157
+ if (!Number.isInteger(answer) || answer < 1 || answer > items.length) throw new CliError("No selection made");
158
+ return items[answer - 1];
159
+ } finally { rl.close(); }
160
+ }
161
+ async function readStdin() {
162
+ const chunks = [];
163
+ for await (const chunk of process.stdin) chunks.push(chunk);
164
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
165
+ }
166
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Main
170
+ // ---------------------------------------------------------------------------
171
+ export async function main(argv = process.argv.slice(2)) {
172
+ const [command, ...rest] = argv;
173
+ if (!command || ["help", "--help", "-h"].includes(command)) { out(USAGE); return 0; }
174
+ if (command === "--version" || command === "version") { out(VERSION); return 0; }
175
+ const { positional: args, flags } = parseArgs(rest);
176
+ if (flags.help) { out(USAGE); return 0; }
177
+
178
+ const config = await loadConfig();
179
+ const base = (flags.url ?? process.env.VIBINGVIEW_URL ?? process.env.LAUNCHDECK_URL ?? config.defaultUrl ?? DEFAULT_URL).replace(/\/$/, "");
180
+ if (!validBase(base)) throw new CliError("Use an HTTPS control-plane origin (http is allowed only for localhost), without a path, query or credentials.");
181
+ const legacy = {};
182
+ for (const file of legacyFiles().reverse()) Object.assign(legacy, await readJson(file));
183
+ const envToken = process.env.VIBINGVIEW_TOKEN || process.env.LAUNCHDECK_TOKEN || "";
184
+ const savedToken = typeof config.servers[base]?.token === "string" ? config.servers[base].token : typeof legacy[base] === "string" ? legacy[base] : "";
185
+ const token = envToken || savedToken;
186
+ const tokenSource = envToken ? "environment variable" : config.servers[base]?.token ? configFile() : savedToken ? "legacy credentials file" : "";
187
+ const call = createClient(base, token);
188
+ const requireToken = () => { if (!token) throw new CliError("Not signed in. Run vibingview login or set VIBINGVIEW_TOKEN."); };
189
+
190
+ // ----- account -------------------------------------------------------------
191
+ if (command === "login") {
192
+ // --token: save the account key shown in the dashboard instead of pairing through the browser.
193
+ if (typeof flags.token === "string") {
194
+ if (!/^ld_[A-Za-z0-9_-]{32,}$/.test(flags.token)) throw new CliError("That does not look like a VibingView key (it starts with ld_).");
195
+ const info = await createClient(base, flags.token)("/api/cli/session");
196
+ config.servers[base] = { url: base, token: flags.token, savedAt: new Date().toISOString() };
197
+ config.defaultUrl = base;
198
+ await saveConfig(config);
199
+ out(`Signed in as ${info.user?.email ?? "your account"} on ${base}. Key saved to ${configFile()} (owner-only permissions).`);
200
+ return 0;
201
+ }
202
+ const session = await createClient(base, "")("/api/cli/authorize", json("POST", { action: "start" }));
203
+ const verification = new URL(session.verificationUri);
204
+ out(`Open ${session.verificationUri}\nConfirm this pairing code in the browser: ${session.userCode}\nWaiting for your explicit browser approval…`);
205
+ if (!flags["no-browser"] && verification.origin === new URL(base).origin) openBrowser(verification.toString());
206
+ const deadline = Date.now() + session.expiresIn * 1000;
207
+ while (Date.now() < deadline) {
208
+ await sleep(Math.max(1, session.interval) * 1000);
209
+ const result = await createClient(base, "")("/api/cli/authorize", json("POST", { action: "poll", deviceCode: session.deviceCode }));
210
+ if (result.status === "authorized") {
211
+ // The origin is stored with its token and becomes the default, so later commands use this instance.
212
+ config.servers[base] = { url: base, token: result.token, savedAt: new Date().toISOString() };
213
+ config.defaultUrl = base;
214
+ await saveConfig(config);
215
+ out(`Authorized for ${base}. Token saved to ${configFile()} (owner-only permissions). Revoke it with vibingview logout or in account settings.`);
216
+ return 0;
217
+ }
218
+ if (["denied", "expired", "consumed"].includes(result.status)) throw new CliError(`Authorization ${result.status}`);
219
+ }
220
+ throw new CliError("Authorization expired");
221
+ }
222
+
223
+ if (command === "logout") {
224
+ if (!token) { out("Not signed in."); return 0; }
225
+ try {
226
+ const result = await call("/api/cli/session", { method: "DELETE" });
227
+ out(result.revoked === false ? "Your account key stays active for your other tools. Rotate it in the dashboard (Connectivity) if it may be exposed." : "Token revoked on the control plane.");
228
+ }
229
+ catch (error) { if (error.status === 401) out("The token was already invalid or revoked."); else throw error; }
230
+ if (config.servers[base]) { delete config.servers[base]; await saveConfig(config); }
231
+ for (const file of legacyFiles()) {
232
+ const values = await readJson(file);
233
+ if (values[base]) { delete values[base]; await writeFile(file, JSON.stringify(values), { mode: 0o600 }); }
234
+ }
235
+ if (envToken) warn("The token came from VIBINGVIEW_TOKEN; remove it from your environment too.");
236
+ out("Signed out.");
237
+ return 0;
238
+ }
239
+
240
+ if (command === "whoami") {
241
+ requireToken();
242
+ const info = await call("/api/cli/session");
243
+ if (flags.json) { out(JSON.stringify(info, null, 2)); return 0; }
244
+ out(`${info.user.displayName} <${info.user.email}>${info.user.emailVerified ? "" : " (email not verified)"}`);
245
+ out(`Control plane: ${base}`);
246
+ out(`Token: ${info.token.name} (${info.token.prefix}…) permissions: ${info.token.scopes.join(", ")}${info.token.expiresAt ? `; expires ${new Date(info.token.expiresAt).toISOString().slice(0, 10)}` : ""}`);
247
+ return 0;
248
+ }
249
+
250
+ if (command === "token") {
251
+ requireToken();
252
+ if (flags.show) {
253
+ warn("Warning: the full token grants account access. Do not paste it into chats, issues, logs or shared files.");
254
+ out(token);
255
+ return 0;
256
+ }
257
+ out(`Token: ${maskToken(token)} (from ${tokenSource})`);
258
+ try {
259
+ const info = await call("/api/cli/session");
260
+ out(`Name: ${info.token.name}\nPermissions: ${info.token.scopes.join(", ")}\nExpires: ${info.token.expiresAt ?? "never"}\nLast used: ${info.token.lastUsedAt ?? "never"}`);
261
+ } catch (error) { out(`Status: ${error.message}`); }
262
+ out("Use --show to print the full token.");
263
+ return 0;
264
+ }
265
+
266
+ if (command === "mcp" && (args[0] === undefined || args[0] === "serve")) {
267
+ // stdio bridge for AI clients: stdout carries JSON-RPC only, so nothing is printed here.
268
+ // Explicit environment wins; otherwise use the instance and token from `vibingview login`.
269
+ process.env.VIBINGVIEW_URL = base;
270
+ if (!process.env.VIBINGVIEW_TOKEN && !process.env.LAUNCHDECK_TOKEN) process.env.VIBINGVIEW_TOKEN = process.env.VIBINGVIEW_MCP_TOKEN || token;
271
+ await import("./mcp.mjs");
272
+ return 0;
273
+ }
274
+
275
+ if (command === "mcp") {
276
+ if (args[0] !== "install") throw new CliError("Usage: vibingview mcp [install [--client <client>] [--transport http|stdio] [--local-bridge] [--dry-run]]");
277
+ const mcpToken = process.env.VIBINGVIEW_MCP_TOKEN || token;
278
+ if (!mcpToken) throw new CliError("Not signed in. Run vibingview login (or set VIBINGVIEW_MCP_TOKEN to a dedicated token) first.");
279
+ let clients;
280
+ if (flags.client) {
281
+ if (!MCP_CLIENTS.includes(flags.client)) throw new CliError(`--client must be one of ${MCP_CLIENTS.join(", ")}`);
282
+ clients = [flags.client];
283
+ } else {
284
+ clients = [];
285
+ for (const client of MCP_CLIENTS) {
286
+ const file = mcpConfigPath(client, { home: home() });
287
+ if (await stat(client === "claude-code" ? file : path.dirname(file)).then(() => true, () => false)) clients.push(client);
288
+ }
289
+ if (!clients.length) throw new CliError(`No supported AI client configuration was found. Choose one with --client ${MCP_CLIENTS.join("|")}.`);
290
+ }
291
+ try {
292
+ const info = await createClient(base, mcpToken)("/api/cli/session");
293
+ warn(`Your AI client will act with this token's permissions: ${info.token.scopes.join(", ")}. ${info.token.scopes.some(scope => scope !== "read") ? "It can request restarts/rebuilds" + (info.token.scopes.some(scope => scope === "apps:manage" || scope === "manage") ? " and change app environment variables/database links" : "") + ". For read-only access, create a read-only token in account settings and set VIBINGVIEW_MCP_TOKEN before running this command." : "Read-only."}`);
294
+ } catch (error) { warn(`Could not confirm token permissions: ${error.message}`); }
295
+ // stdio entries launch the published package (`npx -y @vibingview/cli mcp`). --local-bridge instead
296
+ // points at this installation's bridge file, for unpublished/development checkouts.
297
+ const scriptPath = flags["local-bridge"] ? path.join(path.dirname(fileURLToPath(import.meta.url)), "mcp.mjs") : undefined;
298
+ for (const client of clients) {
299
+ const transport = flags.transport ?? defaultTransport(client);
300
+ const result = await installMcpConfig(client, { home: home(), transport, origin: base, token: mcpToken, scriptPath, nodePath: process.execPath, dryRun: Boolean(flags["dry-run"]) });
301
+ if (transport === "stdio" && !scriptPath) warn(`The stdio entry runs npx -y ${NPM_PACKAGE} mcp, which requires ${NPM_PACKAGE} to be published to npm. From an unpublished checkout, add --local-bridge.`);
302
+ out(`${flags["dry-run"] ? "Would write" : "Wrote"} ${client} (${transport}) → ${result.file}${result.backup ? ` (backup: ${result.backup})` : ""}`);
303
+ out(JSON.stringify({ vibingview: maskedEntry(result.entry, mcpToken) }, null, 2));
304
+ }
305
+ if (!flags["dry-run"]) out("Restart or reload the client, then review the VibingView tools before approving any action.");
306
+ return 0;
307
+ }
308
+
309
+ requireToken();
310
+
311
+ // ----- lookups -------------------------------------------------------------
312
+ const cwd = process.cwd();
313
+ const project = await readProjectConfig(cwd);
314
+ const workspacesList = async () => (await call("/api/workspaces")).workspaces ?? [];
315
+ const isRunning = workspace => String(workspace.status).toUpperCase() === "RUNNING";
316
+ async function resolveWorkspace(reference, { running = true } = {}) {
317
+ const all = await workspacesList();
318
+ const pool = running ? all.filter(isRunning) : all;
319
+ const ref = reference ?? project.workspaceId;
320
+ if (ref) {
321
+ const found = all.find(workspace => workspace.id === ref || workspace.name === ref);
322
+ if (!found) throw new CliError(`Workspace ${ref} not found`);
323
+ if (running && !isRunning(found)) throw new CliError(`Workspace ${found.name} is ${String(found.status).toLowerCase()}, not running`);
324
+ return found;
325
+ }
326
+ if (!pool.length) throw new CliError(running ? "No running workspace. Order or start one in the dashboard first." : "No workspaces found.");
327
+ if (pool.length === 1) return pool[0];
328
+ return choose("workspace", pool, workspace => `${workspace.name} (${workspace.id})`);
329
+ }
330
+ async function resolveApp(reference) {
331
+ const ref = reference ?? flags.app ?? project.applicationId;
332
+ if (!ref) throw new CliError("No application specified. Pass an app ID/name (or --app), or run inside a deployed project directory.");
333
+ const { applications = [] } = await call("/api/apps");
334
+ let matches = applications.filter(app => app.id === ref || app.name === ref);
335
+ if (matches.length > 1) {
336
+ const workspace = flags.workspace ?? project.workspaceId;
337
+ matches = matches.filter(app => !workspace || app.workspaceId === workspace);
338
+ }
339
+ if (!matches.length) throw new CliError(`Application ${ref} not found`);
340
+ if (matches.length > 1) throw new CliError(`Several applications are named ${ref}; pass --workspace or the application ID.`);
341
+ return matches[0];
342
+ }
343
+
344
+ // ----- listing ---------------------------------------------------------------
345
+ if (command === "workspaces") {
346
+ const rows = await workspacesList();
347
+ if (flags.json) out(JSON.stringify(rows, null, 2)); else table(rows, [["id", "ID"], ["name", "NAME"], ["status", "STATUS"], ["planId", "PLAN"], ["publicIp", "IP"]]);
348
+ return 0;
349
+ }
350
+ if (command === "apps") {
351
+ const { applications = [] } = await call("/api/apps");
352
+ if (flags.json) out(JSON.stringify(applications, null, 2)); else table(applications, [["id", "ID"], ["name", "NAME"], ["status", "STATUS"], ["url", "URL"]]);
353
+ return 0;
354
+ }
355
+
356
+ // ----- deploy ----------------------------------------------------------------
357
+ if (command === "deploy") {
358
+ if (args.length === 3 && args[2].toLowerCase().endsWith(".zip")) return legacyDeploy(call, args);
359
+ const directory = path.resolve(args[0] ?? ".");
360
+ const linked = await readProjectConfig(directory);
361
+ let packageName;
362
+ try { packageName = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")).name; } catch {}
363
+ await ensureGitignore(directory);
364
+ const pkg = await packageProject(directory);
365
+ out(`Packaged ${pkg.files.length} files (${(pkg.archive.length / 1024).toFixed(1)} KB, sha256 ${pkg.sha256.slice(0, 12)}…); excluded ${pkg.skipped.length} paths.`);
366
+ for (const message of pkg.warnings) warn(message);
367
+ if (isIdenticalDeploy(linked, pkg.sha256, flags.force)) { out("No changes since the last deploy. Use --force to redeploy anyway."); return 0; }
368
+
369
+ const { applications = [] } = await call("/api/apps");
370
+ let app = linked.applicationId && (!flags.name || flags.name === linked.name) ? applications.find(item => item.id === linked.applicationId) : undefined;
371
+ if (linked.applicationId && !app && !flags.name) warn("The linked application no longer exists; creating a new one.");
372
+ const catalog = await call("/api/catalog");
373
+ const suffixes = catalog.domains ?? [];
374
+ let suffix = linked.domainSuffix;
375
+ let customDomain;
376
+ if (flags.domain) {
377
+ const value = flags.domain.toLowerCase().replace(/^\.+/, "");
378
+ if (suffixes.includes(value)) suffix = value;
379
+ else if (/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value)) customDomain = value;
380
+ else throw new CliError(`--domain must be one of ${suffixes.join(", ")} or a custom hostname`);
381
+ }
382
+ if (!suffix || !suffixes.includes(suffix)) suffix = suffixes[0];
383
+ const port = flags.port === undefined ? undefined : Number(flags.port);
384
+ if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new CliError("--port must be 1-65535");
385
+
386
+ let deploymentId;
387
+ let workspaceId;
388
+ if (app) {
389
+ workspaceId = app.workspaceId;
390
+ if (port !== undefined && port !== app.internalPort) warn(`The port is unchanged by redeploys; run vibingview port set ${port} to change it.`);
391
+ if (!(await confirm(`Redeploy ${app.name} (${app.url ?? app.id}) with this source?`, flags))) return 1;
392
+ const form = new FormData();
393
+ form.set("file", new Blob([pkg.archive], { type: "application/zip" }), `${app.name}.zip`);
394
+ deploymentId = (await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, { method: "PATCH", body: form })).deploymentId;
395
+ } else {
396
+ const workspace = await resolveWorkspace(flags.workspace);
397
+ workspaceId = workspace.id;
398
+ const name = flags.name ?? linked.name ?? inferAppName(packageName, path.basename(directory));
399
+ if (!name || !/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/.test(name)) throw new CliError("Choose an application name with --name (3-40 lowercase letters, numbers or hyphens).");
400
+ if (!(await confirm(`Create ${name} on workspace ${workspace.name} (*.${suffix})${port ? ` listening on port ${port}` : ""}?`, flags))) return 1;
401
+ const form = new FormData();
402
+ form.set("workspaceId", workspace.id);
403
+ form.set("name", name);
404
+ if (suffix) form.set("domainSuffix", suffix);
405
+ if (port !== undefined) form.set("port", String(port));
406
+ form.set("file", new Blob([pkg.archive], { type: "application/zip" }), `${name}.zip`);
407
+ app = (await call("/api/apps", { method: "POST", body: form })).application;
408
+ deploymentId = app.lastDeploymentId;
409
+ }
410
+ 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() };
411
+ await writeProjectConfig(directory, state);
412
+ out(`Uploaded. Application ${app.name} (${app.id})${app.url ? ` → ${app.url}` : ""}`);
413
+
414
+ if (customDomain && customDomain !== linked.customDomain) {
415
+ try {
416
+ const result = await call(`/api/apps/${encodeURIComponent(app.id)}/domains`, json("POST", { hostname: customDomain }));
417
+ out(`Custom domain ${customDomain} added. Prove ownership and route traffic with DNS:\n CNAME ${result.instructions.cname.name} → ${result.instructions.cname.value}\n or TXT ${result.instructions.txt.name} = ${result.instructions.txt.value}\nThen verify it in the dashboard. HTTPS is issued only after verification.`);
418
+ } catch (error) { warn(`Could not add custom domain: ${error.message}`); }
419
+ }
420
+
421
+ if (flags["no-wait"]) { await writeProjectConfig(directory, { ...state, lastHash: pkg.sha256 }); out("Build queued. Follow it with vibingview logs --build."); return 0; }
422
+ const result = await waitForDeployment(call, app.id, deploymentId, Number(flags.timeout ?? 300) * 1000);
423
+ if (result === "succeeded") {
424
+ await writeProjectConfig(directory, { ...state, lastHash: pkg.sha256 });
425
+ out(`Deployment succeeded and passed its startup check.${app.url ? ` Live at ${app.url}` : ""}`);
426
+ return 0;
427
+ }
428
+ if (result === "failed") { warn("Deployment failed. Inspect it with vibingview logs --build, fix the issue and deploy again."); return 1; }
429
+ warn("Still building after the timeout. Check progress with vibingview status or vibingview logs --build.");
430
+ return 1;
431
+ }
432
+
433
+ // ----- status / logs -----------------------------------------------------------
434
+ if (command === "status" || (command === "app" && args[0] === "status")) {
435
+ const app = await resolveApp(command === "app" ? args[1] : args[0]);
436
+ const { deployments = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/deployments`).catch(() => ({ deployments: [] }));
437
+ if (flags.json) { out(JSON.stringify({ application: app, deployments: deployments.slice(0, 5) }, null, 2)); return 0; }
438
+ out(`${app.name} (${app.id})\n Status: ${app.status}\n URL: ${app.url ?? "(private)"}\n Workspace: ${app.workspaceId}\n Port: ${app.internalPort}\n Source: ${app.source}`);
439
+ if (deployments.length) { out("Recent deployments:"); table(deployments.slice(0, 5).map(d => ({ ...d, finished: d.finished_at ?? "" })), [["revision", "REV"], ["status", "STATUS"], ["created_at", "CREATED"], ["finished", "FINISHED"]]); }
440
+ return 0;
441
+ }
442
+
443
+ if (command === "logs") {
444
+ const legacyKind = ["build", "runtime"].includes(args[1]) ? args[1] : undefined;
445
+ const app = await resolveApp(args[0] && !["build", "runtime"].includes(args[0]) ? args[0] : undefined);
446
+ const kind = flags.build || legacyKind === "build" || args[0] === "build" ? "build" : "runtime";
447
+ const max = Math.min(1000, Math.max(1, Number(flags.limit ?? 200) || 200));
448
+ const { logs = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/logs?kind=${kind}&limit=${max}`);
449
+ if (flags.ai) {
450
+ 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`);
451
+ for (const line of logs) out(`${line.recorded_at ? new Date(line.recorded_at).toISOString() + " " : ""}${line.stream ? `[${line.stream}] ` : ""}${line.message}`);
452
+ out("```\n\nSecrets are redacted by the workspace agent before storage, but review the output before sharing it.");
453
+ } else for (const line of logs) out(line.message);
454
+ if (!logs.length) warn(`No ${kind} logs yet.`);
455
+ return 0;
456
+ }
457
+
458
+ if (command === "restart" || command === "rebuild") {
459
+ const app = await resolveApp(args[0]);
460
+ if (!(await confirm(`${command === "restart" ? "Restart" : "Rebuild"} ${app.name}? Traffic may be briefly interrupted.`, flags))) return 1;
461
+ const result = await call(`/api/apps/${encodeURIComponent(app.id)}/actions`, json("POST", { action: command }));
462
+ out(`${command === "restart" ? "Restart" : "Rebuild"} queued${result.deploymentId ? ` (deployment ${result.deploymentId})` : ""}. Queued is not completed; check vibingview status.`);
463
+ return 0;
464
+ }
465
+
466
+ // ----- environment -------------------------------------------------------------
467
+ if (command === "env") {
468
+ const sub = args[0];
469
+ if (!["get", "list", "set", "remove", "rm", "unset"].includes(sub)) {
470
+ // Legacy form: env <app-id> <NAME> <value>
471
+ const [id, name, value] = args;
472
+ if (!id || !name || value === undefined) throw new CliError("Usage: vibingview env get|set|remove … (see vibingview help)");
473
+ warn("Deprecated: use vibingview env set NAME=value --app <app>. Values passed as arguments may be kept in shell history.");
474
+ const result = await call(`/api/apps/${encodeURIComponent(id)}/environment`, json("POST", { name, value }));
475
+ out(`Set ${name}. Redeploy queued (deployment ${result.deploymentId}).`);
476
+ return 0;
477
+ }
478
+ const app = await resolveApp();
479
+ if (sub === "get" || sub === "list") {
480
+ const wanted = args[1];
481
+ if (flags.reveal) {
482
+ warn("Revealing secret values. This is recorded in your account audit log; keep the output private.");
483
+ const { variables = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, json("POST", { action: "reveal" }));
484
+ const selected = variables.filter(variable => !wanted || variable.name === wanted);
485
+ if (wanted && !selected.length) throw new CliError(`${wanted} is not set`);
486
+ for (const variable of selected) out(`${variable.name}=${variable.value}`);
487
+ return 0;
488
+ }
489
+ const { variables = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/environment`);
490
+ if (wanted) {
491
+ if (!variables.some(variable => variable.name === wanted)) throw new CliError(`${wanted} is not set`);
492
+ out(`${wanted} is set (value hidden; add --reveal to print it).`);
493
+ } else {
494
+ for (const variable of variables) out(variable.name);
495
+ if (!variables.length) warn("No environment variables.");
496
+ else warn("Values hidden; add --reveal to print them.");
497
+ }
498
+ return 0;
499
+ }
500
+ if (sub === "set") {
501
+ const environment = {};
502
+ const pairs = args.slice(1);
503
+ if (flags.stdin) {
504
+ if (pairs.length !== 1) throw new CliError("Usage: vibingview env set NAME --stdin");
505
+ environment[pairs[0]] = await readStdin();
506
+ } else {
507
+ if (!pairs.length) throw new CliError("Usage: vibingview env set NAME=value [NAME=value…] or NAME --stdin");
508
+ for (const pair of pairs) {
509
+ const index = pair.indexOf("=");
510
+ if (index < 1) throw new CliError(`Expected NAME=value, got ${pair.split("=")[0]}`);
511
+ environment[pair.slice(0, index)] = pair.slice(index + 1);
512
+ }
513
+ }
514
+ for (const name of Object.keys(environment)) if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(name)) throw new CliError(`Invalid variable name ${name}`);
515
+ const result = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, json("PATCH", { environment }));
516
+ out(`Set ${Object.keys(environment).join(", ")} on ${app.name}. Values stored encrypted. Redeploy queued (deployment ${result.deploymentId}).`);
517
+ return 0;
518
+ }
519
+ const names = args.slice(1);
520
+ if (!names.length) throw new CliError("Usage: vibingview env remove NAME [NAME…]");
521
+ const result = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, json("PATCH", { deleteVariables: names }));
522
+ out(`Removed ${names.join(", ")} from ${app.name}. Redeploy queued (deployment ${result.deploymentId}).`);
523
+ return 0;
524
+ }
525
+
526
+ if (command === "port") {
527
+ if (args[0] !== "set" || !args[1]) throw new CliError("Usage: vibingview port set <port> [--app <app>]");
528
+ const port = Number(args[1]);
529
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError("Port must be 1-65535");
530
+ const app = await resolveApp();
531
+ const current = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`);
532
+ const result = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, json("PATCH", { settings: { ...(current.application?.build_settings ?? {}), port } }));
533
+ out(`${app.name} now expects port ${port}. Redeploy queued (deployment ${result.deploymentId}).`);
534
+ return 0;
535
+ }
536
+
537
+ // ----- databases ---------------------------------------------------------------
538
+ if (command === "db") {
539
+ const sub = args[0];
540
+ if (sub === "list") {
541
+ const workspaces = flags.workspace || project.workspaceId ? [await resolveWorkspace(flags.workspace, { running: false })] : await workspacesList();
542
+ const rows = [];
543
+ for (const workspace of workspaces) {
544
+ const { databases = [] } = await call(`/api/workspaces/${encodeURIComponent(workspace.id)}/databases`);
545
+ for (const item of databases) rows.push({ ...item, workspace: workspace.name, workspaceId: workspace.id });
546
+ }
547
+ if (flags.reveal) {
548
+ warn("Revealing connection strings. Each reveal is audited; keep the output private. Requires a token with the 'manage' permission.");
549
+ for (const row of rows) {
550
+ const { connectionString } = await call(`/api/workspaces/${encodeURIComponent(row.workspaceId)}/databases`, json("PATCH", { databaseId: row.id, action: "reveal" }));
551
+ out(`${row.name} (${row.engine}, ${row.status}): ${connectionString}`);
552
+ }
553
+ return 0;
554
+ }
555
+ if (flags.json) out(JSON.stringify(rows, null, 2)); else table(rows, [["id", "ID"], ["name", "NAME"], ["engine", "ENGINE"], ["version", "VERSION"], ["status", "STATUS"], ["linked_apps", "APPS"], ["workspace", "WORKSPACE"]]);
556
+ return 0;
557
+ }
558
+ if (sub === "create") {
559
+ const name = args[1];
560
+ const engine = flags.engine ?? "postgres";
561
+ if (!name) throw new CliError("Usage: vibingview db create <name> --engine postgres|mysql|redis|mongodb");
562
+ if (!["postgres", "mysql", "redis", "mongodb"].includes(engine)) throw new CliError("--engine must be postgres, mysql, redis or mongodb");
563
+ const workspace = await resolveWorkspace(flags.workspace);
564
+ const result = await call(`/api/workspaces/${encodeURIComponent(workspace.id)}/databases`, json("POST", { name, engine }));
565
+ out(`Database ${result.database.name} (${result.database.engine} ${result.database.version}) is ${result.database.status} on ${workspace.name}. ID ${result.database.id}\nLink it with vibingview db link ${result.database.name}.`);
566
+ return 0;
567
+ }
568
+ if (sub === "link" || sub === "unlink") {
569
+ const app = await resolveApp();
570
+ let databaseId = null;
571
+ if (sub === "link") {
572
+ if (!args[1]) throw new CliError("Usage: vibingview db link <database-id|name> [--app <app>]");
573
+ const { databases = [] } = await call(`/api/workspaces/${encodeURIComponent(app.workspaceId)}/databases`);
574
+ const found = databases.find(item => item.id === args[1] || item.name === args[1]);
575
+ if (!found) throw new CliError(`Database ${args[1]} not found on the application's workspace`);
576
+ databaseId = found.id;
577
+ } else if (!(await confirm(`Unlink the database from ${app.name}? DATABASE_URL will be removed and the app redeployed.`, flags))) return 1;
578
+ const result = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, json("PATCH", { databaseId }));
579
+ out(`${sub === "link" ? `Linked database to ${app.name} as DATABASE_URL` : `Unlinked database from ${app.name}`}. Redeploy queued (deployment ${result.deploymentId}).`);
580
+ return 0;
581
+ }
582
+ throw new CliError("Usage: vibingview db list|create|link|unlink");
583
+ }
584
+
585
+ // ----- catalog apps ------------------------------------------------------------
586
+ if (command === "app") {
587
+ const sub = args[0];
588
+ if (sub === "list") {
589
+ const { apps = [] } = await call("/api/catalog");
590
+ if (flags.json) out(JSON.stringify(apps, null, 2)); else table(apps.map(app => ({ ...app, available: app.available ? "yes" : "operator image required" })), [["slug", "SLUG"], ["name", "NAME"], ["category", "CATEGORY"], ["available", "AVAILABLE"]]);
591
+ return 0;
592
+ }
593
+ if (sub === "create") {
594
+ const slug = args[1];
595
+ if (!slug) throw new CliError("Usage: vibingview app create <slug> [--name <app>] [--workspace <id|name>]");
596
+ const catalog = await call("/api/catalog");
597
+ const recipe = (catalog.apps ?? []).find(app => app.slug === slug);
598
+ if (!recipe) throw new CliError(`Unknown catalog app ${slug}. Run vibingview app list.`);
599
+ if (!recipe.available) throw new CliError(`${recipe.name} needs an operator-configured image on this control plane.`);
600
+ const suffixes = catalog.domains ?? [];
601
+ const suffix = flags.domain ? flags.domain.toLowerCase().replace(/^\.+/, "") : suffixes[0];
602
+ if (!suffixes.includes(suffix)) throw new CliError(`--domain must be one of ${suffixes.join(", ")}`);
603
+ const workspace = await resolveWorkspace(flags.workspace);
604
+ const name = flags.name ?? slug;
605
+ if (!(await confirm(`Create ${recipe.name} as ${name} on ${workspace.name}${recipe.database ? ` (also creates a private ${recipe.database} database)` : ""}?`, flags))) return 1;
606
+ const { application } = await call("/api/apps", json("POST", { workspaceId: workspace.id, name, template: slug, domainSuffix: suffix }));
607
+ out(`${recipe.name} is deploying as ${application.name} (${application.id})${application.url ? ` → ${application.url}` : ""}.\n${recipe.setup}\nSetup access and generated secrets are available in the dashboard. Check progress with vibingview app status ${application.id}.`);
608
+ return 0;
609
+ }
610
+ throw new CliError("Usage: vibingview app list|create|status");
611
+ }
612
+
613
+ throw new CliError(`Unknown command: ${command}\n\n${USAGE}`);
614
+ }
615
+
616
+ async function legacyDeploy(call, [workspaceId, name, file]) {
617
+ warn("Deprecated form. Prefer: vibingview deploy [dir] --workspace <id> --name <app>");
618
+ const form = new FormData();
619
+ form.set("workspaceId", workspaceId);
620
+ form.set("name", name);
621
+ form.set("file", new Blob([await readFile(file)], { type: "application/zip" }), path.basename(file));
622
+ const { application } = await call("/api/apps", { method: "POST", body: form });
623
+ out(`Uploaded ${application.name} (${application.id}). Follow the build with vibingview logs ${application.id} --build.`);
624
+ return 0;
625
+ }
626
+
627
+ async function waitForDeployment(call, applicationId, deploymentId, timeoutMs) {
628
+ const deadline = Date.now() + timeoutMs;
629
+ let printed = 0;
630
+ let lastStatus = "";
631
+ while (Date.now() < deadline) {
632
+ const [{ deployments = [] }, { logs = [] }] = await Promise.all([
633
+ call(`/api/apps/${encodeURIComponent(applicationId)}/deployments`).catch(() => ({})),
634
+ call(`/api/apps/${encodeURIComponent(applicationId)}/logs?kind=build&limit=1000`).catch(() => ({})),
635
+ ]);
636
+ const deployment = deployments.find(item => item.id === deploymentId) ?? deployments[0];
637
+ if (deployment && deployment.id === deploymentId) {
638
+ for (const line of logs.slice(printed)) out(` ${line.message}`);
639
+ printed = Math.max(printed, logs.length);
640
+ }
641
+ const status = deployment?.status ?? "queued";
642
+ if (status !== lastStatus) { out(`… ${status}`); lastStatus = status; }
643
+ if (status === "succeeded" || status === "failed") return status;
644
+ await sleep(2500);
645
+ }
646
+ return "timeout";
647
+ }
648
+
649
+ function openBrowser(url) {
650
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
651
+ try { execFile(command, args, { stdio: "ignore" }, () => {}).unref?.(); } catch {}
652
+ }
653
+
654
+ /** Run the CLI as a process: sets the exit code and prints errors without stack traces or tokens. */
655
+ export function run(argv = process.argv.slice(2)) {
656
+ return main(argv).then(code => { process.exitCode = code ?? 0; }, error => {
657
+ process.stderr.write(`${error instanceof CliError ? error.message : `Unexpected error: ${error.message ?? error}`}\n`);
658
+ process.exitCode = 1;
659
+ });
660
+ }
@@ -0,0 +1,98 @@
1
+ // Writes VibingView MCP entries into user-level AI client configuration files.
2
+ // Existing servers and settings are preserved; files are backed up first and
3
+ // written with owner-only permissions because the entry contains a token.
4
+ import { chmod, copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ export const MCP_CLIENTS = ["vscode", "cursor", "claude-code", "claude-desktop"];
8
+ export const SERVER_NAME = "vibingview";
9
+ /** npm package whose `mcp` subcommand runs the stdio bridge. */
10
+ export const NPM_PACKAGE = "@vibingview/cli";
11
+ /** Command + args that launch the stdio bridge from the published package (no local paths). */
12
+ export const NPX_BRIDGE = { command: "npx", args: ["-y", NPM_PACKAGE, "mcp"] };
13
+
14
+ /** Mask a bearer token for display: keeps a short prefix and the last four characters. */
15
+ export function maskToken(token) {
16
+ if (typeof token !== "string" || token.length < 12) return "••••";
17
+ return `${token.slice(0, 6)}…${token.slice(-4)}`;
18
+ }
19
+
20
+ /** User-level configuration path for a client. */
21
+ export function mcpConfigPath(client, { home, platform = process.platform, env = process.env }) {
22
+ const appData = env.APPDATA || path.join(home, "AppData", "Roaming");
23
+ const xdg = env.XDG_CONFIG_HOME || path.join(home, ".config");
24
+ const userData = platform === "darwin" ? path.join(home, "Library", "Application Support") : platform === "win32" ? appData : xdg;
25
+ switch (client) {
26
+ case "cursor": return path.join(home, ".cursor", "mcp.json");
27
+ case "vscode": return path.join(userData, "Code", "User", "mcp.json");
28
+ case "claude-code": return path.join(home, ".claude.json");
29
+ case "claude-desktop": return path.join(userData, "Claude", "claude_desktop_config.json");
30
+ default: throw new Error(`Unsupported client ${client}. Use one of: ${MCP_CLIENTS.join(", ")}`);
31
+ }
32
+ }
33
+
34
+ /** Claude Desktop's config file only launches local (stdio) servers. */
35
+ export function defaultTransport(client) {
36
+ return client === "claude-desktop" ? "stdio" : "http";
37
+ }
38
+
39
+ /** The server entry for one client. */
40
+ export function mcpServerEntry(client, { transport = defaultTransport(client), origin, token, scriptPath, nodePath = "node" }) {
41
+ if (!MCP_CLIENTS.includes(client)) throw new Error(`Unsupported client ${client}`);
42
+ if (!token) throw new Error("A VibingView token is required");
43
+ if (transport === "http") {
44
+ if (client === "claude-desktop") throw new Error("Claude Desktop's config file supports local stdio servers only; use --transport stdio or add the remote URL as a custom connector in Claude Desktop settings.");
45
+ const url = `${origin.replace(/\/$/, "")}/api/mcp`;
46
+ const headers = { Authorization: `Bearer ${token}` };
47
+ return client === "cursor" ? { url, headers } : { type: "http", url, headers };
48
+ }
49
+ if (transport !== "stdio") throw new Error("Transport must be http or stdio");
50
+ // Default: the published package via npx. An explicit absolute scriptPath (--local-bridge) runs a local bridge file.
51
+ if (scriptPath !== undefined && !path.isAbsolute(scriptPath)) throw new Error("The stdio connector needs an absolute script path");
52
+ const launch = scriptPath ? { command: nodePath, args: [scriptPath] } : { command: NPX_BRIDGE.command, args: [...NPX_BRIDGE.args] };
53
+ const entry = { ...launch, env: { VIBINGVIEW_URL: origin, VIBINGVIEW_TOKEN: token } };
54
+ return client === "vscode" ? { type: "stdio", ...entry } : entry;
55
+ }
56
+
57
+ /** Merge the entry into existing JSON text without touching other servers. */
58
+ export function mergeMcpConfig(existingText, client, entry) {
59
+ let config = {};
60
+ if (existingText && existingText.trim()) {
61
+ try { config = JSON.parse(existingText); } catch { throw new Error("The existing configuration is not plain JSON (it may contain comments). Add the entry manually."); }
62
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("The existing configuration is not a JSON object");
63
+ }
64
+ const key = client === "vscode" ? "servers" : "mcpServers";
65
+ const servers = config[key] && typeof config[key] === "object" && !Array.isArray(config[key]) ? config[key] : {};
66
+ return { ...config, [key]: { ...servers, [SERVER_NAME]: entry } };
67
+ }
68
+
69
+ /** Replace the token with its masked form for printing. */
70
+ export function maskedEntry(entry, token) {
71
+ return JSON.parse(JSON.stringify(entry).split(token).join(maskToken(token)));
72
+ }
73
+
74
+ /**
75
+ * Install the entry. Returns {file, backup, entry}. Never deletes or reorders
76
+ * other servers. A backup copy (owner-only) is written when the file existed.
77
+ */
78
+ export async function installMcpConfig(client, options) {
79
+ const file = options.file ?? mcpConfigPath(client, options);
80
+ const entry = mcpServerEntry(client, options);
81
+ let existing = "";
82
+ let existed = false;
83
+ try { existing = await readFile(file, "utf8"); existed = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
84
+ const merged = mergeMcpConfig(existing, client, entry);
85
+ if (options.dryRun) return { file, backup: undefined, entry, merged };
86
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
87
+ let backup;
88
+ if (existed) {
89
+ backup = `${file}.vibingview-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
90
+ await copyFile(file, backup);
91
+ await chmod(backup, 0o600);
92
+ }
93
+ const temporary = `${file}.vibingview-${process.pid}.tmp`;
94
+ await writeFile(temporary, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
95
+ await chmod(temporary, 0o600);
96
+ await rename(temporary, file);
97
+ return { file, backup, entry, merged };
98
+ }
@@ -0,0 +1,160 @@
1
+ // Project scanning, packaging and linked-project configuration for the CLI.
2
+ // Pure Node.js; no runtime dependencies.
3
+ import { createHash } from "node:crypto";
4
+ import { lstat, readdir, readFile, realpath, stat, mkdir, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { createZip, MAX_ZIP_ENTRIES } from "./zip.mjs";
7
+
8
+ export const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
9
+ export const MAX_SOURCE_BYTES = 500 * 1024 * 1024;
10
+ export const PROJECT_DIR = ".vibingview";
11
+ export const LEGACY_PROJECT_DIR = ".vibed";
12
+
13
+ const EXCLUDED_DIRECTORIES = new Set([
14
+ "node_modules", "dist", "build", "coverage", "venv", "__pycache__", "bower_components", "jspm_packages",
15
+ ]);
16
+ const SECRET_FILE_NAMES = new Set([
17
+ "credentials", "credentials.json", "credentials.yml", "credentials.yaml", "secrets.json", "secrets.yml", "secrets.yaml",
18
+ "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "known_hosts", "authorized_keys", "service-account.json",
19
+ ".npmrc", ".pypirc", ".netrc", ".git-credentials", ".htpasswd", ".pgpass", ".my.cnf", ".dockercfg",
20
+ ]);
21
+ const EXCLUDED_EXTENSIONS = new Set([
22
+ // private keys and certificates bundles
23
+ ".pem", ".key", ".p12", ".pfx", ".jks", ".keystore", ".ppk", ".gpg", ".kdbx",
24
+ // archives
25
+ ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".zst",
26
+ // binaries and compiled artefacts
27
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".obj", ".class", ".jar", ".war", ".pyc", ".pyo", ".dmg", ".iso", ".img", ".msi", ".deb", ".rpm", ".apk",
28
+ // local databases
29
+ ".sqlite", ".sqlite3", ".db",
30
+ // large media
31
+ ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv", ".mp3", ".wav", ".flac", ".aac", ".ogg", ".psd", ".ai", ".sketch",
32
+ // logs
33
+ ".log",
34
+ ]);
35
+ const ALLOWED_DOTFILES = new Set([".dockerignore", ".nvmrc", ".node-version", ".python-version", ".tool-versions", ".gitignore", ".htaccess", ".babelrc", ".browserslistrc", ".prettierrc", ".editorconfig", ".npmignore", ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".env.example", ".env.sample", ".env.template"]);
36
+
37
+ /** Whether a directory name must never be uploaded. */
38
+ export function isExcludedDirectory(name) {
39
+ return name.startsWith(".") || EXCLUDED_DIRECTORIES.has(name) || /^\.?venv[\w.-]*$/i.test(name);
40
+ }
41
+
42
+ /** Whether a file name must never be uploaded (secrets, archives, binaries, large media). */
43
+ export function isExcludedFile(name) {
44
+ const lower = name.toLowerCase();
45
+ if (SECRET_FILE_NAMES.has(lower)) return true;
46
+ if (lower.startsWith(".env")) return !ALLOWED_DOTFILES.has(lower);
47
+ if (lower.startsWith(".") && !ALLOWED_DOTFILES.has(lower)) return true;
48
+ if (/^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/.test(lower)) return true;
49
+ if (/(^|[-_.])(service[-_]?account|gcloud|firebase-adminsdk)[\w.-]*\.json$/.test(lower)) return true;
50
+ if (lower === ".ds_store" || lower === "thumbs.db") return true;
51
+ return EXCLUDED_EXTENSIONS.has(path.extname(lower));
52
+ }
53
+
54
+ function inside(root, candidate) {
55
+ const relative = path.relative(root, candidate);
56
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
57
+ }
58
+
59
+ /**
60
+ * Recursively list uploadable files. Symlinks that resolve outside the project
61
+ * root abort the scan; symlinks to files inside the root are followed and
62
+ * symlinked directories are skipped to avoid loops.
63
+ * @returns {Promise<{files:{name:string,absolute:string,size:number}[],skipped:string[],warnings:string[]}>}
64
+ */
65
+ export async function scanProject(directory) {
66
+ const root = await realpath(path.resolve(directory));
67
+ const rootStat = await stat(root);
68
+ if (!rootStat.isDirectory()) throw new Error(`${directory} is not a directory`);
69
+ const files = [];
70
+ const skipped = [];
71
+ const warnings = [];
72
+ let total = 0;
73
+ async function walk(current, prefix) {
74
+ const entries = (await readdir(current, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
75
+ for (const entry of entries) {
76
+ const absolute = path.join(current, entry.name);
77
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
78
+ if (entry.name.includes("\\") || /[\0\r\n]/.test(entry.name)) { skipped.push(name); continue; }
79
+ const info = await lstat(absolute);
80
+ if (info.isSymbolicLink()) {
81
+ let target;
82
+ try { target = await realpath(absolute); } catch { warnings.push(`Skipped broken symlink ${name}`); continue; }
83
+ if (!inside(root, target)) throw new Error(`Refusing to package ${name}: symlink points outside the project root`);
84
+ const targetInfo = await stat(target);
85
+ if (targetInfo.isDirectory()) { warnings.push(`Skipped symlinked directory ${name}`); continue; }
86
+ if (!targetInfo.isFile()) continue;
87
+ if (isExcludedFile(entry.name) || isExcludedFile(path.basename(target))) { skipped.push(name); continue; }
88
+ total += targetInfo.size;
89
+ files.push({ name, absolute: target, size: targetInfo.size, mode: targetInfo.mode });
90
+ } else if (info.isDirectory()) {
91
+ if (isExcludedDirectory(entry.name)) { skipped.push(`${name}/`); continue; }
92
+ await walk(absolute, name);
93
+ } else if (info.isFile()) {
94
+ if (isExcludedFile(entry.name)) { skipped.push(name); continue; }
95
+ total += info.size;
96
+ files.push({ name, absolute, size: info.size, mode: info.mode });
97
+ }
98
+ if (total > MAX_SOURCE_BYTES) throw new Error("Project source exceeds 500 MB after exclusions");
99
+ if (files.length > MAX_ZIP_ENTRIES) throw new Error(`Project contains more than ${MAX_ZIP_ENTRIES} files after exclusions`);
100
+ }
101
+ }
102
+ await walk(root, "");
103
+ if (!files.length) throw new Error("No deployable files found after exclusions");
104
+ return { root, files, skipped, warnings };
105
+ }
106
+
107
+ /** Package a project directory into an in-memory ZIP and hash it. */
108
+ export async function packageProject(directory) {
109
+ const scan = await scanProject(directory);
110
+ const entries = [];
111
+ for (const file of scan.files) entries.push({ name: file.name, data: await readFile(file.absolute), mode: file.mode & 0o111 ? 0o755 : 0o644 });
112
+ const archive = createZip(entries);
113
+ if (archive.length > MAX_ARCHIVE_BYTES) throw new Error(`Packaged project is ${(archive.length / 1048576).toFixed(1)} MB; the CLI limit is 50 MB`);
114
+ return { ...scan, archive, sha256: createHash("sha256").update(archive).digest("hex") };
115
+ }
116
+
117
+ /** True when an identical archive was already deployed for this linked app. */
118
+ export function isIdenticalDeploy(projectConfig, hash, force = false) {
119
+ return !force && Boolean(projectConfig?.applicationId) && typeof projectConfig?.lastHash === "string" && projectConfig.lastHash === hash;
120
+ }
121
+
122
+ /** Derive a valid application name from package.json or the directory name. */
123
+ export function inferAppName(packageName, directoryName) {
124
+ for (const candidate of [packageName, directoryName]) {
125
+ if (typeof candidate !== "string") continue;
126
+ const name = candidate.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "");
127
+ if (/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/.test(name)) return name;
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ export async function readProjectConfig(directory) {
133
+ for (const folder of [PROJECT_DIR, LEGACY_PROJECT_DIR]) {
134
+ try {
135
+ const value = JSON.parse(await readFile(path.join(directory, folder, "config.json"), "utf8"));
136
+ if (value && typeof value === "object" && !Array.isArray(value)) return folder === PROJECT_DIR ? value : { workspaceId: value.workspaceId, name: value.name ?? value.appName };
137
+ } catch {}
138
+ }
139
+ return {};
140
+ }
141
+
142
+ /** Persist linked-project state and keep the folder out of Git. Never stores tokens. */
143
+ export async function writeProjectConfig(directory, value) {
144
+ const folder = path.join(directory, PROJECT_DIR);
145
+ await mkdir(folder, { recursive: true });
146
+ const safe = Object.fromEntries(Object.entries(value).filter(([key, item]) => item !== undefined && !/token|secret|password/i.test(key)));
147
+ await writeFile(path.join(folder, "config.json"), JSON.stringify(safe, null, 2) + "\n");
148
+ await ensureGitignore(directory);
149
+ }
150
+
151
+ /** Add the linked-project folder to .gitignore (run before packaging so the hash stays stable). */
152
+ export async function ensureGitignore(directory) {
153
+ const gitignore = path.join(directory, ".gitignore");
154
+ let current = "";
155
+ try { current = await readFile(gitignore, "utf8"); } catch {}
156
+ const lines = current.split(/\r?\n/).map(line => line.trim());
157
+ if (!lines.includes(`${PROJECT_DIR}/`) && !lines.includes(PROJECT_DIR) && !lines.includes(`/${PROJECT_DIR}`) && !lines.includes(`/${PROJECT_DIR}/`)) {
158
+ await writeFile(gitignore, current + (current && !current.endsWith("\n") ? "\n" : "") + `${PROJECT_DIR}/\n`);
159
+ }
160
+ }
@@ -0,0 +1,110 @@
1
+ // Minimal, dependency-free ZIP writer used by the VibingView CLI.
2
+ // Writes standard PKZIP archives (local file headers, central directory and
3
+ // end-of-central-directory record). Deflate uses node:zlib; entries that do not
4
+ // shrink are stored. ZIP64 is intentionally unsupported: the CLI caps archives
5
+ // at 50 MB and 20,000 entries, well below the classic format's limits.
6
+ import zlib from "node:zlib";
7
+
8
+ const CRC_TABLE = (() => {
9
+ const table = new Uint32Array(256);
10
+ for (let n = 0; n < 256; n++) {
11
+ let c = n;
12
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
13
+ table[n] = c >>> 0;
14
+ }
15
+ return table;
16
+ })();
17
+
18
+ /** CRC-32 (IEEE) of a buffer. Uses zlib.crc32 when the runtime provides it. */
19
+ export function crc32(buffer) {
20
+ if (typeof zlib.crc32 === "function") return zlib.crc32(buffer) >>> 0;
21
+ let crc = 0xffffffff;
22
+ for (let i = 0; i < buffer.length; i++) crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
23
+ return (crc ^ 0xffffffff) >>> 0;
24
+ }
25
+
26
+ // A fixed timestamp (1980-01-01 00:00) keeps archives byte-for-byte
27
+ // reproducible, so an unchanged project produces the same SHA-256.
28
+ const DOS_TIME = 0;
29
+ const DOS_DATE = (0 << 9) | (1 << 5) | 1;
30
+ export const MAX_ZIP_ENTRIES = 20000;
31
+
32
+ function validEntryName(name) {
33
+ return typeof name === "string" && name.length > 0 && Buffer.byteLength(name) < 0xffff && !name.startsWith("/") &&
34
+ !name.includes("\\") && !name.includes("\0") && !/^[a-zA-Z]:/.test(name) && !name.split("/").some(part => part === ".." || part === "." || part === "");
35
+ }
36
+
37
+ /**
38
+ * Build a ZIP archive in memory.
39
+ * @param {{name:string,data:Buffer|Uint8Array|string,mode?:number}[]} entries
40
+ * @returns {Buffer}
41
+ */
42
+ export function createZip(entries) {
43
+ if (!Array.isArray(entries)) throw new TypeError("entries must be an array");
44
+ if (entries.length > MAX_ZIP_ENTRIES) throw new Error(`Archives are limited to ${MAX_ZIP_ENTRIES} files`);
45
+ const seen = new Set();
46
+ const locals = [];
47
+ const centrals = [];
48
+ let offset = 0;
49
+ for (const entry of entries) {
50
+ if (!validEntryName(entry.name)) throw new Error(`Unsafe archive path: ${entry.name}`);
51
+ if (seen.has(entry.name)) throw new Error(`Duplicate archive path: ${entry.name}`);
52
+ seen.add(entry.name);
53
+ const name = Buffer.from(entry.name, "utf8");
54
+ const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data);
55
+ const deflated = zlib.deflateRawSync(data, { level: 9 });
56
+ const stored = deflated.length >= data.length;
57
+ const body = stored ? data : deflated;
58
+ const method = stored ? 0 : 8;
59
+ const crc = crc32(data);
60
+ if (offset > 0xffffffff || body.length > 0xffffffff) throw new Error("Archive too large for the ZIP format used by this CLI");
61
+
62
+ const local = Buffer.alloc(30);
63
+ local.writeUInt32LE(0x04034b50, 0);
64
+ local.writeUInt16LE(20, 4); // version needed
65
+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
66
+ local.writeUInt16LE(method, 8);
67
+ local.writeUInt16LE(DOS_TIME, 10);
68
+ local.writeUInt16LE(DOS_DATE, 12);
69
+ local.writeUInt32LE(crc, 14);
70
+ local.writeUInt32LE(body.length, 18);
71
+ local.writeUInt32LE(data.length, 22);
72
+ local.writeUInt16LE(name.length, 26);
73
+ local.writeUInt16LE(0, 28);
74
+
75
+ const central = Buffer.alloc(46);
76
+ central.writeUInt32LE(0x02014b50, 0);
77
+ central.writeUInt16LE((3 << 8) | 20, 4); // made by: Unix, spec 2.0
78
+ central.writeUInt16LE(20, 6);
79
+ central.writeUInt16LE(0x0800, 8);
80
+ central.writeUInt16LE(method, 10);
81
+ central.writeUInt16LE(DOS_TIME, 12);
82
+ central.writeUInt16LE(DOS_DATE, 14);
83
+ central.writeUInt32LE(crc, 16);
84
+ central.writeUInt32LE(body.length, 20);
85
+ central.writeUInt32LE(data.length, 24);
86
+ central.writeUInt16LE(name.length, 28);
87
+ central.writeUInt16LE(0, 30); // extra
88
+ central.writeUInt16LE(0, 32); // comment
89
+ central.writeUInt16LE(0, 34); // disk
90
+ central.writeUInt16LE(0, 36); // internal attributes
91
+ // Regular file permissions only; never symlink or device bits.
92
+ central.writeUInt32LE((((entry.mode ?? 0o644) & 0o777) | 0o100000) * 0x10000 >>> 0, 38);
93
+ central.writeUInt32LE(offset, 42);
94
+
95
+ locals.push(local, name, body);
96
+ centrals.push(central, name);
97
+ offset += local.length + name.length + body.length;
98
+ }
99
+ const directory = Buffer.concat(centrals);
100
+ const end = Buffer.alloc(22);
101
+ end.writeUInt32LE(0x06054b50, 0);
102
+ end.writeUInt16LE(0, 4);
103
+ end.writeUInt16LE(0, 6);
104
+ end.writeUInt16LE(entries.length, 8);
105
+ end.writeUInt16LE(entries.length, 10);
106
+ end.writeUInt32LE(directory.length, 12);
107
+ end.writeUInt32LE(offset, 16);
108
+ end.writeUInt16LE(0, 20);
109
+ return Buffer.concat([...locals, directory, end]);
110
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // Standalone VibingView stdio MCP connector. Requires Node.js 18 or newer; no packages.
3
+ // It is a thin bridge: initialize/ping are answered locally, while tools/list and
4
+ // tools/call are forwarded to the control plane's remote MCP endpoint (/api/mcp),
5
+ // so the stdio and remote tool sets are always identical and scope-checked server-side.
6
+ // Keep this file self-contained (Node built-ins only): /api/mcp/download serves it as a
7
+ // single standalone file, and `vibingview mcp` imports it from the npm package.
8
+ import readline from "node:readline";
9
+
10
+ const base = (process.env.VIBINGVIEW_URL ?? process.env.LAUNCHDECK_URL ?? "https://vibingview.com").replace(/\/$/, "");
11
+ const token = process.env.VIBINGVIEW_TOKEN || process.env.LAUNCHDECK_TOKEN || "";
12
+ const SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
13
+ const INSTRUCTIONS = "Use only the user's own resources. Ask for approval before any mutating tool. Mutations are queued, not completed. Secret values are never returned.";
14
+
15
+ class RpcError extends Error {
16
+ constructor(code, message) { super(message); this.rpcCode = code; }
17
+ }
18
+ const redact = text => (token ? String(text).split(token).join("[REDACTED]") : String(text));
19
+
20
+ function endpoint() {
21
+ const url = new URL(base);
22
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/") throw new Error("VIBINGVIEW_URL must be the control-plane origin without credentials, a path or a query.");
23
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error("Use HTTPS for a remote VibingView control plane.");
24
+ if (!token) throw new Error("Set VIBINGVIEW_TOKEN to a scoped account token before using tools.");
25
+ return `${url.origin}/api/mcp`;
26
+ }
27
+
28
+ let forwardId = 0;
29
+ async function forward(method, params) {
30
+ const response = await fetch(endpoint(), {
31
+ method: "POST",
32
+ redirect: "error",
33
+ signal: AbortSignal.timeout(30000),
34
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": SUPPORTED_VERSIONS[0] },
35
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++forwardId, method, ...(params === undefined ? {} : { params }) }),
36
+ });
37
+ const body = await response.json().catch(() => null);
38
+ if (body?.error) throw new RpcError(response.status === 401 ? -32001 : body.error.code ?? -32603, redact(body.error.message ?? `VibingView returned HTTP ${response.status}`));
39
+ if (!response.ok || !body || !("result" in body)) throw new RpcError(-32603, `VibingView returned HTTP ${response.status}`);
40
+ return body.result;
41
+ }
42
+
43
+ const textResult = (text, isError = false) => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
44
+
45
+ async function handle(message) {
46
+ const params = message.params && typeof message.params === "object" && !Array.isArray(message.params) ? message.params : {};
47
+ if (message.method === "initialize") {
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.0" }, instructions: INSTRUCTIONS };
50
+ }
51
+ if (message.method === "ping") return {};
52
+ if (message.method === "tools/list") {
53
+ try { return await forward("tools/list"); }
54
+ catch (error) { throw error instanceof RpcError ? error : new RpcError(-32603, redact(error.message ?? error)); }
55
+ }
56
+ if (message.method === "tools/call") {
57
+ if (typeof params.name !== "string") throw new RpcError(-32602, "Tool name is required");
58
+ if (params.arguments !== undefined && (!params.arguments || typeof params.arguments !== "object" || Array.isArray(params.arguments))) throw new RpcError(-32602, "Tool arguments must be an object");
59
+ try { return await forward("tools/call", { name: params.name, arguments: params.arguments ?? {} }); }
60
+ catch (error) {
61
+ if (error instanceof RpcError && error.rpcCode === -32602) throw error;
62
+ return textResult(redact(error.message ?? error), true);
63
+ }
64
+ }
65
+ throw new RpcError(-32601, "Method not found");
66
+ }
67
+
68
+ function send(response) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...response }) + "\n"); }
69
+ const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
70
+ for await (const line of input) {
71
+ if (!line.trim()) continue;
72
+ let message;
73
+ try { message = JSON.parse(line); } catch { send({ id: null, error: { code: -32700, message: "Parse error" } }); continue; }
74
+ if (!message || Array.isArray(message) || message.jsonrpc !== "2.0" || typeof message.method !== "string" || (message.id !== undefined && message.id !== null && !["string", "number"].includes(typeof message.id))) { send({ id: null, error: { code: -32600, message: "Invalid request" } }); continue; }
75
+ // Notifications have no response and must never invoke an action.
76
+ if (message.id === undefined) continue;
77
+ try { send({ id: message.id, result: await handle(message) }); }
78
+ catch (error) { send({ id: message.id, error: { code: error.rpcCode ?? -32603, message: error.rpcCode ? error.message : "Internal error" } }); }
79
+ }