@pellux/goodvibes-tui 0.19.99 → 0.20.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/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.20.0] — 2026-05-11
8
+
9
+ ### Changes
10
+ - Updated `@pellux/goodvibes-sdk` to `0.33.30` for guest-side QEMU REPL execution and exec working-directory fixes.
11
+ - Added generated QEMU sandbox setup docs, runtime bootstrap scripts, cloud-init seed files, and guest runtime provisioning guidance.
12
+ - Wired `sandbox.replJavaScriptCommand` through generated QEMU setup manifests so JavaScript-family REPL snippets use the guest Bun runtime.
13
+ - Removed the obsolete `/sandbox qemu create-image` command in favor of the generated `create-image.sh` bootstrap workflow.
14
+ - Added focused coverage for QEMU setup scaffolding, command-level exec working directories, context auto-compaction thresholds, and retrospective setup-guide intent classification.
15
+
7
16
  ## [0.19.99] — 2026-05-12
8
17
 
9
18
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.99-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.20.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
@@ -626,13 +626,16 @@ The QEMU path includes:
626
626
 
627
627
  - setup bundle generation
628
628
  - first-run bootstrap scaffolding
629
- - `qemu-img` image creation helpers
629
+ - Debian cloud-image download, mutable qcow2 clone, resize, and NoCloud ISO creation through the generated `create-image.sh`
630
630
  - host-side wrapper generation
631
+ - guest cloud-init seed generation for the `goodvibes` sudo user, SSH key auth, `/workspace`, and `ens3` DHCP
631
632
  - guest-test and wrapper-test validation
632
633
  - session-backed command execution
633
634
  - guest bundle export / inspect flows
634
635
  - setup manifest export / apply flows
635
636
  - `attach` and `launch-per-command` execution modes
637
+ - REPL/MCP-friendly guest bootstrap packages for Python, JavaScript, TypeScript, SQL, GraphQL, Bun, Deno, DuckDB, Go, Rust, Ruby, and common CLI build/search tools
638
+ - guest JavaScript-family REPL execution through `sandbox.replJavaScriptCommand`, defaulting the generated QEMU setup to `/home/goodvibes/.bun/bin/bun`
636
639
 
637
640
  Key commands:
638
641
 
@@ -643,7 +646,6 @@ Key commands:
643
646
  - `/sandbox probe`
644
647
  - `/sandbox qemu setup <dir>`
645
648
  - `/sandbox qemu bootstrap <dir> [size-gb]`
646
- - `/sandbox qemu create-image <path> [size-gb]`
647
649
  - `/sandbox qemu inspect-setup <manifest>`
648
650
  - `/sandbox qemu apply-setup <manifest>`
649
651
  - `/sandbox session ...`
@@ -654,10 +656,14 @@ Typical first-run path:
654
656
 
655
657
  ```sh
656
658
  /sandbox qemu bootstrap .goodvibes/tui/sandbox 20
659
+ .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G
660
+ GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh
657
661
  /sandbox doctor
658
- /sandbox guest-test eval-js
662
+ /sandbox guest-test eval-py
659
663
  ```
660
664
 
665
+ See [QEMU sandbox bootstrapping](docs/qemu-sandbox.md) for host prerequisites, generated files, first-boot behavior, guest runtime packages, and troubleshooting logs.
666
+
661
667
  ---
662
668
 
663
669
  ## Remote, Local Services, And Integration Helpers
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "0.33.27"
6
+ "version": "0.33.30"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.99",
3
+ "version": "0.20.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -97,7 +97,7 @@
97
97
  "@anthropic-ai/vertex-sdk": "^0.16.0",
98
98
  "@ast-grep/napi": "^0.42.0",
99
99
  "@aws/bedrock-token-generator": "^1.1.0",
100
- "@pellux/goodvibes-sdk": "0.33.27",
100
+ "@pellux/goodvibes-sdk": "0.33.30",
101
101
  "bash-language-server": "^5.6.0",
102
102
  "fuse.js": "^7.1.0",
103
103
  "graphql": "^16.13.2",
@@ -155,10 +155,13 @@ export function renderSetupSandboxReview(ctx: CommandContext, snapshot: SetupRev
155
155
  ];
156
156
  if (backend === 'local') {
157
157
  lines.push(' /sandbox qemu bootstrap .goodvibes/tui/sandbox 20');
158
+ lines.push(' .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G');
159
+ lines.push(' GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh');
158
160
  lines.push(' /sandbox doctor');
159
161
  } else if (!image || !wrapper) {
160
162
  lines.push(' /sandbox qemu setup .goodvibes/tui/sandbox');
161
- lines.push(' /sandbox qemu create-image .goodvibes/tui/sandbox/images/goodvibes-sandbox.qcow2 20');
163
+ lines.push(' .goodvibes/tui/sandbox/create-image.sh .goodvibes/tui/sandbox/goodvibes-sandbox.qcow2 20G');
164
+ lines.push(' GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh .goodvibes/tui/sandbox/qemu-wrapper.sh bash -s < .goodvibes/tui/sandbox/guest-bootstrap.sh');
162
165
  lines.push(' /sandbox doctor');
163
166
  } else {
164
167
  lines.push(' /sandbox guest-test eval-js');
@@ -4,7 +4,6 @@ import type { CommandContext } from '../command-registry.ts';
4
4
  import {
5
5
  applySandboxQemuSetupManifest,
6
6
  bootstrapSandboxQemuSetupBundle,
7
- createSandboxQemuImage,
8
7
  inspectSandboxQemuSetupManifest,
9
8
  loadSandboxQemuSetupManifest,
10
9
  scaffoldSandboxQemuSetupBundle,
@@ -27,29 +26,25 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
27
26
  return true;
28
27
  }
29
28
  const bundle = scaffoldSandboxQemuSetupBundle(ctx.platform.configManager, shellPaths.workingDirectory, dirArg, { surfaceRoot: 'tui' });
30
- ctx.platform.configManager.setDynamic('sandbox.vmBackend', 'qemu');
31
- ctx.platform.configManager.setDynamic('sandbox.qemuExecWrapper', bundle.wrapperPath);
32
- ctx.platform.configManager.setDynamic('sandbox.qemuImagePath', bundle.imagePath);
33
- if (!`${ctx.platform.configManager.get('sandbox.qemuGuestHost') ?? ''}`.trim()) {
34
- ctx.platform.configManager.setDynamic('sandbox.qemuGuestHost', '127.0.0.1');
35
- }
36
- if (!`${ctx.platform.configManager.get('sandbox.qemuGuestUser') ?? ''}`.trim()) {
37
- ctx.platform.configManager.setDynamic('sandbox.qemuGuestUser', 'goodvibes');
38
- }
39
- if (!`${ctx.platform.configManager.get('sandbox.qemuWorkspacePath') ?? ''}`.trim()) {
40
- ctx.platform.configManager.setDynamic('sandbox.qemuWorkspacePath', '/workspace');
41
- }
29
+ const manifest = loadSandboxQemuSetupManifest(shellPaths.workingDirectory, bundle.manifestPath);
30
+ applySandboxQemuSetupManifest(ctx.platform.configManager, manifest);
31
+ ctx.platform.configManager.setDynamic('sandbox.replIsolation', 'shared-vm');
32
+ ctx.platform.configManager.setDynamic('sandbox.mcpIsolation', 'shared-vm');
42
33
  ctx.print([
43
34
  `Initialized QEMU sandbox setup bundle in ${bundle.directory}`,
44
35
  ` wrapper: ${bundle.wrapperPath}`,
45
36
  ` image path: ${bundle.imagePath}`,
46
37
  ` image create script: ${bundle.imageCreateScriptPath}`,
47
38
  ` guest bootstrap: ${bundle.guestBootstrapScriptPath}`,
39
+ ` seed directory: ${bundle.seedDirectory}`,
40
+ ` seed ISO: ${bundle.seedIsoPath}`,
41
+ ` ssh key: ${bundle.sshKeyPath}`,
48
42
  ` projection policy: ${bundle.projectionPolicyPath}`,
49
43
  ` ssh config: ${bundle.sshConfigPath}`,
50
44
  ` manifest: ${bundle.manifestPath}`,
51
- ' applied: backend=qemu, wrapper path, image path, and default guest settings',
52
- ` next: /sandbox qemu create-image ${bundle.imagePath} 20`,
45
+ ' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
46
+ ` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} 20G`,
47
+ ` then provision runtimes: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`,
53
48
  ].join('\n'));
54
49
  return true;
55
50
  }
