vmlive 1.0.2 → 1.0.3

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
@@ -29,9 +29,16 @@ Interactively scaffolds additional serverless endpoints natively into your `src/
29
29
  ### `npx vmlive dev`
30
30
  Starts the local emulation environment.
31
31
  - Iteratively hosts your entire multi-function footprint locally via predictable slugs: `http://<workspace_id>-<project_id>-<function_name>.localhost:8787`.
32
+ - **Local Dashboard:** Automatically spawns a web dashboard at `http://dashboard.localhost:8787` for visualizing offline databases, global KV, and Object Storage.
32
33
  - Provides local proxy bindings for your SQL Database (`env.DB`), Global KV (`env.KV`), and Object Storage (`env.BUCKET`) using the `.vmlive/` directory for persistent storage.
33
34
  - Supports hot-reloading for `.js` and `.ts` files.
34
35
 
36
+ ### `npx vmlive test`
37
+ Executes your Vitest suites against a strictly ephemeral **in-memory** engine.
38
+ - Spins up `VirtualMachine` in memory-only mode without touching `.vmlive/` disk databases.
39
+ - Runs `npx vitest run` and gracefully destroys the engine upon exit.
40
+ - Allows test suites to deeply mutate state predictably without sandbox bleeding.
41
+
35
42
  ### `npx vmlive deploy`
36
43
  Uploads the local code to the vm.live edge platform.
37
44
  - Bundles the code using `esbuild`.
@@ -42,6 +49,5 @@ Uploads the local code to the vm.live edge platform.
42
49
  If you are using an AI assistant (such as Cursor, Copilot, or Aider) to write code, provide it with the platform context to ensure it adheres to the V8 Isolate execution model and uses the correct infrastructure bindings.
43
50
 
44
51
  **Instructions:**
