@vibingview/cli 0.1.1 → 0.1.4
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/README.md +4 -2
- package/package.json +4 -4
- package/src/cli.mjs +75 -21
- package/src/lib/project.mjs +35 -9
- package/src/mcp.mjs +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ vibingview logout # revokes the token on the server and forgets it loc
|
|
|
31
31
|
## Deploy
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
vibingview deploy [dir] [--workspace <id|name>] [--name <app>] [--port <n>] [--domain <suffix|hostname>] [--force] [--yes]
|
|
34
|
+
vibingview deploy [dir] [--workspace <id|name>] [--name <app>] [--port <n>] [--domain <suffix|hostname>] [--technique 1|2|3|4|5] [--force] [--yes]
|
|
35
35
|
vibingview status [app]
|
|
36
36
|
vibingview logs [app] [--build|--runtime] [--limit <n>] [--ai]
|
|
37
37
|
vibingview restart [app]
|
|
@@ -40,7 +40,9 @@ vibingview workspaces
|
|
|
40
40
|
vibingview apps
|
|
41
41
|
```
|
|
42
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
|
|
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 design source files (`.psd`, `.ai`, `.sketch`), and refusing symlinks that escape the project. Folders starting with a dot are skipped except `.well-known`; video and audio files are uploaded only from a folder the site serves files from (`public/`, `static/`, `assets/`, `media/`, `images/`…). Skipped files the site may need (stray media, unknown hidden folders, `.env` files) are listed by name after packaging. 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
|
+
`--technique` picks how a new app is built (default 5, "Smart": detects npm/pnpm/yarn/bun and the Node version, reuses installed packages between builds, ships the smallest safe image; other languages use technique 1). Changing environment variables or linking a database restarts a running app on its current build instead of rebuilding.
|
|
44
46
|
|
|
45
47
|
## Environment variables, ports, databases and catalog apps
|
|
46
48
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibingview/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"cli": "
|
|
9
|
-
"vibingview": "
|
|
10
|
-
"vibingview-mcp": "
|
|
8
|
+
"cli": "bin/vibingview.mjs",
|
|
9
|
+
"vibingview": "bin/vibingview.mjs",
|
|
10
|
+
"vibingview-mcp": "bin/vibingview-mcp.mjs"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
".": "./src/cli.mjs",
|
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.
|
|
13
|
+
export const VERSION = "0.1.4";
|
|
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}
|
|
@@ -25,7 +25,7 @@ Account
|
|
|
25
25
|
vibingview mcp Run the stdio MCP bridge (used by AI clients)
|
|
26
26
|
|
|
27
27
|
Deploy
|
|
28
|
-
vibingview deploy [dir] [--workspace <id|name>] [--name <app>] [--port <n>] [--domain <suffix|hostname>] [--force] [--yes] [--no-wait] [--timeout <s>]
|
|
28
|
+
vibingview deploy [dir] [--workspace <id|name>] [--name <app>] [--port <n>] [--domain <suffix|hostname>] [--technique 1|2|3|4|5] [--force] [--yes] [--no-wait] [--timeout <s>]
|
|
29
29
|
vibingview status [app] Application status and recent deployments
|
|
30
30
|
vibingview logs [app] [--build|--runtime] [--limit <n>] [--ai]
|
|
31
31
|
vibingview restart|rebuild [app]
|
|
@@ -54,7 +54,7 @@ Control plane: --url <origin> on any command, else VIBINGVIEW_URL, else the inst
|
|
|
54
54
|
else ${DEFAULT_URL}. VIBINGVIEW_TOKEN overrides the saved token.`;
|
|
55
55
|
|
|
56
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"]);
|
|
57
|
+
const VALUE_FLAGS = new Set(["workspace", "name", "port", "domain", "app", "limit", "engine", "client", "transport", "timeout", "url", "token", "technique", "builder"]);
|
|
58
58
|
|
|
59
59
|
export function parseArgs(argv) {
|
|
60
60
|
const positional = [];
|
|
@@ -81,6 +81,8 @@ export function parseArgs(argv) {
|
|
|
81
81
|
// ---------------------------------------------------------------------------
|
|
82
82
|
// Credentials
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
|
+
// The control plane restarts on the current image when only runtime settings changed (rebuild: false).
|
|
85
|
+
const queuedNote = result => result.rebuild === false ? `Restarting on the current build, no rebuild needed (deployment ${result.deploymentId}).` : `Redeploy queued (deployment ${result.deploymentId}).`;
|
|
84
86
|
const home = () => os.homedir();
|
|
85
87
|
const configDir = () => path.join(home(), ".vibingview");
|
|
86
88
|
const configFile = () => path.join(configDir(), "config.json");
|
|
@@ -120,11 +122,17 @@ function createClient(base, token) {
|
|
|
120
122
|
response = await fetch(`${base}${route}`, { ...init, redirect: "error", signal: AbortSignal.timeout(init.timeout ?? 120000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...init.headers } });
|
|
121
123
|
} catch (error) { throw new CliError(redact(`Could not reach ${base}: ${error.cause?.message ?? error.message}`)); }
|
|
122
124
|
if (response.status === 204) return {};
|
|
123
|
-
|
|
125
|
+
let body = {}, unreadable = false;
|
|
126
|
+
const text = await response.text().catch(() => { unreadable = true; return ""; });
|
|
127
|
+
if (text) { try { body = JSON.parse(text); } catch { unreadable = true; } }
|
|
128
|
+
// A success whose body was cut off (e.g. the connection dropped) must not read as "empty": callers would treat an
|
|
129
|
+
// unread app list as "no apps" and create a duplicate.
|
|
130
|
+
if (response.ok && unreadable)
|
|
131
|
+
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).`);
|
|
124
132
|
if (!response.ok) {
|
|
125
133
|
let message = typeof body.error === "string" ? body.error : body.error?.message ?? `HTTP ${response.status}`;
|
|
126
134
|
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 });
|
|
135
|
+
throw Object.assign(new CliError(redact(message)), { status: response.status, code: typeof body.code === "string" ? body.code : undefined });
|
|
128
136
|
}
|
|
129
137
|
return body;
|
|
130
138
|
};
|
|
@@ -135,6 +143,8 @@ const json = (method, body) => ({ method, headers: { "Content-Type": "applicatio
|
|
|
135
143
|
// Output helpers
|
|
136
144
|
// ---------------------------------------------------------------------------
|
|
137
145
|
const out = text => process.stdout.write(`${text}\n`);
|
|
146
|
+
// Log lines are third-party program output: strip ANSI/OSC escapes and C0 controls (except tab) before they reach the terminal.
|
|
147
|
+
const clean = text => String(text ?? "").replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
138
148
|
const warn = text => process.stderr.write(`${text}\n`);
|
|
139
149
|
function table(rows, columns) {
|
|
140
150
|
if (!rows.length) return out("(none)");
|
|
@@ -311,7 +321,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
311
321
|
// ----- lookups -------------------------------------------------------------
|
|
312
322
|
const cwd = process.cwd();
|
|
313
323
|
const project = await readProjectConfig(cwd);
|
|
314
|
-
const workspacesList = async () =>
|
|
324
|
+
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; };
|
|
315
325
|
const isRunning = workspace => String(workspace.status).toUpperCase() === "RUNNING";
|
|
316
326
|
async function resolveWorkspace(reference, { running = true } = {}) {
|
|
317
327
|
const all = await workspacesList();
|
|
@@ -364,11 +374,25 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
364
374
|
const pkg = await packageProject(directory);
|
|
365
375
|
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
376
|
for (const message of pkg.warnings) warn(message);
|
|
377
|
+
reportNotableSkips(pkg.notable ?? []);
|
|
367
378
|
if (isIdenticalDeploy(linked, pkg.sha256, flags.force)) { out("No changes since the last deploy. Use --force to redeploy anyway."); return 0; }
|
|
368
379
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (linked.applicationId &&
|
|
380
|
+
if (linked.controlPlane && linked.controlPlane !== base) warn(`This folder was deployed to ${linked.controlPlane}; you are now using ${base}.`);
|
|
381
|
+
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; };
|
|
382
|
+
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.`);
|
|
383
|
+
const useLinked = linked.applicationId && (!flags.name || flags.name === linked.name);
|
|
384
|
+
let app = useLinked ? (await appList()).find(item => item.id === linked.applicationId) : undefined;
|
|
385
|
+
if (useLinked && !app) {
|
|
386
|
+
// One answer without the app must never create a duplicate: ask for the app itself before assuming it was deleted.
|
|
387
|
+
const deleted = await call(`/api/apps/${encodeURIComponent(linked.applicationId)}/settings`).then(() => false, error => {
|
|
388
|
+
if (error.status === 404 || error.code === "apps.not_found") return true;
|
|
389
|
+
throw new CliError(`Could not confirm that the linked application ${linked.name ?? linked.applicationId} was deleted (${error.message}). Nothing was created; try again.`);
|
|
390
|
+
});
|
|
391
|
+
if (!deleted) {
|
|
392
|
+
app = (await appList()).find(item => item.id === linked.applicationId);
|
|
393
|
+
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.`);
|
|
394
|
+
} else warn("The linked application was deleted; creating a new one.");
|
|
395
|
+
}
|
|
372
396
|
const catalog = await call("/api/catalog");
|
|
373
397
|
const suffixes = catalog.domains ?? [];
|
|
374
398
|
let suffix = linked.domainSuffix;
|
|
@@ -381,6 +405,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
381
405
|
}
|
|
382
406
|
if (!suffix || !suffixes.includes(suffix)) suffix = suffixes[0];
|
|
383
407
|
const port = flags.port === undefined ? undefined : Number(flags.port);
|
|
408
|
+
// --technique 1|2 (or --builder auto|classic|railpack|technique-2) chooses how a new app is built.
|
|
409
|
+
const builderFlags = { "1": "auto", "2": "technique-2", "3": "technique-3", "4": "technique-4", "5": "technique-5", "technique-1": "auto", "technique-2": "technique-2", "technique-3": "technique-3", "technique-4": "technique-4", "technique-5": "technique-5", auto: "auto", classic: "classic", railpack: "railpack" };
|
|
410
|
+
const builderFlag = flags.technique ?? flags.builder;
|
|
411
|
+
const builder = builderFlag === undefined ? undefined : builderFlags[String(builderFlag).toLowerCase()];
|
|
412
|
+
if (builderFlag !== undefined && !builder) throw new CliError("--technique must be 1, 2, 3, 4 or 5 (or --builder auto|classic|railpack|technique-2|technique-3|technique-4|technique-5)");
|
|
384
413
|
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new CliError("--port must be 1-65535");
|
|
385
414
|
|
|
386
415
|
let deploymentId;
|
|
@@ -388,6 +417,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
388
417
|
if (app) {
|
|
389
418
|
workspaceId = app.workspaceId;
|
|
390
419
|
if (port !== undefined && port !== app.internalPort) warn(`The port is unchanged by redeploys; run vibingview port set ${port} to change it.`);
|
|
420
|
+
if (builder) warn("--technique applies when an app is created; change an existing app's build technique in its build settings on the dashboard.");
|
|
391
421
|
if (!(await confirm(`Redeploy ${app.name} (${app.url ?? app.id}) with this source?`, flags))) return 1;
|
|
392
422
|
const form = new FormData();
|
|
393
423
|
form.set("file", new Blob([pkg.archive], { type: "application/zip" }), `${app.name}.zip`);
|
|
@@ -403,8 +433,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
403
433
|
form.set("name", name);
|
|
404
434
|
if (suffix) form.set("domainSuffix", suffix);
|
|
405
435
|
if (port !== undefined) form.set("port", String(port));
|
|
436
|
+
if (builder) form.set("builder", builder);
|
|
406
437
|
form.set("file", new Blob([pkg.archive], { type: "application/zip" }), `${name}.zip`);
|
|
407
|
-
|
|
438
|
+
// A large upload must not time out client-side while the server finishes creating the app (that made a second app on retry).
|
|
439
|
+
app = (await call("/api/apps", { method: "POST", body: form, timeout: 120000 + Math.ceil(pkg.archive.length / 1_000_000) * 4000 })).application;
|
|
408
440
|
deploymentId = app.lastDeploymentId;
|
|
409
441
|
}
|
|
410
442
|
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() };
|
|
@@ -414,7 +446,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
414
446
|
if (customDomain && customDomain !== linked.customDomain) {
|
|
415
447
|
try {
|
|
416
448
|
const result = await call(`/api/apps/${encodeURIComponent(app.id)}/domains`, json("POST", { hostname: customDomain }));
|
|
417
|
-
|
|
449
|
+
// The server returns the records to add for each hostname it added (the main domain also adds www).
|
|
450
|
+
const lines = (result.domains ?? []).flatMap((d) => (d.records ?? []).map((r) => ` ${d.hostname}: ${r.type} ${r.name} → ${r.value}`));
|
|
451
|
+
out(`Custom domain ${customDomain} added. Add ${lines.length === 1 ? "this record" : "these records"} where you bought the domain:\n${lines.join("\n")}\nVibingView checks every few minutes and connects it with HTTPS by itself.`);
|
|
418
452
|
} catch (error) { warn(`Could not add custom domain: ${error.message}`); }
|
|
419
453
|
}
|
|
420
454
|
|
|
@@ -448,9 +482,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
448
482
|
const { logs = [] } = await call(`/api/apps/${encodeURIComponent(app.id)}/logs?kind=${kind}&limit=${max}`);
|
|
449
483
|
if (flags.ai) {
|
|
450
484
|
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}`);
|
|
485
|
+
for (const line of logs) out(`${line.recorded_at ? new Date(line.recorded_at).toISOString() + " " : ""}${line.stream ? `[${line.stream}] ` : ""}${clean(line.message)}`);
|
|
452
486
|
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);
|
|
487
|
+
} else for (const line of logs) out(clean(line.message));
|
|
454
488
|
if (!logs.length) warn(`No ${kind} logs yet.`);
|
|
455
489
|
return 0;
|
|
456
490
|
}
|
|
@@ -472,7 +506,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
472
506
|
if (!id || !name || value === undefined) throw new CliError("Usage: vibingview env get|set|remove … (see vibingview help)");
|
|
473
507
|
warn("Deprecated: use vibingview env set NAME=value --app <app>. Values passed as arguments may be kept in shell history.");
|
|
474
508
|
const result = await call(`/api/apps/${encodeURIComponent(id)}/environment`, json("POST", { name, value }));
|
|
475
|
-
out(`Set ${name}.
|
|
509
|
+
out(`Set ${name}. ${queuedNote(result)}`);
|
|
476
510
|
return 0;
|
|
477
511
|
}
|
|
478
512
|
const app = await resolveApp();
|
|
@@ -501,8 +535,20 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
501
535
|
const environment = {};
|
|
502
536
|
const pairs = args.slice(1);
|
|
503
537
|
if (flags.stdin) {
|
|
504
|
-
|
|
505
|
-
|
|
538
|
+
// One name: the whole input is its value. No name: one NAME=value per line (.env style), so secrets never sit in argv.
|
|
539
|
+
if (pairs.length > 1) throw new CliError("Usage: vibingview env set NAME --stdin, or env set --stdin with NAME=value lines on stdin");
|
|
540
|
+
const input = await readStdin();
|
|
541
|
+
if (pairs.length === 1) environment[pairs[0]] = input;
|
|
542
|
+
else for (const line of input.split(/\r?\n/)) {
|
|
543
|
+
const text = line.trim();
|
|
544
|
+
if (!text || text.startsWith("#")) continue;
|
|
545
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(text);
|
|
546
|
+
if (!match) throw new CliError(`Not a NAME=value line: ${text.slice(0, 40)}`);
|
|
547
|
+
let value = match[2];
|
|
548
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
549
|
+
environment[match[1]] = value;
|
|
550
|
+
}
|
|
551
|
+
if (!Object.keys(environment).length) throw new CliError("No NAME=value lines were read from stdin.");
|
|
506
552
|
} else {
|
|
507
553
|
if (!pairs.length) throw new CliError("Usage: vibingview env set NAME=value [NAME=value…] or NAME --stdin");
|
|
508
554
|
for (const pair of pairs) {
|
|
@@ -513,13 +559,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
513
559
|
}
|
|
514
560
|
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
561
|
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.
|
|
562
|
+
out(`Set ${Object.keys(environment).join(", ")} on ${app.name}. Values stored encrypted. ${queuedNote(result)}`);
|
|
517
563
|
return 0;
|
|
518
564
|
}
|
|
519
565
|
const names = args.slice(1);
|
|
520
566
|
if (!names.length) throw new CliError("Usage: vibingview env remove NAME [NAME…]");
|
|
521
567
|
const result = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`, json("PATCH", { deleteVariables: names }));
|
|
522
|
-
out(`Removed ${names.join(", ")} from ${app.name}.
|
|
568
|
+
out(`Removed ${names.join(", ")} from ${app.name}. ${queuedNote(result)}`);
|
|
523
569
|
return 0;
|
|
524
570
|
}
|
|
525
571
|
|
|
@@ -530,7 +576,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
530
576
|
const app = await resolveApp();
|
|
531
577
|
const current = await call(`/api/apps/${encodeURIComponent(app.id)}/settings`);
|
|
532
578
|
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}.
|
|
579
|
+
out(`${app.name} now expects port ${port}. ${queuedNote(result)}`);
|
|
534
580
|
return 0;
|
|
535
581
|
}
|
|
536
582
|
|
|
@@ -576,7 +622,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
576
622
|
databaseId = found.id;
|
|
577
623
|
} else if (!(await confirm(`Unlink the database from ${app.name}? DATABASE_URL will be removed and the app redeployed.`, flags))) return 1;
|
|
578
624
|
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}`}.
|
|
625
|
+
out(`${sub === "link" ? `Linked database to ${app.name} as DATABASE_URL` : `Unlinked database from ${app.name}`}. ${queuedNote(result)}`);
|
|
580
626
|
return 0;
|
|
581
627
|
}
|
|
582
628
|
throw new CliError("Usage: vibingview db list|create|link|unlink");
|
|
@@ -624,6 +670,14 @@ async function legacyDeploy(call, [workspaceId, name, file]) {
|
|
|
624
670
|
return 0;
|
|
625
671
|
}
|
|
626
672
|
|
|
673
|
+
/** Names the skipped files a website may need, so nothing disappears without a word. */
|
|
674
|
+
function reportNotableSkips(notable) {
|
|
675
|
+
const show = (items, text) => { if (!items.length) return; warn(`${text}\n${items.slice(0, 10).map(item => ` ${item.name}`).join("\n")}${items.length > 10 ? `\n …and ${items.length - 10} more` : ""}`); };
|
|
676
|
+
show(notable.filter(item => item.reason === "media"), "Not uploaded: video and audio files are only uploaded from a folder your site serves files from (public/, static/ or assets/). Move these there if the site uses them:");
|
|
677
|
+
show(notable.filter(item => item.reason === "hidden-folder"), "Not uploaded: folders whose names start with a dot (only .well-known is uploaded). If the site needs these files, move them to a normal folder:");
|
|
678
|
+
show(notable.filter(item => item.reason === "env"), "Not uploaded: .env files (they can hold secrets). Set the values the app needs with vibingview env set NAME1=value1 NAME2=value2 --app <app> after the first deploy:");
|
|
679
|
+
}
|
|
680
|
+
|
|
627
681
|
async function waitForDeployment(call, applicationId, deploymentId, timeoutMs) {
|
|
628
682
|
const deadline = Date.now() + timeoutMs;
|
|
629
683
|
let printed = 0;
|
|
@@ -635,7 +689,7 @@ async function waitForDeployment(call, applicationId, deploymentId, timeoutMs) {
|
|
|
635
689
|
]);
|
|
636
690
|
const deployment = deployments.find(item => item.id === deploymentId) ?? deployments[0];
|
|
637
691
|
if (deployment && deployment.id === deploymentId) {
|
|
638
|
-
for (const line of logs.slice(printed)) out(` ${line.message}`);
|
|
692
|
+
for (const line of logs.slice(printed)) out(` ${clean(line.message)}`);
|
|
639
693
|
printed = Math.max(printed, logs.length);
|
|
640
694
|
}
|
|
641
695
|
const status = deployment?.status ?? "queued";
|
package/src/lib/project.mjs
CHANGED
|
@@ -27,21 +27,39 @@ const EXCLUDED_EXTENSIONS = new Set([
|
|
|
27
27
|
".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a", ".obj", ".class", ".jar", ".war", ".pyc", ".pyo", ".dmg", ".iso", ".img", ".msi", ".deb", ".rpm", ".apk",
|
|
28
28
|
// local databases
|
|
29
29
|
".sqlite", ".sqlite3", ".db",
|
|
30
|
-
//
|
|
31
|
-
".
|
|
30
|
+
// design source files
|
|
31
|
+
".psd", ".ai", ".sketch",
|
|
32
32
|
// logs
|
|
33
33
|
".log",
|
|
34
34
|
]);
|
|
35
|
+
// Video and audio are uploaded when they sit in a folder a website serves files from (public/hero.mp4,
|
|
36
|
+
// src/assets/intro.webm); elsewhere they are usually raw footage and only make the upload bigger.
|
|
37
|
+
const MEDIA_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv", ".mp3", ".wav", ".flac", ".aac", ".ogg", ".oga", ".opus", ".m4a"]);
|
|
38
|
+
const ASSET_FOLDERS = new Set(["public", "static", "assets", "media", "images", "img", "video", "videos", "audio", "sounds", "fonts", "www", "wwwroot", "htdocs"]);
|
|
39
|
+
// Hidden folders a website really serves (domain checks, app links, payment-provider verification).
|
|
40
|
+
const ALLOWED_DOT_DIRECTORIES = new Set([".well-known"]);
|
|
41
|
+
// Hidden folders that are tools, caches or editor settings: skipped without a warning.
|
|
42
|
+
const TOOL_DOT_DIRECTORIES = new Set([".git", ".next", ".nuxt", ".svelte-kit", ".vercel", ".netlify", ".output", ".turbo", ".cache", ".parcel-cache", ".idea", ".vscode", ".fleet", ".zed", ".github", ".gitlab", ".husky", ".devcontainer", ".expo", ".angular", ".yarn", ".pnpm-store", ".venv", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".terraform", ".serverless", ".docusaurus", ".astro", ".vite", ".wrangler", ".firebase", ".aws-sam", ".claude", ".cursor", ".windsurf", ".vibingview", ".vibed"]);
|
|
35
43
|
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
44
|
|
|
37
45
|
/** Whether a directory name must never be uploaded. */
|
|
38
46
|
export function isExcludedDirectory(name) {
|
|
47
|
+
if (ALLOWED_DOT_DIRECTORIES.has(name.toLowerCase())) return false;
|
|
39
48
|
return name.startsWith(".") || EXCLUDED_DIRECTORIES.has(name) || /^\.?venv[\w.-]*$/i.test(name);
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
/**
|
|
43
|
-
export function
|
|
51
|
+
/** True when a project path sits in a folder a website serves files from (public/, static/, assets/…). */
|
|
52
|
+
export function inAssetFolder(relativePath) {
|
|
53
|
+
return relativePath.split("/").slice(0, -1).some(part => ASSET_FOLDERS.has(part.toLowerCase()));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a file must never be uploaded (secrets, archives, binaries, local databases, logs). Video and audio are
|
|
58
|
+
* uploaded only from asset folders, so pass the project-relative path to allow them there.
|
|
59
|
+
*/
|
|
60
|
+
export function isExcludedFile(name, relativePath = name) {
|
|
44
61
|
const lower = name.toLowerCase();
|
|
62
|
+
if (MEDIA_EXTENSIONS.has(path.extname(lower))) return !inAssetFolder(relativePath);
|
|
45
63
|
if (SECRET_FILE_NAMES.has(lower)) return true;
|
|
46
64
|
if (lower.startsWith(".env")) return !ALLOWED_DOTFILES.has(lower);
|
|
47
65
|
if (lower.startsWith(".") && !ALLOWED_DOTFILES.has(lower)) return true;
|
|
@@ -60,7 +78,9 @@ function inside(root, candidate) {
|
|
|
60
78
|
* Recursively list uploadable files. Symlinks that resolve outside the project
|
|
61
79
|
* root abort the scan; symlinks to files inside the root are followed and
|
|
62
80
|
* symlinked directories are skipped to avoid loops.
|
|
63
|
-
*
|
|
81
|
+
* `notable` lists skipped paths the site may really need (media outside asset folders, unknown hidden folders)
|
|
82
|
+
* and .env files whose settings must be set on the server instead; the CLI prints them by name.
|
|
83
|
+
* @returns {Promise<{files:{name:string,absolute:string,size:number}[],skipped:string[],warnings:string[],notable:{name:string,reason:string}[]}>}
|
|
64
84
|
*/
|
|
65
85
|
export async function scanProject(directory) {
|
|
66
86
|
const root = await realpath(path.resolve(directory));
|
|
@@ -69,6 +89,12 @@ export async function scanProject(directory) {
|
|
|
69
89
|
const files = [];
|
|
70
90
|
const skipped = [];
|
|
71
91
|
const warnings = [];
|
|
92
|
+
const notable = [];
|
|
93
|
+
const note = (relative, base) => {
|
|
94
|
+
const lower = base.toLowerCase();
|
|
95
|
+
if (MEDIA_EXTENSIONS.has(path.extname(lower))) notable.push({ name: relative, reason: "media" });
|
|
96
|
+
else if (lower.startsWith(".env") && !ALLOWED_DOTFILES.has(lower)) notable.push({ name: relative, reason: "env" });
|
|
97
|
+
};
|
|
72
98
|
let total = 0;
|
|
73
99
|
async function walk(current, prefix) {
|
|
74
100
|
const entries = (await readdir(current, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
@@ -84,14 +110,14 @@ export async function scanProject(directory) {
|
|
|
84
110
|
const targetInfo = await stat(target);
|
|
85
111
|
if (targetInfo.isDirectory()) { warnings.push(`Skipped symlinked directory ${name}`); continue; }
|
|
86
112
|
if (!targetInfo.isFile()) continue;
|
|
87
|
-
if (isExcludedFile(entry.name) || isExcludedFile(path.basename(target))) { skipped.push(name); continue; }
|
|
113
|
+
if (isExcludedFile(entry.name, name) || isExcludedFile(path.basename(target), name)) { skipped.push(name); note(name, entry.name); continue; }
|
|
88
114
|
total += targetInfo.size;
|
|
89
115
|
files.push({ name, absolute: target, size: targetInfo.size, mode: targetInfo.mode });
|
|
90
116
|
} else if (info.isDirectory()) {
|
|
91
|
-
if (isExcludedDirectory(entry.name)) { skipped.push(`${name}/`); continue; }
|
|
117
|
+
if (isExcludedDirectory(entry.name)) { skipped.push(`${name}/`); if (entry.name.startsWith(".") && !TOOL_DOT_DIRECTORIES.has(entry.name.toLowerCase()) && !/^\.?venv/i.test(entry.name)) notable.push({ name: `${name}/`, reason: "hidden-folder" }); continue; }
|
|
92
118
|
await walk(absolute, name);
|
|
93
119
|
} else if (info.isFile()) {
|
|
94
|
-
if (isExcludedFile(entry.name)) { skipped.push(name); continue; }
|
|
120
|
+
if (isExcludedFile(entry.name, name)) { skipped.push(name); note(name, entry.name); continue; }
|
|
95
121
|
total += info.size;
|
|
96
122
|
files.push({ name, absolute, size: info.size, mode: info.mode });
|
|
97
123
|
}
|
|
@@ -101,7 +127,7 @@ export async function scanProject(directory) {
|
|
|
101
127
|
}
|
|
102
128
|
await walk(root, "");
|
|
103
129
|
if (!files.length) throw new Error("No deployable files found after exclusions");
|
|
104
|
-
return { root, files, skipped, warnings };
|
|
130
|
+
return { root, files, skipped, warnings, notable };
|
|
105
131
|
}
|
|
106
132
|
|
|
107
133
|
/** Package a project directory into an in-memory ZIP and hash it. */
|
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.
|
|
49
|
+
return { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "vibingview", version: "0.1.4" }, instructions: INSTRUCTIONS };
|
|
50
50
|
}
|
|
51
51
|
if (message.method === "ping") return {};
|
|
52
52
|
if (message.method === "tools/list") {
|