@@ -67,33 +62,20 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
67
62
  ` wrapper: ${bundle.wrapperPath}`,
68
63
  ` image path: ${bundle.imagePath}`,
69
64
  ` guest bootstrap: ${bundle.guestBootstrapScriptPath}`,
65
+ ` seed ISO: ${bundle.seedIsoPath}`,
66
+ ` ssh key: ${bundle.sshKeyPath}`,
70
67
  ` projection policy: ${bundle.projectionPolicyPath}`,
71
68
  ` manifest: ${bundle.manifestPath}`,
72
- ' applied: backend=qemu, wrapper path, image path, and guest settings',
73
- ' next: boot the image, run the guest bootstrap script, then /sandbox guest-test eval-js',
69
+ ' applied: backend=qemu, qemu binary, wrapper path, image path, launch-per-command guest settings, shared VM isolation',
70
+ ` next: ${bundle.imageCreateScriptPath} ${bundle.imagePath} ${sizeGb}G`,
71
+ ` then provision runtimes: GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${bundle.wrapperPath} bash -s < ${bundle.guestBootstrapScriptPath}`,
72
+ ' then: /sandbox guest-test eval-py',
74
73
  ].join('\n'));
75
74
  } catch (error) {
76
75
  ctx.print(summarizeError(error));
77
76
  }
78
77
  return true;
79
78
  }
80
- if (sub === 'create-image') {
81
- const imagePath = args[2];
82
- const sizeGb = Number.parseInt(args[3] ?? '20', 10);
83
- if (!imagePath || !Number.isInteger(sizeGb) || sizeGb < 1) {
84
- ctx.print('Usage: /sandbox qemu create-image <path> [size-gb]');
85
- return true;
86
- }
87
- try {
88
- const created = createSandboxQemuImage(shellPaths.workingDirectory, imagePath, sizeGb);
89
- ctx.platform.configManager.setDynamic('sandbox.qemuImagePath', created.path);
90
- ctx.platform.configManager.setDynamic('sandbox.vmBackend', 'qemu');
91
- ctx.print(`Created QEMU image ${created.path} (${created.sizeGb}G).`);
92
- } catch (error) {
93
- ctx.print(summarizeError(error));
94
- }
95
- return true;
96
- }
97
79
  if (sub === 'recover') {
98
80
  const sessionId = args[2];
99
81
  if (!sessionId) {
@@ -132,6 +114,6 @@ export async function handleSandboxQemuCommand(args: string[], ctx: CommandConte
132
114
  ctx.print(`Applied QEMU sandbox setup from ${shellPaths.resolveWorkspacePath(pathArg)}.`);
133
115
  return true;
134
116
  }
135
- ctx.print('Usage: /sandbox qemu <setup <directory>|bootstrap <directory> [size-gb]|create-image <path> [size-gb]|recover <session-id>|inspect-setup <setup-manifest.json>|apply-setup <setup-manifest.json>>');
117
+ ctx.print('Usage: /sandbox qemu <setup <directory>|bootstrap <directory> [size-gb]|recover <session-id>|inspect-setup <setup-manifest.json>|apply-setup <setup-manifest.json>>');
136
118
  return true;
137
119
  }
@@ -61,6 +61,7 @@ function applySandboxPreset(
61
61
  configManager.setDynamic('sandbox.qemuGuestUser' as never, preset.config.qemuGuestUser);
62
62
  configManager.setDynamic('sandbox.qemuWorkspacePath' as never, preset.config.qemuWorkspacePath);
63
63
  configManager.setDynamic('sandbox.qemuSessionMode' as never, preset.config.qemuSessionMode);
64
+ configManager.setDynamic('sandbox.replJavaScriptCommand' as never, preset.config.replJavaScriptCommand);
64
65
  return true;
65
66
  }
66
67
 
@@ -83,6 +84,7 @@ function renderSandboxPresetDetail(presetId: string): string | null {
83
84
  ` qemu guest user: ${preset.config.qemuGuestUser || '(not configured)'}`,
84
85
  ` qemu workspace: ${preset.config.qemuWorkspacePath || '(not configured)'}`,
85
86
  ` qemu session mode: ${preset.config.qemuSessionMode}`,
87
+ ` repl JavaScript command: ${preset.config.replJavaScriptCommand || 'bun'}`,
86
88
  ...preset.notes.map((note) => ` note: ${note}`),
87
89
  ].join('\n');
88
90
  }
