shmakk 1.2.4 → 1.2.5
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/.env.example +11 -0
- package/README.md +75 -1
- package/docs/index.html +154 -16
- package/docs/mcp.md +78 -0
- package/docs/ssh.md +82 -0
- package/docs/vibedit-analysis.md +375 -0
- package/docs/vim.md +110 -0
- package/docs/voice.md +4 -0
- package/package.json +9 -5
- package/scripts/test-vibedit.js +45 -0
- package/scripts/vibedit-demo.sh +52 -0
- package/skills/shmakk-skill-creator.md +269 -0
- package/src/_check.js +7 -0
- package/src/_check_schema.js +5 -0
- package/src/_cleanup.js +18 -0
- package/src/_fix.js +9 -0
- package/src/_test_import.js +15 -0
- package/src/agent.js +11 -4
- package/src/browser-daemon.js +209 -0
- package/src/browser.js +10 -0
- package/src/cli/browserDaemon.js +60 -0
- package/src/cli/connectBrowser.js +137 -0
- package/src/cli.js +235 -8
- package/src/completions.js +8 -0
- package/src/control.js +273 -1
- package/src/core/browserConnector.js +523 -0
- package/src/electron.js +305 -0
- package/src/endpoints.js +74 -9
- package/src/index.js +24 -1
- package/src/llm.js +501 -61
- package/src/mobile.js +307 -0
- package/src/notify.js +51 -3
- package/src/orchestrator.js +35 -1
- package/src/pty.js +11 -6
- package/src/review.js +45 -11
- package/src/self-commands.js +153 -0
- package/src/session-convert.js +508 -0
- package/src/session-search.js +31 -0
- package/src/session.js +384 -46
- package/src/skills/browserActions.ts +984 -0
- package/src/skills.js +451 -24
- package/src/system-prompt.js +31 -25
- package/src/tools.js +81 -0
- package/src/vibedit/control.js +534 -0
- package/src/vibedit/electron.js +108 -0
- package/src/vibedit/files.js +171 -0
- package/src/vibedit/index.js +298 -0
- package/src/vibedit/overlay.js +1482 -0
- package/src/vibedit/prompts.js +245 -0
- package/src/vibedit/state.js +32 -0
- package/src/vim.js +410 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Source file walking and search/replace edit block application.
|
|
2
|
+
// Ported from vibedit for shmakk integration.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const SOURCE_EXT = new Set([
|
|
8
|
+
".html", ".htm", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".astro",
|
|
9
|
+
".css", ".scss", ".sass", ".less", ".mjs", ".cjs"
|
|
10
|
+
]);
|
|
11
|
+
const IGNORE_DIRS = new Set([
|
|
12
|
+
"node_modules", ".git", "dist", "build", ".next", ".nuxt", ".output",
|
|
13
|
+
".shmakk", "coverage", ".cache", ".svelte-kit", "out", "vendor"
|
|
14
|
+
]);
|
|
15
|
+
const MAX_FILE_BYTES = 400_000;
|
|
16
|
+
const MAX_SHORTLIST = 5;
|
|
17
|
+
const MAX_TOTAL_CHARS = 16_000;
|
|
18
|
+
|
|
19
|
+
function walkSources(root) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const stack = [root];
|
|
22
|
+
while (stack.length) {
|
|
23
|
+
const dir = stack.pop();
|
|
24
|
+
let entries;
|
|
25
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
if (e.name.startsWith(".") && e.name !== ".env") continue;
|
|
28
|
+
const full = path.join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) {
|
|
30
|
+
if (!IGNORE_DIRS.has(e.name)) stack.push(full);
|
|
31
|
+
} else if (SOURCE_EXT.has(path.extname(e.name))) {
|
|
32
|
+
try { if (fs.statSync(full).size <= MAX_FILE_BYTES) out.push(full); } catch {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractNeedles(changes) {
|
|
40
|
+
const needles = new Set();
|
|
41
|
+
for (const ch of changes) {
|
|
42
|
+
if (ch.kind === "css") {
|
|
43
|
+
if (ch.selector) needles.add(ch.selector.replace(/^\./, ""));
|
|
44
|
+
for (const m of (ch.before || "").matchAll(/([\w-]+)\s*:/g)) if (m[1].length >= 4) needles.add(m[1]);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const html of [ch.before, ch.after]) {
|
|
48
|
+
if (!html) continue;
|
|
49
|
+
const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
50
|
+
for (const frag of text.split(/[.!?\n]/)) {
|
|
51
|
+
const f = frag.trim();
|
|
52
|
+
if (f.length >= 6 && f.length <= 80) needles.add(f);
|
|
53
|
+
}
|
|
54
|
+
for (const m of html.matchAll(/class="([^"]+)"/g)) {
|
|
55
|
+
for (const cls of m[1].split(/\s+/)) if (cls.length >= 4) needles.add(cls);
|
|
56
|
+
}
|
|
57
|
+
for (const m of html.matchAll(/id="([^"]+)"/g)) if (m[1].length >= 3) needles.add(m[1]);
|
|
58
|
+
}
|
|
59
|
+
if (ch.selector) {
|
|
60
|
+
for (const part of ch.selector.split(/[\s>.#:\[\]]+/)) {
|
|
61
|
+
if (part.length >= 4 && !/^(div|span|nth|of|type)$/.test(part)) needles.add(part);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...needles].slice(0, 60);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shortlistFiles(projectDir, changes) {
|
|
69
|
+
const needles = extractNeedles(changes);
|
|
70
|
+
if (needles.length === 0) return [];
|
|
71
|
+
const files = walkSources(projectDir);
|
|
72
|
+
const scored = [];
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
let content;
|
|
75
|
+
try { content = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
76
|
+
let score = 0;
|
|
77
|
+
const hitLines = new Set();
|
|
78
|
+
for (const n of needles) {
|
|
79
|
+
let idx = content.indexOf(n);
|
|
80
|
+
while (idx !== -1) {
|
|
81
|
+
score += Math.min(n.length, 30);
|
|
82
|
+
hitLines.add(content.slice(0, idx).split("\n").length);
|
|
83
|
+
idx = content.indexOf(n, idx + n.length);
|
|
84
|
+
if (score > 5000) break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (score > 0) scored.push({ file, score, content, hitLines: [...hitLines] });
|
|
88
|
+
}
|
|
89
|
+
scored.sort((a, b) => b.score - a.score);
|
|
90
|
+
const top = scored.slice(0, MAX_SHORTLIST);
|
|
91
|
+
let budget = MAX_TOTAL_CHARS;
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const s of top) {
|
|
94
|
+
let snippet = s.content;
|
|
95
|
+
if (snippet.length > budget) {
|
|
96
|
+
snippet = windowsAroundLines(s.content, s.hitLines, Math.max(budget, 2000));
|
|
97
|
+
}
|
|
98
|
+
snippet = snippet.slice(0, budget);
|
|
99
|
+
budget -= snippet.length;
|
|
100
|
+
result.push({ path: path.relative(projectDir, s.file), content: snippet, truncated: snippet.length < s.content.length });
|
|
101
|
+
if (budget < 1500) break;
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function windowsAroundLines(content, lines, charBudget) {
|
|
107
|
+
const all = content.split("\n");
|
|
108
|
+
const keep = new Set();
|
|
109
|
+
for (const ln of lines) {
|
|
110
|
+
for (let i = Math.max(0, ln - 16); i < Math.min(all.length, ln + 16); i++) keep.add(i);
|
|
111
|
+
}
|
|
112
|
+
const parts = [];
|
|
113
|
+
let chunk = [];
|
|
114
|
+
let prev = -2;
|
|
115
|
+
const sorted = [...keep].sort((a, b) => a - b);
|
|
116
|
+
for (const i of sorted) {
|
|
117
|
+
if (i !== prev + 1 && chunk.length) { parts.push(chunk.join("\n")); chunk = []; }
|
|
118
|
+
chunk.push(all[i]);
|
|
119
|
+
prev = i;
|
|
120
|
+
}
|
|
121
|
+
if (chunk.length) parts.push(chunk.join("\n"));
|
|
122
|
+
return parts.join("\n...\n").slice(0, charBudget);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const BLOCK_RE = /FILE:\s*(.+?)\s*\n<{5,}\s*SEARCH\s*\n([\s\S]*?)\n={5,}\s*\n([\s\S]*?)\n>{5,}\s*REPLACE/g;
|
|
126
|
+
|
|
127
|
+
function applyEditBlocks(projectDir, stateDir, modelOutput) {
|
|
128
|
+
const applied = [];
|
|
129
|
+
const failed = [];
|
|
130
|
+
const backupDir = path.join(stateDir, "backups", String(Date.now()));
|
|
131
|
+
let m;
|
|
132
|
+
while ((m = BLOCK_RE.exec(modelOutput)) !== null) {
|
|
133
|
+
const relPath = m[1].trim().replace(/^["'`]|["'`]$/g, "");
|
|
134
|
+
const search = m[2];
|
|
135
|
+
const replace = m[3];
|
|
136
|
+
const full = path.join(projectDir, relPath);
|
|
137
|
+
if (!full.startsWith(projectDir)) { failed.push(`${relPath} (outside project)`); continue; }
|
|
138
|
+
let content;
|
|
139
|
+
try { content = fs.readFileSync(full, "utf8"); } catch { failed.push(`${relPath} (not readable)`); continue; }
|
|
140
|
+
let next = exactReplace(content, search, replace) ?? fuzzyReplace(content, search, replace);
|
|
141
|
+
if (next === null) { failed.push(`${relPath} (search text not found)`); continue; }
|
|
142
|
+
fs.mkdirSync(path.join(backupDir, path.dirname(relPath)), { recursive: true });
|
|
143
|
+
fs.writeFileSync(path.join(backupDir, relPath), content);
|
|
144
|
+
fs.writeFileSync(full, next);
|
|
145
|
+
applied.push(relPath);
|
|
146
|
+
}
|
|
147
|
+
return { applied, failed };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function exactReplace(content, search, replace) {
|
|
151
|
+
const idx = content.indexOf(search);
|
|
152
|
+
if (idx === -1) return null;
|
|
153
|
+
return content.slice(0, idx) + replace + content.slice(idx + search.length);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fuzzyReplace(content, search, replace) {
|
|
157
|
+
const cLines = content.split("\n");
|
|
158
|
+
const sLines = search.split("\n").map((l) => l.trim()).filter((l, i, a) => !(l === "" && (i === 0 || i === a.length - 1)));
|
|
159
|
+
if (sLines.length === 0) return null;
|
|
160
|
+
outer: for (let i = 0; i <= cLines.length - sLines.length; i++) {
|
|
161
|
+
for (let j = 0; j < sLines.length; j++) {
|
|
162
|
+
if (cLines[i + j].trim() !== sLines[j]) continue outer;
|
|
163
|
+
}
|
|
164
|
+
const before = cLines.slice(0, i).join("\n");
|
|
165
|
+
const after = cLines.slice(i + sLines.length).join("\n");
|
|
166
|
+
return [before, replace, after].filter((s, k) => s !== "" || k === 1).join("\n");
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { walkSources, extractNeedles, shortlistFiles, applyEditBlocks };
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// vibedit — in-browser overlay chat + recorder, integrated into shmakk.
|
|
2
|
+
// Starts a visible Playwright Chromium browser, injects the overlay into
|
|
3
|
+
// every page load, and runs the control WebSocket server for chat/save/flow.
|
|
4
|
+
//
|
|
5
|
+
// The target can be:
|
|
6
|
+
// - A URL (http://... or https://...)
|
|
7
|
+
// - An HTML file (opened directly as file://)
|
|
8
|
+
// - A package.json or directory containing one (dev server auto-started)
|
|
9
|
+
|
|
10
|
+
const { chromium } = require('playwright');
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const { startControlServer } = require('./control');
|
|
16
|
+
const { ensureVibeditState } = require('./state');
|
|
17
|
+
|
|
18
|
+
const VIBEDIT_CONTROL_PORT = 0;
|
|
19
|
+
|
|
20
|
+
// URL pattern matched against dev server stdout lines.
|
|
21
|
+
const DEV_URL_RE = /(https?:\/\/localhost:\d{1,5}|https?:\/\/127\.0\.0\.1:\d{1,5}|https?:\/\/\[::1\]:\d{1,5})/;
|
|
22
|
+
|
|
23
|
+
// ── resolveTarget ──────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function resolveTarget(target) {
|
|
26
|
+
const resolved = path.resolve(target);
|
|
27
|
+
|
|
28
|
+
// Already a URL — pass through.
|
|
29
|
+
if (/^https?:\/\//.test(target)) {
|
|
30
|
+
return { url: target, proc: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The target is a package.json file.
|
|
34
|
+
if (path.basename(resolved) === 'package.json' && fs.existsSync(resolved)) {
|
|
35
|
+
return startDevServer(path.dirname(resolved));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The target is an HTML file — open directly as file://.
|
|
39
|
+
if (/\.html?$/i.test(resolved) && fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
40
|
+
return { url: `file://${resolved}`, proc: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The target is a directory — check for package.json first, then index.html.
|
|
44
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
45
|
+
const pkg = path.join(resolved, 'package.json');
|
|
46
|
+
if (fs.existsSync(pkg)) return startDevServer(resolved);
|
|
47
|
+
const idx = path.join(resolved, 'index.html');
|
|
48
|
+
if (fs.existsSync(idx)) return { url: `file://${idx}`, proc: null };
|
|
49
|
+
return { url: null, proc: null, error: `no package.json or index.html in ${resolved}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The target is some other file — open as file://.
|
|
53
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
54
|
+
return { url: `file://${resolved}`, proc: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve relative to cwd, same logic.
|
|
58
|
+
const cwdResolved = path.resolve(process.cwd(), target);
|
|
59
|
+
if (path.basename(cwdResolved) === 'package.json' && fs.existsSync(cwdResolved)) {
|
|
60
|
+
return startDevServer(path.dirname(cwdResolved));
|
|
61
|
+
}
|
|
62
|
+
if (/\.html?$/i.test(cwdResolved) && fs.existsSync(cwdResolved) && fs.statSync(cwdResolved).isFile()) {
|
|
63
|
+
return { url: `file://${cwdResolved}`, proc: null };
|
|
64
|
+
}
|
|
65
|
+
if (fs.existsSync(cwdResolved) && fs.statSync(cwdResolved).isDirectory()) {
|
|
66
|
+
const pkg = path.join(cwdResolved, 'package.json');
|
|
67
|
+
if (fs.existsSync(pkg)) return startDevServer(cwdResolved);
|
|
68
|
+
const idx = path.join(cwdResolved, 'index.html');
|
|
69
|
+
if (fs.existsSync(idx)) return { url: `file://${idx}`, proc: null };
|
|
70
|
+
}
|
|
71
|
+
if (fs.existsSync(cwdResolved) && fs.statSync(cwdResolved).isFile()) {
|
|
72
|
+
return { url: `file://${cwdResolved}`, proc: null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Fallback: assume it's a URL.
|
|
76
|
+
return { url: target, proc: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── startDevServer ─────────────────────────────────────────────────────────
|
|
80
|
+
// Reads package.json, picks the dev/start/serve script, spawns it, and
|
|
81
|
+
// watches stdout for the URL the dev server prints.
|
|
82
|
+
|
|
83
|
+
function detectPackageManager(projectDir) {
|
|
84
|
+
if (fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
85
|
+
if (fs.existsSync(path.join(projectDir, 'bun.lockb')) || fs.existsSync(path.join(projectDir, 'bun.lock'))) return 'bun';
|
|
86
|
+
if (fs.existsSync(path.join(projectDir, 'yarn.lock'))) return 'yarn';
|
|
87
|
+
if (fs.existsSync(path.join(projectDir, 'package-lock.json'))) return 'npm';
|
|
88
|
+
return 'npm';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pickDevScript(pkg) {
|
|
92
|
+
const scripts = pkg.scripts || {};
|
|
93
|
+
if (scripts.dev) return 'dev';
|
|
94
|
+
if (scripts.start) return 'start';
|
|
95
|
+
if (scripts.serve) return 'serve';
|
|
96
|
+
if (scripts.develop) return 'develop';
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function startDevServer(projectDir) {
|
|
101
|
+
let pkg;
|
|
102
|
+
try {
|
|
103
|
+
pkg = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'));
|
|
104
|
+
} catch {
|
|
105
|
+
return { url: null, proc: null, error: `failed to read ${path.join(projectDir, 'package.json')}` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const scriptName = pickDevScript(pkg);
|
|
109
|
+
if (!scriptName) {
|
|
110
|
+
return { url: null, proc: null, error: 'no dev, start, or serve script in package.json' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pm = detectPackageManager(projectDir);
|
|
114
|
+
const args = pm === 'npm' || pm === 'yarn' ? ['run', scriptName] : [scriptName];
|
|
115
|
+
const cmd = pm;
|
|
116
|
+
|
|
117
|
+
console.log(`[shmakk vibedit] starting: ${cmd} ${args.join(' ')} (in ${projectDir})`);
|
|
118
|
+
|
|
119
|
+
const proc = spawn(cmd, args, {
|
|
120
|
+
cwd: projectDir,
|
|
121
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
122
|
+
shell: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const urlPromise = new Promise((resolveUrl) => {
|
|
126
|
+
let found = false;
|
|
127
|
+
|
|
128
|
+
function onLine(line) {
|
|
129
|
+
if (found) return;
|
|
130
|
+
const m = line.match(DEV_URL_RE);
|
|
131
|
+
if (m) {
|
|
132
|
+
found = true;
|
|
133
|
+
const url = m[1].replace(/\/+$/, '');
|
|
134
|
+
console.log(`[shmakk vibedit] detected dev server: ${url}`);
|
|
135
|
+
resolveUrl(url);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let stdoutBuf = '';
|
|
140
|
+
proc.stdout.on('data', (chunk) => {
|
|
141
|
+
stdoutBuf += chunk.toString();
|
|
142
|
+
const lines = stdoutBuf.split('\n');
|
|
143
|
+
stdoutBuf = lines.pop();
|
|
144
|
+
for (const ln of lines) onLine(ln);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
let stderrBuf = '';
|
|
148
|
+
proc.stderr.on('data', (chunk) => {
|
|
149
|
+
stderrBuf += chunk.toString();
|
|
150
|
+
const lines = stderrBuf.split('\n');
|
|
151
|
+
stderrBuf = lines.pop();
|
|
152
|
+
for (const ln of lines) onLine(ln);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
proc.on('close', (code) => {
|
|
156
|
+
if (!found) resolveUrl(null);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
proc.on('error', () => {
|
|
160
|
+
if (!found) resolveUrl(null);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return { urlPromise, proc };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── waitReachable ──────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
async function waitReachable(url, timeoutMs) {
|
|
170
|
+
// file:// URLs don't need reachability checks.
|
|
171
|
+
if (url.startsWith('file://')) return true;
|
|
172
|
+
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const deadline = Date.now() + (timeoutMs || 30000);
|
|
175
|
+
const tryConnect = () => {
|
|
176
|
+
if (Date.now() >= deadline) return resolve(false);
|
|
177
|
+
const req = http.get(url, (res) => { res.resume(); resolve(true); });
|
|
178
|
+
req.on('error', () => setTimeout(tryConnect, 500));
|
|
179
|
+
req.setTimeout(2000, () => { req.destroy(); setTimeout(tryConnect, 500); });
|
|
180
|
+
};
|
|
181
|
+
tryConnect();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── startVibedit ───────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
// Resolve the built-in extension path. Returns the absolute path if the
|
|
188
|
+
// manifest exists, otherwise null.
|
|
189
|
+
function resolveExtensionPath() {
|
|
190
|
+
const extPath = path.resolve(__dirname, '..', '..', 'extensions', 'vibedit');
|
|
191
|
+
if (fs.existsSync(path.join(extPath, 'manifest.json'))) return extPath;
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function startVibedit(opts) {
|
|
196
|
+
let { projectDir, appUrl, onSpec, useExtension } = opts;
|
|
197
|
+
|
|
198
|
+
// Resolve target.
|
|
199
|
+
const target = resolveTarget(appUrl);
|
|
200
|
+
let resolvedUrl;
|
|
201
|
+
let devProc = null;
|
|
202
|
+
|
|
203
|
+
if (target.urlPromise) {
|
|
204
|
+
// Dev server was spawned — wait for the URL with a 60s timeout.
|
|
205
|
+
console.log('[shmakk vibedit] waiting for dev server URL...');
|
|
206
|
+
resolvedUrl = await Promise.race([
|
|
207
|
+
target.urlPromise,
|
|
208
|
+
new Promise((r) => setTimeout(() => r(null), 60000)),
|
|
209
|
+
]);
|
|
210
|
+
devProc = target.proc;
|
|
211
|
+
if (!resolvedUrl) {
|
|
212
|
+
console.error('[shmakk vibedit] dev server did not print a URL within 60s');
|
|
213
|
+
if (devProc) { try { devProc.kill(); } catch {} }
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
} else if (target.error) {
|
|
217
|
+
console.error(`[shmakk vibedit] ${target.error}`);
|
|
218
|
+
return null;
|
|
219
|
+
} else {
|
|
220
|
+
resolvedUrl = target.url;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const state = ensureVibeditState(projectDir);
|
|
224
|
+
|
|
225
|
+
const port = opts.port ?? VIBEDIT_CONTROL_PORT;
|
|
226
|
+
|
|
227
|
+
// Launch visible browser.
|
|
228
|
+
const browser = await chromium.launch({
|
|
229
|
+
headless: false,
|
|
230
|
+
args: ['--disable-blink-features=AutomationControlled'],
|
|
231
|
+
});
|
|
232
|
+
const context = await browser.newContext({ viewport: null });
|
|
233
|
+
const page = await context.newPage();
|
|
234
|
+
|
|
235
|
+
console.log(`[shmakk vibedit] app: ${resolvedUrl}`);
|
|
236
|
+
|
|
237
|
+
// Start control server (WebSocket + HTTP) with page ref for screenshots.
|
|
238
|
+
const control = await startControlServer({
|
|
239
|
+
port,
|
|
240
|
+
stateDir: state.stateDir,
|
|
241
|
+
sessionsDir: state.sessionsDir,
|
|
242
|
+
specsDir: state.specsDir,
|
|
243
|
+
pendingSpecFile: state.pendingSpecFile,
|
|
244
|
+
automationsDir: state.automationsDir,
|
|
245
|
+
pageStateFile: state.pageStateFile,
|
|
246
|
+
page,
|
|
247
|
+
projectDir,
|
|
248
|
+
onSpec,
|
|
249
|
+
});
|
|
250
|
+
const controlPort = control.port;
|
|
251
|
+
console.log(`[shmakk vibedit] control: ws://127.0.0.1:${controlPort}`);
|
|
252
|
+
|
|
253
|
+
// Inject overlay on every document load (survives HMR reloads).
|
|
254
|
+
const overlayJs = fs.readFileSync(path.join(__dirname, 'overlay.js'), 'utf8');
|
|
255
|
+
const boot = `window.__VIBEDIT__ = { port: ${controlPort} };\n${overlayJs}`;
|
|
256
|
+
await context.addInitScript({ content: boot });
|
|
257
|
+
|
|
258
|
+
// Wait for app to be reachable, then navigate.
|
|
259
|
+
const reachable = await waitReachable(resolvedUrl, 30000);
|
|
260
|
+
if (!reachable && !resolvedUrl.startsWith('file://')) {
|
|
261
|
+
console.warn(`[shmakk vibedit] warning: ${resolvedUrl} is not responding yet, navigating anyway`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let navigated = false;
|
|
265
|
+
for (let attempt = 1; attempt <= 3 && !navigated; attempt++) {
|
|
266
|
+
try {
|
|
267
|
+
await page.goto(resolvedUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
268
|
+
navigated = true;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.warn(`[shmakk vibedit] navigation attempt ${attempt} failed: ${err.message.split('\n')[0]}`);
|
|
271
|
+
if (attempt < 3) await new Promise((r) => setTimeout(r, 1500));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!navigated) {
|
|
275
|
+
console.error(`[shmakk vibedit] could not open ${resolvedUrl}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let closed = false;
|
|
279
|
+
const shutdown = async (reason = 'requested') => {
|
|
280
|
+
if (closed) return;
|
|
281
|
+
closed = true;
|
|
282
|
+
console.log(`\n[shmakk vibedit] shutting down (${reason})`);
|
|
283
|
+
try { await browser.close(); } catch {}
|
|
284
|
+
control.close();
|
|
285
|
+
if (devProc) {
|
|
286
|
+
try { devProc.kill('SIGTERM'); } catch {}
|
|
287
|
+
// Give it a moment, then force kill.
|
|
288
|
+
setTimeout(() => { try { devProc.kill('SIGKILL'); } catch {} }, 3000);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
page.on('close', () => { shutdown('browser window closed'); });
|
|
293
|
+
browser.on('disconnected', () => { shutdown('browser disconnected'); });
|
|
294
|
+
|
|
295
|
+
return { browser, control, port: controlPort, shutdown };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = { startVibedit, VIBEDIT_CONTROL_PORT };
|