@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 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 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).
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.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": "./bin/vibingview.mjs",
9
- "vibingview": "./bin/vibingview.mjs",
10
- "vibingview-mcp": "./bin/vibingview-mcp.mjs"
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.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
- const body = await response.json().catch(() => ({}));
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 () => (await call("/api/workspaces")).workspaces ?? [];
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
- 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.");
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
- app = (await call("/api/apps", { method: "POST", body: form })).application;
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
- 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.`);
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}. Redeploy queued (deployment ${result.deploymentId}).`);
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
- if (pairs.length !== 1) throw new CliError("Usage: vibingview env set NAME --stdin");
505
- environment[pairs[0]] = await readStdin();
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. Redeploy queued (deployment ${result.deploymentId}).`);
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}. Redeploy queued (deployment ${result.deploymentId}).`);
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}. Redeploy queued (deployment ${result.deploymentId}).`);
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}`}. Redeploy queued (deployment ${result.deploymentId}).`);
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";
@@ -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
- // large media
31
- ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv", ".mp3", ".wav", ".flac", ".aac", ".ogg", ".psd", ".ai", ".sketch",
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
- /** Whether a file name must never be uploaded (secrets, archives, binaries, large media). */
43
- export function isExcludedFile(name) {
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
- * @returns {Promise<{files:{name:string,absolute:string,size:number}[],skipped:string[],warnings:string[]}>}
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.1" }, instructions: INSTRUCTIONS };
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") {