@@ -91,7 +93,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
91
93
  registry.register({
92
94
  name: 'sandbox',
93
95
  description: 'Review and configure VM isolation policy for MCP and evaluation runtimes',
94
- usage: '[open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|create-image|recover|inspect-setup|apply-setup> ...|session ...|bundle ...|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]',
96
+ usage: '[open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|recover|inspect-setup|apply-setup> ...|session ...|bundle ...|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]',
95
97
  async handler(args, ctx) {
96
98
  const shellPaths = requireShellPaths(ctx);
97
99
  const sub = args[0] ?? 'open';
@@ -400,7 +402,7 @@ export function registerPlatformSandboxRuntimeCommands(registry: CommandRegistry
400
402
  ctx.print([`Scaffolded QEMU wrapper to ${targetPath}`, ' bridge test mode: GV_SANDBOX_WRAPPER_MODE=host-exec', ' next: /sandbox set-qemu-wrapper ' + targetPath].join('\n'));
401
403
  return;
402
404
  }
403
- ctx.print('Usage: /sandbox [open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|create-image|recover|inspect-setup|apply-setup> ...|session ...|bundle export <path>|bundle inspect <path>|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]');
405
+ ctx.print('Usage: /sandbox [open|review|recommend|profiles|presets|preset <id>|apply-preset <id>|probe|doctor|wrapper-test <profile>|guest-test <profile>|init-qemu <dir>|qemu <setup|bootstrap|recover|inspect-setup|apply-setup> ...|session ...|bundle export <path>|bundle inspect <path>|guest-bundle <export|inspect> <path>|scaffold-qemu-wrapper <path>|set-mcp <mode>|set-repl <mode>|set-windows <mode>|set-backend <mode>|set-qemu-binary <path>|set-qemu-image <path>|set-qemu-wrapper <path>|set-qemu-guest-host <host>|set-qemu-guest-port <port>|set-qemu-guest-user <user>|set-qemu-workspace <path>|set-qemu-session-mode <attach|launch-per-command>]');
404
406
  },
405
407
  });
406
408
  }