45
- 1. Download the `vmlive-ai-rules.md` file (or relevant rules document) provided by the platform.
46
- 2. Place it in the root directory of your project.
47
- 3. If using Cursor, rename the file to `.cursorrules` to force the AI to apply the constraints automatically.
52
+ 1. Download the rules seamlessly via the API directly into your local root footprint: `curl -O https://api.vm.live/vmlive-ai-rules.md`
53
+ 2. Configure your AI Assistant (e.g. Cursor, Copilot) to ingest this manifest so it dynamically enforces the strict PaaS architectural primitives across your local logic scope!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vmlive",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Local development VM for custom Serverless PaaS",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -35,9 +35,18 @@ const buildProxyDispatcher = (functions, workspaceId, projectId) => ({
35
35
  export default {
36
36
  async fetch(request, env) {
37
37
  const url = new URL(request.url);
38
+
39
+ if (url.pathname === '/favicon.ico') {
40
+ return new Response(null, { status: 204 });
41
+ }
42
+
38
43
  const hostHeader = request.headers.get("Host") || url.hostname;
39
44
  let targetName = hostHeader.split('.')[0];
40
45
 
46
+ if (targetName === 'dashboard' && env.DASHBOARD) {
47
+ return env.DASHBOARD.fetch(request);
48
+ }
49
+
41
50
  if (targetName && env[targetName]) {
42
51
  return env[targetName].fetch(request);
43
52
  }
@@ -49,10 +58,13 @@ const buildProxyDispatcher = (functions, workspaceId, projectId) => ({
49
58
  }
50
59
  }
51
60
  `,
52
- serviceBindings: functions.reduce((acc, fn) => {
53
- acc[`${workspaceId}-${projectId}-${fn.name}`] = fn.name;
54
- return acc;
55
- }, {})
61
+ serviceBindings: {
62
+ ...functions.reduce((acc, fn) => {
63
+ acc[`${workspaceId}-${projectId}-${fn.name}`] = fn.name;
64
+ return acc;
65
+ }, {}),
66
+ DASHBOARD: "vm-dashboard-worker"
67
+ }
56
68
  });
57
69
 
58
70
  const runInit = async () => {
@@ -138,7 +150,7 @@ const runInit = async () => {
138
150
 
139
151
  if (stripeRes.ok) {
140
152
  const stripeData = await stripeRes.json();
141
- console.log(`\n\x1b[34m${stripeData.url}\x1b[0m\n`);
153
+ console.log(`\n\x1b]8;;${stripeData.url}\x1b\\\x1b[34m${stripeData.url}\x1b[0m\x1b]8;;\x1b\\\n`);
142
154
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
143
155
  exec(`${openCmd} "${stripeData.url}"`);
144
156
  await input({ message: 'Press Enter once verified to continue:' });
@@ -401,10 +413,21 @@ const runDev = async () => {
401
413
  const shadowSource = fs.readFileSync(path.join(__dirname, 'shadow-dos.js'), 'utf-8');
402
414
  fs.writeFileSync(path.join(WORK_DIR, 'shadow-dos.mjs'), shadowSource);
403
415
 
416
+ const dashSource = fs.readFileSync(path.join(__dirname, 'dashboard-worker.js'), 'utf-8');
417
+ fs.writeFileSync(path.join(WORK_DIR, 'dashboard-worker.mjs'), dashSource);
418
+
404
419
  const mfPort = process.env.PORT ? parseInt(process.env.PORT) : (config.port || 8787);
405
420
 
406
421
  const miniflareWorkers = [
407
422
  buildProxyDispatcher(config.functions, workspaceId, projectId),
423
+ {
424
+ name: "vm-dashboard-worker",
425
+ modules: true,
426
+ scriptPath: path.join(WORK_DIR, 'dashboard-worker.mjs'),
427
+ d1Databases: resourcesConfig.d1Databases,
428
+ kvNamespaces: resourcesConfig.kvNamespaces,
429
+ r2Buckets: resourcesConfig.r2Buckets
430
+ },
408
431
  {
409
432
  name: "vm-shadow-worker",
410
433
  modules: true,
@@ -436,9 +459,14 @@ const runDev = async () => {
436
459
  r2Persist: path.join(DATA_DIR, 'bucket')
437
460
  });
438
461
  await mf.ready;
439
- console.log('\\nLocal endpoints available:');
462
+ console.log('\nLocal endpoints available:');
463
+
464
+ const dashboardUrl = `http://dashboard.localhost:${mfPort}`;
465
+ console.log(` \x1b]8;;${dashboardUrl}\x1b\\${dashboardUrl}\x1b]8;;\x1b\\ \x1b[32m[Dashboard]\x1b[0m`);
466
+
440
467
  config.functions.forEach(fn => {
441
- console.log(` http://${workspaceId}-${projectId}-${fn.name}.localhost:${mfPort}`);
468
+ const endpointUrl = `http://${workspaceId}-${projectId}-${fn.name}.localhost:${mfPort}`;
469
+ console.log(` \x1b]8;;${endpointUrl}\x1b\\${endpointUrl}\x1b]8;;\x1b\\`);
442
470
  });
443
471
 
444
472
  setInterval(() => {
@@ -489,6 +517,100 @@ const runDev = async () => {
489
517
  });
490
518
  };
491
519
 
520
+ const runTest = async () => {
521
+ if (!fs.existsSync(CONFIG_PATH)) {
522
+ throw new Error("Missing vm.json in project root. Run 'vm init' to scaffold a new project workspace.");
523
+ }
524
+
525
+ const testArgs = process.argv.slice(3);
526
+ console.log('\x1b[36mInitializing Ephemeral Target Engine for Integration Tests...\x1b[0m');
527
+
528
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
529
+ if (!fs.existsSync(WORK_DIR)) fs.mkdirSync(WORK_DIR, { recursive: true });
530
+
531
+ const sharedBindings = fs.existsSync(path.resolve('.env'))
532
+ ? dotenv.parse(fs.readFileSync(path.resolve('.env')))
533
+ : {};
534
+
535
+ const resourcesConfig = buildResourcesConfig();
536
+
537
+ const entryPoints = config.functions.reduce((acc, fn) => {
538
+ acc[`${fn.name}-out`] = path.resolve(fn.entry);
539
+ return acc;
540
+ }, {});
541
+
542
+ const workspaceId = config.workspaceId || "ws_local";
543
+ const projectId = config.projectId || "prj_local";
544
+
545
+ config.functions.forEach(fn => {
546
+ const relativeTarget = `./${fn.name}-out.mjs`;
547
+ fs.writeFileSync(path.join(WORK_DIR, `${fn.name}-shim.mjs`), generateShim(relativeTarget, workspaceId, projectId, fn.name));
548
+ });
549
+
550
+ const builder = await esbuild.context({
551
+ entryPoints,
552
+ bundle: true,
553
+ format: 'esm',
554
+ outdir: WORK_DIR,
555
+ outExtension: { '.js': '.mjs' },
556
+ external: ['cloudflare:*'],
557
+ logLevel: 'silent'
558
+ });
559
+
560
+ await builder.rebuild();
561
+
562
+ const shadowSource = fs.readFileSync(path.join(__dirname, 'shadow-dos.js'), 'utf-8');
563
+ fs.writeFileSync(path.join(WORK_DIR, 'shadow-dos.mjs'), shadowSource);
564
+
565
+ const mfPort = process.env.PORT ? parseInt(process.env.PORT) : 8787;
566
+
567
+ // Exact parity workers but omitting the Dashboard intentionally
568
+ const miniflareWorkers = [
569
+ buildProxyDispatcher(config.functions, workspaceId, projectId),
570
+ {
571
+ name: "vm-shadow-worker",
572
+ modules: true,
573
+ scriptPath: path.join(WORK_DIR, 'shadow-dos.mjs'),
574
+ bindings: { PORT: mfPort },
575
+ durableObjects: { LocalTaskManagerDO: "LocalTaskManagerDO", LocalChannelRoomDO: "LocalChannelRoomDO" }
576
+ },
577
+ ...config.functions.map(fn => ({
578
+ name: fn.name,
579
+ modules: true,
580
+ scriptPath: path.join(WORK_DIR, `${fn.name}-shim.mjs`),
581
+ bindings: sharedBindings,
582
+ ...resourcesConfig,
583
+ durableObjects: {
584
+ TASK_DO: { className: "LocalTaskManagerDO", scriptName: "vm-shadow-worker" },
585
+ CHANNEL_DO: { className: "LocalChannelRoomDO", scriptName: "vm-shadow-worker" }
586
+ }
587
+ }))
588
+ ];
589
+
590
+ // IN-MEMORY EMULATOR (NO PERSIST PATHS)
591
+ const mf = new Emulator({
592
+ workers: miniflareWorkers,
593
+ port: mfPort
594
+ });
595
+
596
+ await mf.ready;
597
+ console.log('\x1b[32m✔ Ephemeral environment completely provisioned and bound.\x1b[0m\\n');
598
+
599
+ const { spawn } = await import('child_process');
600
+
601
+ const testProcess = spawn('npx', ['vitest', 'run', ...testArgs], {
602
+ stdio: 'inherit',
603
+ env: { ...process.env, VMLIVE_TESTING: "true" }
604
+ });
605
+
606
+ testProcess.on('exit', async (code) => {
607
+ console.log('\\n\x1b[36mTerminating ephemeral engine execution context...\x1b[0m');
608
+ await builder.dispose();
609
+ await mf.dispose();
610
+ process.exit(code || 0);
611
+ });
612
+ };
613
+
492
614
  const runDeploy = async () => {
493
615
  console.log('\x1b[36mDeploying...\x1b[0m');
494
616
 
@@ -633,7 +755,7 @@ const runLogin = async () => {
633
755
  const PORT = server.address().port;
634
756
  const EDITOR_URL = process.env.EDITOR_URL || 'http://localhost:5174';
635
757
  const authUrl = `${EDITOR_URL}/?challenge=${codeChallenge}&port=${PORT}`;
636
- console.log(`\x1b[34mOpening browser to: ${authUrl}\x1b[0m`);
758
+ console.log(`\x1b[34mOpening browser to: \x1b]8;;${authUrl}\x1b\\${authUrl}\x1b]8;;\x1b\\\x1b[0m`);
637
759
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
638
760
  exec(`${openCmd} "${authUrl}"`);
639
761
  });
@@ -645,15 +767,17 @@ const main = async () => {
645
767
  runInit();
646
768
  } else if (command === 'add') {
647
769
  await runAdd();
648
- } else if (command === 'dev' || !command) {
770
+ } else if (command === 'dev') {
649
771
  await runDev();
772
+ } else if (command === 'test') {
773
+ await runTest();
650
774
  } else if (command === 'login') {
651
775
  await runLogin();
652
776
  } else if (command === 'deploy') {
653
777
  await runDeploy();
654
778
  } else {
655
- console.error(`\x1b[31m❌ Unknown command: ${command}\x1b[0m`);
656
- console.log('Usage: vm init | vm add | vm dev | vm login | vm deploy');
779
+ console.error(`\x1b[31m❌ Unknown command: ${command || 'missing'}\x1b[0m`);
780
+ console.log('Usage: vm init | vm add | vm dev | vm test | vm login | vm deploy');
657
781
  process.exit(1);
658
782
  }
659
783
  };
@@ -0,0 +1,149 @@
1
+ const UI_HTML = `
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>vm.live — Local Dashboard</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ body { font-family: 'Inter', sans-serif; background-color: #09090b; color: #f4f4f5; }
11
+ .tab-active { border-bottom: 2px solid #10b981; color: #10b981; }
12
+ .tab-inactive { color: #a1a1aa; }
13
+ .tab-inactive:hover { color: #f4f4f5; }
14
+ pre { background: #18181b; padding: 1rem; border-radius: 0.5rem; border: 1px solid #27272a; overflow-x: auto; font-size: 0.875rem; color: #a1a1aa; }
15
+ </style>
16
+ </head>
17
+ <body class="h-screen flex flex-col">
18
+ <header class="border-b border-zinc-800 bg-zinc-950 p-4 shrink-0 flex items-center justify-between">
19
+ <div class="flex items-center gap-3">
20
+ <div class="w-8 h-8 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center font-bold">V</div>
21
+ <h1 class="font-semibold tracking-tight">Local Sandbox</h1>
22
+ </div>
23
+ <div class="flex gap-6 text-sm font-medium">
24
+ <button onclick="switchTab('db')" id="tab-db" class="tab-active pb-4 -mb-4">Database (D1)</button>
25
+ <button onclick="switchTab('kv')" id="tab-kv" class="tab-inactive pb-4 -mb-4">Global KV</button>
26
+ <button onclick="switchTab('r2')" id="tab-r2" class="tab-inactive pb-4 -mb-4">Storage (R2)</button>
27
+ </div>
28
+ </header>
29
+
30
+ <main class="flex-1 overflow-hidden relative">
31
+ <!-- D1 View -->
32
+ <div id="view-db" class="absolute inset-0 flex flex-col p-6 gap-4">
33
+ <h2 class="text-lg font-semibold">Execute SQL Query</h2>
34
+ <div class="flex gap-2">
35
+ <input type="text" id="sql-input" placeholder="SELECT * FROM users;" class="flex-1 bg-zinc-900 border border-zinc-800 rounded-md px-4 py-2 font-mono text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500">
36
+ <button onclick="runQuery()" class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-2 rounded-md font-medium transition-colors">Run</button>
37
+ </div>
38
+ <div class="flex-1 overflow-auto rounded-md border border-zinc-800 bg-zinc-900/50">
39
+ <pre id="db-results" class="h-full m-0 rounded-none border-0 text-sm">Awaiting query...</pre>
40
+ </div>
41
+ </div>
42
+
43
+ <!-- KV View -->
44
+ <div id="view-kv" class="absolute inset-0 flex flex-col p-6 gap-4 hidden">
45
+ <div class="flex items-center justify-between">
46
+ <h2 class="text-lg font-semibold">Key-Value Pairs</h2>
47
+ <button onclick="loadKV()" class="text-xs bg-zinc-800 hover:bg-zinc-700 px-3 py-1.5 rounded-md transition-colors">Refresh</button>
48
+ </div>
49
+ <div class="flex-1 overflow-auto rounded-md border border-zinc-800 bg-zinc-900/50">
50
+ <pre id="kv-results" class="h-full m-0 rounded-none border-0 text-sm">Loading keys...</pre>
51
+ </div>
52
+ </div>
53
+
54
+ <!-- R2 View -->
55
+ <div id="view-r2" class="absolute inset-0 flex flex-col p-6 gap-4 hidden">
56
+ <div class="flex items-center justify-between">
57
+ <h2 class="text-lg font-semibold">Object Storage</h2>
58
+ <button onclick="loadR2()" class="text-xs bg-zinc-800 hover:bg-zinc-700 px-3 py-1.5 rounded-md transition-colors">Refresh</button>
59
+ </div>
60
+ <div class="flex-1 overflow-auto rounded-md border border-zinc-800 bg-zinc-900/50">
61
+ <pre id="r2-results" class="h-full m-0 rounded-none border-0 text-sm">Loading objects...</pre>
62
+ </div>
63
+ </div>
64
+ </main>
65
+
66
+ <script>
67
+ const views = ['db', 'kv', 'r2'];
68
+ function switchTab(target) {
69
+ views.forEach(v => {
70
+ document.getElementById('view-' + v).classList.add('hidden');
71
+ document.getElementById('tab-' + v).className = 'tab-inactive pb-4 -mb-4';
72
+ });
73
+ document.getElementById('view-' + target).classList.remove('hidden');
74
+ document.getElementById('tab-' + target).className = 'tab-active pb-4 -mb-4';
75
+
76
+ if (target === 'kv') loadKV();
77
+ if (target === 'r2') loadR2();
78
+ }
79
+
80
+ async function runQuery() {
81
+ const q = document.getElementById('sql-input').value;
82
+ if (!q) return;
83
+ document.getElementById('db-results').innerText = 'Executing...';
84
+ try {
85
+ const res = await fetch('/api/db', { method: 'POST', body: JSON.stringify({ query: q }) });
86
+ const json = await res.json();
87
+ document.getElementById('db-results').innerText = JSON.stringify(json, null, 2);
88
+ } catch (e) {
89
+ document.getElementById('db-results').innerText = 'Network Error: ' + e.message;
90
+ }
91
+ }
92
+
93
+ async function loadKV() {
94
+ document.getElementById('kv-results').innerText = 'Fetching keys...';
95
+ const res = await fetch('/api/kv');
96
+ const json = await res.json();
97
+ document.getElementById('kv-results').innerText = JSON.stringify(json, null, 2);
98
+ }
99
+
100
+ async function loadR2() {
101
+ document.getElementById('r2-results').innerText = 'Fetching objects...';
102
+ const res = await fetch('/api/bucket');
103
+ const json = await res.json();
104
+ document.getElementById('r2-results').innerText = JSON.stringify(json, null, 2);
105
+ }
106
+ </script>
107
+ </body>
108
+ </html>
109
+ \`;
110
+
111
+ export default {
112
+ async fetch(req, env) {
113
+ const url = new URL(req.url);
114
+
115
+ if (req.method === 'POST' && url.pathname === '/api/db') {
116
+ if (!env.DB) return Response.json({ error: "DB binding not configured in emulator" }, { status: 400 });
117
+ const body = await req.json();
118
+ try {
119
+ const res = await env.DB.prepare(body.query).all();
120
+ return Response.json(res);
121
+ } catch(e) {
122
+ return Response.json({ error: e.message }, { status: 400 });
123
+ }
124
+ }
125
+
126
+ if (req.method === 'GET' && url.pathname === '/api/kv') {
127
+ if (!env.__RAW_KV) return Response.json({ error: "KV binding not configured in emulator" }, { status: 400 });
128
+ try {
129
+ const res = await env.__RAW_KV.list();
130
+ return Response.json(res);
131
+ } catch(e) {
132
+ return Response.json({ error: e.message }, { status: 400 });
133
+ }
134
+ }
135
+
136
+ if (req.method === 'GET' && url.pathname === '/api/bucket') {
137
+ if (!env.R2) return Response.json({ error: "BUCKET binding not configured in emulator" }, { status: 400 });
138
+ try {
139
+ const res = await env.R2.list();
140
+ return Response.json(res);
141
+ } catch(e) {
142
+ return Response.json({ error: e.message }, { status: 400 });
143
+ }
144
+ }
145
+
146
+ // Default to the Editor UI Payload
147
+ return new Response(UI_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' }});
148
+ }
149
+ };`