@pellux/goodvibes-tui 0.19.99 → 0.20.1
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 +16 -0
- package/README.md +10 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/input/commands/local-setup-review.ts +4 -1
- package/src/input/commands/platform-sandbox-qemu.ts +17 -35
- package/src/input/commands/platform-sandbox-runtime.ts +4 -2
- package/src/renderer/fullscreen-primitives.ts +130 -0
- package/src/renderer/fullscreen-workspace.ts +199 -0
- package/src/renderer/mcp-workspace.ts +176 -236
- package/src/renderer/onboarding/onboarding-wizard.ts +16 -42
- package/src/renderer/overlay-box.ts +12 -31
- package/src/renderer/settings-modal-helpers.ts +1 -0
- package/src/renderer/settings-modal.ts +53 -210
- package/src/runtime/sandbox-public-gaps.ts +158 -38
- package/src/runtime/sandbox-qemu-templates.ts +340 -0
- package/src/version.ts +1 -1
|
@@ -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
|
-
'-
|
|
163
|
-
'-
|
|
164
|
-
'-
|
|
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
|
|
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
|
|
395
|
-
guestUser
|
|
396
|
-
guestWorkspacePath
|
|
397
|
-
sessionMode:
|
|
489
|
+
guestPort,
|
|
490
|
+
guestUser,
|
|
491
|
+
guestWorkspacePath,
|
|
492
|
+
sessionMode: 'launch-per-command',
|
|
493
|
+
replJavaScriptCommand,
|
|
398
494
|
},
|
|
399
495
|
};
|
|
400
|
-
|
|
401
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
manager
|
|
418
|
-
manager.setDynamic('sandbox.
|
|
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.
|
|
9
|
+
let _version = '0.20.1';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|