@@ -121,6 +121,7 @@ export const SETTING_LABELS: Partial<Record<string, string>> = {
121
121
  'sandbox.qemuBinary': 'QEMU Binary',
122
122
  'sandbox.qemuImagePath': 'QEMU Image',
123
123
  'sandbox.qemuExecWrapper': 'QEMU Wrapper',
124
+ 'sandbox.replJavaScriptCommand': 'REPL JS Command',
124
125
  'tools.llmProvider': 'Tool LLM Provider',
125
126
  'tools.llmModel': 'Tool LLM Model',
126
127
  'tools.autoHeal': 'Auto-Heal',
@@ -9,6 +9,14 @@ import {
9
9
  type SandboxLaunchPlan,
10
10
  type SandboxProfile,
11
11
  } from '@pellux/goodvibes-sdk/platform/runtime/sandbox';
12
+ import {
13
+ renderQemuGuestBootstrapScript,
14
+ renderQemuImageCreateScript,
15
+ renderQemuSetupReadme,
16
+ renderQemuWrapperTemplate,
17
+ } from './sandbox-qemu-templates.ts';
18
+
19
+ export { renderQemuWrapperTemplate } from './sandbox-qemu-templates.ts';
12
20
 
13
21
  export interface SandboxGuestBundle {
14
22
  readonly version: 1;
@@ -22,6 +30,7 @@ export interface SandboxGuestBundle {
22
30
  readonly user: string;
23
31
  readonly workspacePath: string;
24
32
  readonly sessionMode: string;
33
+ readonly replJavaScriptCommand: string;
25
34
  };
26
35
  readonly nextSteps: readonly string[];
27
36
  }
@@ -39,6 +48,10 @@ export interface SandboxQemuSetupBundle extends SandboxQemuInitBundle {
39
48
  readonly guestBootstrapScriptPath: string;
40
49
  readonly projectionPolicyPath: string;
41
50
  readonly sshConfigPath: string;
51
+ readonly seedDirectory: string;
52
+ readonly seedIsoPath: string;
53
+ readonly sshKeyPath: string;
54
+ readonly sshPublicKeyPath: string;
42
55
  readonly manifestPath: string;
43
56
  }
44
57
 
@@ -51,8 +64,13 @@ export interface SandboxQemuSetupManifest {
51
64
  readonly guestBootstrapScriptPath: string;
52
65
  readonly projectionPolicyPath: string;
53
66
  readonly sshConfigPath: string;
67
+ readonly seedDirectory: string;
68
+ readonly seedIsoPath: string;
69
+ readonly sshKeyPath: string;
70
+ readonly sshPublicKeyPath: string;
54
71
  readonly recommendedSettings: {
55
72
  readonly backend: 'qemu';
73
+ readonly qemuBinary: string;
56
74
  readonly wrapperPath: string;
57
75
  readonly imagePath: string;
58
76
  readonly guestHost: string;
@@ -60,6 +78,7 @@ export interface SandboxQemuSetupManifest {
60
78
  readonly guestUser: string;
61
79
  readonly guestWorkspacePath: string;
62
80
  readonly sessionMode: string;
81
+ readonly replJavaScriptCommand: string;
63
82
  };
64
83
  }
65
84
 
@@ -71,21 +90,6 @@ export interface WritableConfigManagerLike extends ConfigManagerLike {
71
90
  setDynamic(key: string, value: unknown): void;
72
91
  }
73
92
 
74
- export function renderQemuWrapperTemplate(): string {
75
- return `#!/usr/bin/env bash
76
- set -euo pipefail
77
- mode="\${GV_SANDBOX_WRAPPER_MODE:-ssh-guest}"
78
- if [[ "$mode" == "host-exec" ]]; then
79
- exec "$@"
80
- fi
81
- host="\${GOODVIBES_QEMU_GUEST_HOST:-127.0.0.1}"
82
- port="\${GOODVIBES_QEMU_GUEST_PORT:-2222}"
83
- user="\${GOODVIBES_QEMU_GUEST_USER:-goodvibes}"
84
- workspace="\${GOODVIBES_QEMU_WORKSPACE:-/workspace}"
85
- exec ssh -o StrictHostKeyChecking=accept-new -p "$port" "$user@$host" "cd '$workspace' && exec \\"$@\\""
86
- `;
87
- }
88
-
89
93
  export function probeSandboxBackends(manager: ConfigManagerLike): SandboxBackendProbe {
90
94
  const host = detectSandboxHostStatus(manager);
91
95
  const config = getSandboxConfigSnapshot(manager);
@@ -155,15 +159,25 @@ export function buildSandboxLaunchPlan(
155
159
  throw new Error(`Requested QEMU sandbox backend is unavailable (${qemuAvailability?.detail ?? 'probe failed'}); refusing to downgrade to local process isolation. Set sandbox.vmBackend to "local" to use host-local isolation explicitly.`);
156
160
  }
157
161
  const guestPort = config.qemuGuestPort || 2222;
162
+ const wrapperDirectory = config.qemuExecWrapper ? dirname(config.qemuExecWrapper) : resolve(safeWorkspaceRoot, '.goodvibes/tui/sandbox');
163
+ const serialLog = resolve(wrapperDirectory, `logs/serial-${guestPort}.log`);
164
+ const monitorSocket = resolve(wrapperDirectory, `run/monitor-${guestPort}.sock`);
165
+ const seedIso = resolve(wrapperDirectory, 'seed/nocloud.iso');
166
+ ensureDir(dirname(serialLog));
167
+ ensureDir(dirname(monitorSocket));
158
168
  const args = [
159
169
  '-display', 'none',
160
- '-nodefaults',
161
170
  '-name', `gv-${profile.id}`,
162
- '-snapshot',
163
- '-m', '512',
164
- '-nic', `user,hostfwd=tcp::${guestPort}-:22`,
171
+ '-serial', `file:${serialLog}`,
172
+ '-monitor', `unix:${monitorSocket},server,nowait`,
173
+ '-m', '1024',
174
+ '-smp', '2',
175
+ '-netdev', `user,id=net0,hostfwd=tcp:127.0.0.1:${guestPort}-:22`,
176
+ '-device', 'virtio-net-pci,netdev=net0',
177
+ '-smbios', 'type=1,serial=ds=nocloud',
165
178
  ];
166
179
  if (config.qemuImagePath) args.push('-drive', `file=${config.qemuImagePath},if=virtio,format=qcow2`);
180
+ if (existsSync(seedIso)) args.push('-drive', `file=${seedIso},if=virtio,media=cdrom,readonly=on`);
167
181
  return {
168
182
  backend,
169
183
  command: config.qemuBinary || 'qemu-system-x86_64',
@@ -326,6 +340,70 @@ function ensureDir(path: string): void {
326
340
  mkdirSync(path, { recursive: true });
327
341
  }
328
342
 
343
+ function ensureQemuSshKey(directory: string): { readonly keyPath: string; readonly publicKeyPath: string; readonly publicKey: string } {
344
+ const keyPath = resolve(directory, 'keys/goodvibes_qemu_ed25519');
345
+ const publicKeyPath = `${keyPath}.pub`;
346
+ ensureDir(dirname(keyPath));
347
+ if (!existsSync(keyPath) || !existsSync(publicKeyPath)) {
348
+ const generated = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', 'goodvibes-qemu', '-f', keyPath], {
349
+ stdio: ['ignore', 'pipe', 'pipe'],
350
+ encoding: 'utf8',
351
+ windowsHide: true,
352
+ });
353
+ if (generated.status !== 0) {
354
+ writeFileSync(resolve(directory, 'keys/README.txt'), [
355
+ 'ssh-keygen was not available when this bundle was scaffolded.',
356
+ 'Generate the key manually before building the image:',
357
+ ` ssh-keygen -t ed25519 -N '' -C goodvibes-qemu -f ${keyPath}`,
358
+ '',
359
+ ].join('\n'));
360
+ }
361
+ }
362
+ const publicKey = existsSync(publicKeyPath) ? readFileSync(publicKeyPath, 'utf8').trim() : '';
363
+ return { keyPath, publicKeyPath, publicKey };
364
+ }
365
+
366
+ function renderQemuUserData(publicKey: string): string {
367
+ const authorizedKeys = publicKey
368
+ ? ` ssh_authorized_keys:\n - ${publicKey}\n`
369
+ : ' # Generate keys/goodvibes_qemu_ed25519.pub before rebuilding nocloud.iso.\n';
370
+ return `#cloud-config
371
+ users:
372
+ - default
373
+ - name: goodvibes
374
+ groups: [sudo]
375
+ shell: /bin/bash
376
+ sudo: ['ALL=(ALL) NOPASSWD:ALL']
377
+ lock_passwd: true
378
+ ${authorizedKeys}
379
+ ssh_pwauth: false
380
+ disable_root: true
381
+ manage_etc_hosts: true
382
+
383
+ bootcmd:
384
+ - [ sh, -c, 'systemctl disable systemd-networkd-wait-online.service || true' ]
385
+ - [ sh, -c, 'systemctl mask systemd-networkd-wait-online.service || true' ]
386
+
387
+ runcmd:
388
+ - [ mkdir, -p, /workspace ]
389
+ - [ chown, goodvibes:goodvibes, /workspace ]
390
+ - [ sh, -c, 'systemctl enable ssh ssh.service 2>/dev/null || true' ]
391
+ - [ sh, -c, 'systemctl restart ssh ssh.service 2>/dev/null || true' ]
392
+ `;
393
+ }
394
+
395
+ function renderQemuNetworkConfig(): string {
396
+ return `version: 2
397
+ ethernets:
398
+ ens3:
399
+ match:
400
+ name: "ens3"
401
+ dhcp4: true
402
+ dhcp6: false
403
+ optional: true
404
+ `;
405
+ }
406
+
329
407
  export function scaffoldSandboxQemuInitBundle(
330
408
  manager: ConfigManagerLike,
331
409
  workspaceRoot: string,
@@ -358,8 +436,9 @@ function buildGuestBundle(manager: ConfigManagerLike, _workspaceRoot: string, wr
358
436
  user: config.qemuGuestUser,
359
437
  workspacePath: config.qemuWorkspacePath,
360
438
  sessionMode: config.qemuSessionMode,
439
+ replJavaScriptCommand: config.replJavaScriptCommand,
361
440
  },
362
- nextSteps: ['Create or attach a QEMU guest, run the bootstrap script, then run /sandbox guest-test eval-js.'],
441
+ nextSteps: ['Create the QEMU image, provision guest runtimes, then run /sandbox guest-test eval-py.'],
363
442
  };
364
443
  }
365
444
 
@@ -376,7 +455,18 @@ export function scaffoldSandboxQemuSetupBundle(
376
455
  const projectionPolicyPath = resolve(init.directory, 'projection-policy.json');
377
456
  const sshConfigPath = resolve(init.directory, 'ssh-config');
378
457
  const manifestPath = resolve(init.directory, 'setup-manifest.json');
458
+ const seedDirectory = resolve(init.directory, 'seed');
459
+ const seedIsoPath = resolve(seedDirectory, 'nocloud.iso');
460
+ const logsDirectory = resolve(init.directory, 'logs');
461
+ const runDirectory = resolve(init.directory, 'run');
462
+ const imagesDirectory = resolve(init.directory, 'images');
379
463
  const config = getSandboxConfigSnapshot(manager);
464
+ const qemuBinary = config.qemuBinary || 'qemu-system-x86_64';
465
+ const guestPort = config.qemuGuestPort || 2222;
466
+ const guestUser = config.qemuGuestUser || 'goodvibes';
467
+ const guestWorkspacePath = config.qemuWorkspacePath || '/workspace';
468
+ const replJavaScriptCommand = `/home/${guestUser}/.bun/bin/bun`;
469
+ const sshKey = ensureQemuSshKey(init.directory);
380
470
  const manifest: SandboxQemuSetupManifest = {
381
471
  version: 1,
382
472
  createdAt: Date.now(),
@@ -386,23 +476,57 @@ export function scaffoldSandboxQemuSetupBundle(
386
476
  guestBootstrapScriptPath,
387
477
  projectionPolicyPath,
388
478
  sshConfigPath,
479
+ seedDirectory,
480
+ seedIsoPath,
481
+ sshKeyPath: sshKey.keyPath,
482
+ sshPublicKeyPath: sshKey.publicKeyPath,
389
483
  recommendedSettings: {
390
484
  backend: 'qemu',
485
+ qemuBinary,
391
486
  wrapperPath: init.wrapperPath,
392
487
  imagePath,
393
488
  guestHost: config.qemuGuestHost || '127.0.0.1',
394
- guestPort: config.qemuGuestPort || 2222,
395
- guestUser: config.qemuGuestUser || 'goodvibes',
396
- guestWorkspacePath: config.qemuWorkspacePath || '/workspace',
397
- sessionMode: config.qemuSessionMode || 'attach',
489
+ guestPort,
490
+ guestUser,
491
+ guestWorkspacePath,
492
+ sessionMode: 'launch-per-command',
493
+ replJavaScriptCommand,
398
494
  },
399
495
  };
400
- writeFileSync(imageCreateScriptPath, '#!/usr/bin/env bash\nset -euo pipefail\nqemu-img create -f qcow2 "$1" "${2:-20G}"\n', { mode: 0o755 });
401
- writeFileSync(guestBootstrapScriptPath, '#!/usr/bin/env bash\nset -euo pipefail\nmkdir -p /workspace\n');
496
+ ensureDir(seedDirectory);
497
+ ensureDir(logsDirectory);
498
+ ensureDir(runDirectory);
499
+ ensureDir(imagesDirectory);
500
+ writeFileSync(resolve(seedDirectory, 'meta-data'), 'instance-id: goodvibes-qemu-sandbox\nlocal-hostname: goodvibes-qemu\n');
501
+ writeFileSync(resolve(seedDirectory, 'user-data'), renderQemuUserData(sshKey.publicKey));
502
+ writeFileSync(resolve(seedDirectory, 'network-config'), renderQemuNetworkConfig());
503
+ writeFileSync(imageCreateScriptPath, renderQemuImageCreateScript(init.directory, imagePath, 20), { mode: 0o755 });
504
+ writeFileSync(guestBootstrapScriptPath, renderQemuGuestBootstrapScript(), { mode: 0o755 });
402
505
  writeFileSync(projectionPolicyPath, `${JSON.stringify({ version: 1, workspace: '/workspace' }, null, 2)}\n`);
403
- writeFileSync(sshConfigPath, 'Host goodvibes-qemu\n HostName 127.0.0.1\n Port 2222\n User goodvibes\n');
506
+ writeFileSync(sshConfigPath, [
507
+ 'Host goodvibes-qemu',
508
+ ' HostName 127.0.0.1',
509
+ ` Port ${guestPort}`,
510
+ ` User ${guestUser}`,
511
+ ` IdentityFile ${sshKey.keyPath}`,
512
+ ' StrictHostKeyChecking accept-new',
513
+ '',
514
+ ].join('\n'));
404
515
  writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
405
- return { ...init, imagePath, imageCreateScriptPath, guestBootstrapScriptPath, projectionPolicyPath, sshConfigPath, manifestPath };
516
+ writeFileSync(init.readmePath, renderQemuSetupReadme(init.directory, imagePath, seedIsoPath));
517
+ return {
518
+ ...init,
519
+ imagePath,
520
+ imageCreateScriptPath,
521
+ guestBootstrapScriptPath,
522
+ projectionPolicyPath,
523
+ sshConfigPath,
524
+ seedDirectory,
525
+ seedIsoPath,
526
+ sshKeyPath: sshKey.keyPath,
527
+ sshPublicKeyPath: sshKey.publicKeyPath,
528
+ manifestPath,
529
+ };
406
530
  }
407
531
 
408
532
  export function bootstrapSandboxQemuSetupBundle(
@@ -413,19 +537,13 @@ export function bootstrapSandboxQemuSetupBundle(
413
537
  options: SandboxProvisioningOptions,
414
538
  ): SandboxQemuSetupBundle {
415
539
  const bundle = scaffoldSandboxQemuSetupBundle(manager, workspaceRoot, pathArg, options);
416
- manager.setDynamic('sandbox.vmBackend', 'qemu');
417
- manager.setDynamic('sandbox.qemuExecWrapper', bundle.wrapperPath);
418
- manager.setDynamic('sandbox.qemuImagePath', bundle.imagePath);
540
+ const manifest = loadSandboxQemuSetupManifest(workspaceRoot, bundle.manifestPath);
541
+ applySandboxQemuSetupManifest(manager, manifest);
542
+ manager.setDynamic('sandbox.replIsolation', 'shared-vm');
543
+ manager.setDynamic('sandbox.mcpIsolation', 'shared-vm');
419
544
  return bundle;
420
545
  }
421
546
 
422
- export function createSandboxQemuImage(workspaceRoot: string, imagePathArg: string, sizeGb: number): { readonly path: string; readonly sizeGb: number } {
423
- const path = resolveWorkspacePath(workspaceRoot, imagePathArg);
424
- ensureDir(dirname(path));
425
- if (!existsSync(path)) writeFileSync(path, '');
426
- return { path, sizeGb };
427
- }
428
-
429
547
  export function inspectSandboxQemuSetupManifest(manifest: SandboxQemuSetupManifest): string {
430
548
  return [
431
549
  'QEMU sandbox setup manifest',
@@ -442,6 +560,7 @@ export function loadSandboxQemuSetupManifest(workspaceRoot: string, pathArg: str
442
560
 
443
561
  export function applySandboxQemuSetupManifest(manager: WritableConfigManagerLike, manifest: SandboxQemuSetupManifest): void {
444
562
  manager.setDynamic('sandbox.vmBackend', 'qemu');
563
+ manager.setDynamic('sandbox.qemuBinary', manifest.recommendedSettings.qemuBinary);
445
564
  manager.setDynamic('sandbox.qemuExecWrapper', manifest.recommendedSettings.wrapperPath);
446
565
  manager.setDynamic('sandbox.qemuImagePath', manifest.recommendedSettings.imagePath);
447
566
  manager.setDynamic('sandbox.qemuGuestHost', manifest.recommendedSettings.guestHost);
@@ -449,6 +568,7 @@ export function applySandboxQemuSetupManifest(manager: WritableConfigManagerLike
449
568
  manager.setDynamic('sandbox.qemuGuestUser', manifest.recommendedSettings.guestUser);
450
569
  manager.setDynamic('sandbox.qemuWorkspacePath', manifest.recommendedSettings.guestWorkspacePath);
451
570
  manager.setDynamic('sandbox.qemuSessionMode', manifest.recommendedSettings.sessionMode);
571
+ manager.setDynamic('sandbox.replJavaScriptCommand', manifest.recommendedSettings.replJavaScriptCommand);
452
572
  }
453
573
 
454
574
  export function exportSandboxGuestBundle(
@@ -0,0 +1,340 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ function shellSingleQuote(value: string): string {
4
+ return `'${value.replace(/'/g, `'\\''`)}'`;
5
+ }
6
+
7
+ export function renderQemuWrapperTemplate(): string {
8
+ return `#!/usr/bin/env bash
9
+ set -euo pipefail
10
+
11
+ mode="\${GV_SANDBOX_WRAPPER_MODE:-\${GOODVIBES_QEMU_WRAPPER_MODE:-ssh-guest}}"
12
+ script_dir="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
13
+ host="\${GV_SANDBOX_GUEST_HOST:-\${GOODVIBES_QEMU_GUEST_HOST:-127.0.0.1}}"
14
+ port="\${GV_SANDBOX_GUEST_PORT:-\${GOODVIBES_QEMU_GUEST_PORT:-2222}}"
15
+ user="\${GV_SANDBOX_GUEST_USER:-\${GOODVIBES_QEMU_GUEST_USER:-goodvibes}}"
16
+ workspace="\${GV_SANDBOX_GUEST_WORKSPACE:-\${GOODVIBES_QEMU_WORKSPACE:-/workspace}}"
17
+ workspace_root="\${GV_SANDBOX_WORKSPACE_ROOT:-$PWD}"
18
+ qemu_bin="\${GV_SANDBOX_QEMU_BINARY:-\${GOODVIBES_QEMU_BINARY:-qemu-system-x86_64}}"
19
+ qemu_image="\${GV_SANDBOX_QEMU_IMAGE:-\${GOODVIBES_QEMU_IMAGE:-$script_dir/goodvibes-sandbox.qcow2}}"
20
+ ssh_key="\${GV_SANDBOX_SSH_KEY:-\${GOODVIBES_QEMU_SSH_KEY:-$script_dir/keys/goodvibes_qemu_ed25519}}"
21
+ known_hosts="\${GV_SANDBOX_KNOWN_HOSTS:-$script_dir/known_hosts}"
22
+ logs_dir="\${GV_SANDBOX_LOGS_DIR:-$script_dir/logs}"
23
+ run_dir="\${GV_SANDBOX_RUN_DIR:-$script_dir/run}"
24
+ seed_iso="\${GV_SANDBOX_SEED_ISO:-$script_dir/seed/nocloud.iso}"
25
+ ssh_timeout="\${GOODVIBES_QEMU_SSH_TIMEOUT:-300}"
26
+
27
+ mkdir -p "$logs_dir" "$run_dir"
28
+
29
+ ssh_opts=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts" -p "$port")
30
+ if [[ -f "$ssh_key" ]]; then
31
+ chmod 600 "$ssh_key" 2>/dev/null || true
32
+ ssh_opts=(-i "$ssh_key" "\${ssh_opts[@]}")
33
+ fi
34
+
35
+ shell_quote() {
36
+ printf '%q' "$1"
37
+ }
38
+
39
+ remote_command() {
40
+ local remote="export PATH=\\$HOME/.bun/bin:\\$HOME/.deno/bin:\\$HOME/.local/bin:\\$PATH; cd $(shell_quote "$workspace") && exec"
41
+ for arg in "$@"; do
42
+ remote+=" $(shell_quote "$arg")"
43
+ done
44
+ printf '%s' "$remote"
45
+ }
46
+
47
+ wait_for_ssh() {
48
+ local timeout="$1"
49
+ local start now
50
+ start="$(date +%s)"
51
+ while true; do
52
+ if ssh "\${ssh_opts[@]}" "$user@$host" "true" >/dev/null 2>&1; then
53
+ return 0
54
+ fi
55
+ now="$(date +%s)"
56
+ if (( now - start >= timeout )); then
57
+ echo "Timed out waiting for SSH on $host:$port" >&2
58
+ return 1
59
+ fi
60
+ sleep 2
61
+ done
62
+ }
63
+
64
+ sync_workspace() {
65
+ if [[ "\${GV_SANDBOX_SYNC_WORKSPACE:-1}" == "0" ]]; then
66
+ return 0
67
+ fi
68
+ tar -C "$workspace_root" \\
69
+ --exclude='./.git' \\
70
+ --exclude='./.goodvibes/tui/sandbox' \\
71
+ -cf - . \\
72
+ | ssh "\${ssh_opts[@]}" "$user@$host" "mkdir -p $(shell_quote "$workspace") && tar -C $(shell_quote "$workspace") -xf -"
73
+ }
74
+
75
+ run_guest_command() {
76
+ ssh "\${ssh_opts[@]}" "$user@$host" "$(remote_command "$@")"
77
+ }
78
+
79
+ start_qemu() {
80
+ if [[ ! -f "$qemu_image" ]]; then
81
+ echo "QEMU image not found: $qemu_image" >&2
82
+ return 1
83
+ fi
84
+ local pidfile="$run_dir/qemu-$port.pid"
85
+ local serial_log="$logs_dir/serial-$port.log"
86
+ local qemu_log="$logs_dir/qemu-$port.log"
87
+ local monitor_sock="$run_dir/monitor-$port.sock"
88
+ rm -f "$monitor_sock"
89
+ if [[ -f "$pidfile" ]]; then
90
+ local old_pid
91
+ old_pid="$(<"$pidfile")"
92
+ if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then
93
+ return 0
94
+ fi
95
+ rm -f "$pidfile"
96
+ fi
97
+ local qemu_args=(
98
+ -display none
99
+ -serial "file:$serial_log"
100
+ -monitor "unix:$monitor_sock,server,nowait"
101
+ -m "\${GOODVIBES_QEMU_MEMORY:-1024}"
102
+ -smp "\${GOODVIBES_QEMU_CPUS:-2}"
103
+ -drive "file=$qemu_image,if=virtio,format=qcow2"
104
+ -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$port-:22"
105
+ -device "virtio-net-pci,netdev=net0"
106
+ -smbios "type=1,serial=ds=nocloud"
107
+ )
108
+ if [[ -r "$seed_iso" ]]; then
109
+ qemu_args+=( -drive "file=$seed_iso,if=virtio,media=cdrom,readonly=on" )
110
+ fi
111
+ if [[ -e /dev/kvm && -r /dev/kvm && -w /dev/kvm && "\${GOODVIBES_QEMU_ENABLE_KVM:-1}" != "0" ]]; then
112
+ qemu_args=( -enable-kvm "\${qemu_args[@]}" )
113
+ fi
114
+ "$qemu_bin" "\${qemu_args[@]}" >"$qemu_log" 2>&1 &
115
+ echo "$!" > "$pidfile"
116
+ }
117
+
118
+ stop_qemu() {
119
+ local pidfile="$run_dir/qemu-$port.pid"
120
+ local pid=""
121
+ if [[ -f "$pidfile" ]]; then
122
+ pid="$(<"$pidfile")"
123
+ fi
124
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
125
+ kill "$pid" 2>/dev/null || true
126
+ for _ in {1..20}; do
127
+ kill -0 "$pid" 2>/dev/null || break
128
+ sleep 0.25
129
+ done
130
+ kill -9 "$pid" 2>/dev/null || true
131
+ fi
132
+ rm -f "$pidfile" "$run_dir/monitor-$port.sock"
133
+ }
134
+
135
+ if [[ "$mode" == "host-exec" ]]; then
136
+ exec "$@"
137
+ fi
138
+
139
+ if [[ "$mode" == "ssh-guest" ]]; then
140
+ wait_for_ssh "$ssh_timeout"
141
+ sync_workspace
142
+ run_guest_command "$@"
143
+ exit $?
144
+ fi
145
+
146
+ if [[ "$mode" == "launch-qemu-ssh" ]]; then
147
+ start_qemu
148
+ trap stop_qemu EXIT
149
+ wait_for_ssh "$ssh_timeout"
150
+ sync_workspace
151
+ run_guest_command "$@"
152
+ exit $?
153
+ fi
154
+
155
+ echo "Unknown GV_SANDBOX_WRAPPER_MODE: $mode" >&2
156
+ exit 2
157
+ `;
158
+ }
159
+
160
+ export function renderQemuImageCreateScript(directory: string, imagePath: string, sizeGb: number): string {
161
+ const baseImage = resolve(directory, 'images/debian-12-genericcloud-amd64.qcow2');
162
+ const seedIso = resolve(directory, 'seed/nocloud.iso');
163
+ return `#!/usr/bin/env bash
164
+ set -euo pipefail
165
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
166
+ BASE_URL="\${GOODVIBES_QEMU_BASE_IMAGE_URL:-https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2}"
167
+ BASE_IMAGE="\${GOODVIBES_QEMU_BASE_IMAGE:-${baseImage}}"
168
+ TARGET_IMAGE="\${1:-${imagePath}}"
169
+ SIZE="\${2:-${sizeGb}G}"
170
+ SEED_DIR="$SCRIPT_DIR/seed"
171
+ SEED_ISO="\${GOODVIBES_QEMU_SEED_ISO:-${seedIso}}"
172
+
173
+ mkdir -p "$(dirname "$BASE_IMAGE")" "$(dirname "$TARGET_IMAGE")" "$SEED_DIR"
174
+
175
+ if [[ ! -f "$BASE_IMAGE" ]]; then
176
+ if command -v curl >/dev/null 2>&1; then
177
+ curl -fL "$BASE_URL" -o "$BASE_IMAGE"
178
+ elif command -v wget >/dev/null 2>&1; then
179
+ wget -O "$BASE_IMAGE" "$BASE_URL"
180
+ else
181
+ echo "curl or wget is required to download $BASE_URL" >&2
182
+ exit 1
183
+ fi
184
+ fi
185
+
186
+ cp "$BASE_IMAGE" "$TARGET_IMAGE"
187
+ qemu-img resize "$TARGET_IMAGE" "$SIZE" >/dev/null
188
+
189
+ if command -v xorriso >/dev/null 2>&1; then
190
+ ( cd "$SEED_DIR" && xorriso -as mkisofs -quiet -output "$SEED_ISO" -volid CIDATA -joliet -rock user-data meta-data network-config )
191
+ elif command -v genisoimage >/dev/null 2>&1; then
192
+ ( cd "$SEED_DIR" && genisoimage -quiet -output "$SEED_ISO" -volid CIDATA -joliet -rock user-data meta-data network-config )
193
+ elif command -v mkisofs >/dev/null 2>&1; then
194
+ ( cd "$SEED_DIR" && mkisofs -quiet -output "$SEED_ISO" -volid CIDATA -joliet -rock user-data meta-data network-config )
195
+ else
196
+ echo "xorriso, genisoimage, or mkisofs is required to build $SEED_ISO" >&2
197
+ exit 1
198
+ fi
199
+
200
+ chmod 600 "$SCRIPT_DIR/keys/goodvibes_qemu_ed25519" 2>/dev/null || true
201
+ echo "QEMU image ready: $TARGET_IMAGE"
202
+ echo "NoCloud seed ready: $SEED_ISO"
203
+ `;
204
+ }
205
+
206
+ export function renderQemuGuestBootstrapScript(): string {
207
+ return `#!/usr/bin/env bash
208
+ set -euo pipefail
209
+ export DEBIAN_FRONTEND=noninteractive
210
+
211
+ sudo apt-get update
212
+ sudo apt-get install -y \\
213
+ ca-certificates \\
214
+ curl \\
215
+ wget \\
216
+ git \\
217
+ jq \\
218
+ tar \\
219
+ unzip \\
220
+ xz-utils \\
221
+ build-essential \\
222
+ python3 \\
223
+ python3-pip \\
224
+ python3-venv \\
225
+ nodejs \\
226
+ npm \\
227
+ sqlite3 \\
228
+ postgresql-client \\
229
+ mariadb-client \\
230
+ openssh-server \\
231
+ ripgrep \\
232
+ fd-find \\
233
+ shellcheck \\
234
+ make \\
235
+ pkg-config \\
236
+ libssl-dev \\
237
+ python3-dev \\
238
+ golang \\
239
+ cargo \\
240
+ ruby \\
241
+ ruby-dev
242
+
243
+ if command -v fdfind >/dev/null 2>&1 && ! command -v fd >/dev/null 2>&1; then
244
+ sudo ln -sf /usr/bin/fdfind /usr/local/bin/fd
245
+ fi
246
+
247
+ if command -v npm >/dev/null 2>&1; then
248
+ sudo npm install -g typescript tsx ts-node graphql || true
249
+ sudo npm install -g graphql-cli || true
250
+ fi
251
+
252
+ if [[ "\${GOODVIBES_QEMU_INSTALL_BUN:-1}" == "1" ]]; then
253
+ curl -fsSL https://bun.sh/install | bash || true
254
+ fi
255
+
256
+ if [[ "\${GOODVIBES_QEMU_INSTALL_DENO:-1}" == "1" ]]; then
257
+ curl -fsSL https://deno.land/install.sh | sh || true
258
+ fi
259
+
260
+ if [[ "\${GOODVIBES_QEMU_INSTALL_UV:-1}" == "1" ]]; then
261
+ curl -LsSf https://astral.sh/uv/install.sh | sh || true
262
+ fi
263
+
264
+ if [[ "\${GOODVIBES_QEMU_INSTALL_DUCKDB:-1}" == "1" ]]; then
265
+ curl https://install.duckdb.org | sh || true
266
+ fi
267
+
268
+ if [[ -x "$HOME/.bun/bin/bun" ]]; then
269
+ sudo ln -sf "$HOME/.bun/bin/bun" /usr/local/bin/bun
270
+ fi
271
+ if [[ -x "$HOME/.deno/bin/deno" ]]; then
272
+ sudo ln -sf "$HOME/.deno/bin/deno" /usr/local/bin/deno
273
+ fi
274
+ if [[ -x "$HOME/.local/bin/uv" ]]; then
275
+ sudo ln -sf "$HOME/.local/bin/uv" /usr/local/bin/uv
276
+ fi
277
+ if [[ -x "$HOME/.duckdb/cli/latest/duckdb" ]]; then
278
+ sudo ln -sf "$HOME/.duckdb/cli/latest/duckdb" /usr/local/bin/duckdb
279
+ fi
280
+
281
+ grep -qxF 'export PATH="$HOME/.bun/bin:$HOME/.deno/bin:$HOME/.local/bin:$PATH"' "$HOME/.profile" 2>/dev/null \\
282
+ || echo 'export PATH="$HOME/.bun/bin:$HOME/.deno/bin:$HOME/.local/bin:$PATH"' >> "$HOME/.profile"
283
+
284
+ mkdir -p /workspace
285
+ sudo chown goodvibes:goodvibes /workspace
286
+
287
+ echo "GoodVibes guest bootstrap complete."
288
+ `;
289
+ }
290
+
291
+ export function renderQemuSetupReadme(directory: string, imagePath: string, seedIsoPath: string): string {
292
+ return `GoodVibes QEMU sandbox bootstrap bundle
293
+
294
+ Host prerequisites:
295
+ qemu-system-x86_64 qemu-img ssh ssh-keygen curl-or-wget xorriso-or-genisoimage
296
+ KVM is optional but strongly recommended: /dev/kvm
297
+
298
+ Generated files:
299
+ qemu-wrapper.sh launch/attach wrapper used by GoodVibes
300
+ create-image.sh downloads Debian 12 cloud image, clones it, resizes it, builds NoCloud ISO
301
+ guest-bootstrap.sh run inside the guest to install REPL/MCP-friendly runtimes
302
+ keys/goodvibes_qemu_ed25519 SSH key for goodvibes guest user
303
+ seed/user-data cloud-init user/bootstrap config
304
+ seed/meta-data cloud-init identity
305
+ seed/network-config cloud-init ens3 DHCP config
306
+ seed/nocloud.iso generated cloud-init ISO
307
+ logs/ QEMU and serial logs
308
+ run/ pid/socket runtime files
309
+
310
+ First-run workflow:
311
+ 1. Run: ${shellSingleQuote(resolve(directory, 'create-image.sh'))} ${shellSingleQuote(imagePath)} 20G
312
+ 2. GoodVibes settings should point to:
313
+ sandbox.vmBackend = qemu
314
+ sandbox.qemuBinary = qemu-system-x86_64
315
+ sandbox.qemuImagePath = ${imagePath}
316
+ sandbox.qemuExecWrapper = ${resolve(directory, 'qemu-wrapper.sh')}
317
+ sandbox.qemuGuestHost = 127.0.0.1
318
+ sandbox.qemuGuestPort = 2222
319
+ sandbox.qemuGuestUser = goodvibes
320
+ sandbox.qemuWorkspacePath = /workspace
321
+ sandbox.qemuSessionMode = launch-per-command
322
+ sandbox.replJavaScriptCommand = /home/goodvibes/.bun/bin/bun
323
+ 3. Provision guest runtimes:
324
+ GV_SANDBOX_SYNC_WORKSPACE=0 GV_SANDBOX_WRAPPER_MODE=launch-qemu-ssh ${shellSingleQuote(resolve(directory, 'qemu-wrapper.sh'))} bash -s < ${shellSingleQuote(resolve(directory, 'guest-bootstrap.sh'))}
325
+ 4. Run: /sandbox guest-test eval-py
326
+
327
+ Guest runtimes installed by guest-bootstrap.sh:
328
+ python3, pip, venv, nodejs, npm, typescript, tsx, ts-node, sqlite3,
329
+ postgresql-client, mariadb-client, GraphQL tools, Bun, Deno, uv,
330
+ DuckDB, Go, Rust/Cargo, Ruby, ripgrep, fd, shellcheck, make, pkg-config,
331
+ libssl-dev, and python3-dev.
332
+
333
+ Debugging:
334
+ serial log: ${resolve(directory, 'logs/serial-2222.log')}
335
+ qemu log: ${resolve(directory, 'logs/qemu-2222.log')}
336
+ seed ISO: ${seedIsoPath}
337
+
338
+ The wrapper accepts GOODVIBES_QEMU_SSH_TIMEOUT, defaulting to 300 seconds, because first boot cloud-init can be slow.
339
+ `;
340
+ }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.19.99';
9
+ let _version = '0.20.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;