stashes 0.1.0 → 0.1.2
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/dist/cli.js +2023 -0
- package/dist/commands/apply.d.ts +2 -0
- package/dist/commands/apply.d.ts.map +1 -0
- package/dist/commands/apply.js +10 -0
- package/dist/commands/apply.js.map +1 -0
- package/dist/commands/cleanup.d.ts +2 -0
- package/dist/commands/cleanup.d.ts.map +1 -0
- package/dist/commands/cleanup.js +10 -0
- package/dist/commands/cleanup.js.map +1 -0
- package/dist/commands/generate.d.ts +8 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +55 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +19 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/remove.js +9 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/setup.d.ts +7 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +229 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/start.d.ts +9 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +42 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/vary.d.ts +2 -0
- package/dist/commands/vary.d.ts.map +1 -0
- package/dist/commands/vary.js +27 -0
- package/dist/commands/vary.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.js +1781 -0
- package/dist/web/assets/index-B_JpGQr9.css +1 -0
- package/dist/web/assets/index-BiVQXVVI.js +62 -0
- package/dist/web/index.html +16 -0
- package/package.json +51 -4
- package/index.js +0 -1
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,1781 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
9
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
10
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
11
|
+
for (let key of __getOwnPropNames(mod))
|
|
12
|
+
if (!__hasOwnProp.call(to, key))
|
|
13
|
+
__defProp(to, key, {
|
|
14
|
+
get: () => mod[key],
|
|
15
|
+
enumerable: true
|
|
16
|
+
});
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __require = import.meta.require;
|
|
20
|
+
|
|
21
|
+
// ../mcp/src/index.ts
|
|
22
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
23
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
24
|
+
|
|
25
|
+
// ../mcp/src/tools/generate.ts
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
|
|
28
|
+
// ../core/dist/generation.js
|
|
29
|
+
import { readFileSync as readFileSync2, existsSync as existsSync5 } from "fs";
|
|
30
|
+
import { join as join5 } from "path";
|
|
31
|
+
var {spawn: spawn3 } = globalThis.Bun;
|
|
32
|
+
import simpleGit2 from "simple-git";
|
|
33
|
+
// ../shared/dist/constants/index.js
|
|
34
|
+
var STASHES_PORT = 4000;
|
|
35
|
+
var DEFAULT_STASH_COUNT = 3;
|
|
36
|
+
var STASH_PORT_START = 4010;
|
|
37
|
+
var DEFAULT_DIRECTIVES = [
|
|
38
|
+
"Minimal and clean \u2014 reduce visual noise, emphasize whitespace, limit to 2-3 colors, typography-driven hierarchy",
|
|
39
|
+
"Bold and expressive \u2014 strong visual identity, use motion/animation, rich interactions, distinctive layout",
|
|
40
|
+
"Data-dense and functional \u2014 maximize information density, compact layout, efficient use of space, power-user focused",
|
|
41
|
+
"Elegant and premium \u2014 refined aesthetics, subtle animations, luxury feel, careful attention to micro-interactions",
|
|
42
|
+
"Playful and modern \u2014 rounded corners, vibrant colors, friendly tone, engaging micro-interactions"
|
|
43
|
+
];
|
|
44
|
+
// ../core/dist/worktree.js
|
|
45
|
+
import simpleGit from "simple-git";
|
|
46
|
+
import { join as join2 } from "path";
|
|
47
|
+
import { existsSync as existsSync2, rmSync, symlinkSync } from "fs";
|
|
48
|
+
|
|
49
|
+
// ../core/dist/logger.js
|
|
50
|
+
import { appendFileSync, mkdirSync, existsSync } from "fs";
|
|
51
|
+
import { join } from "path";
|
|
52
|
+
var COLORS = {
|
|
53
|
+
reset: "\x1B[0m",
|
|
54
|
+
dim: "\x1B[2m",
|
|
55
|
+
cyan: "\x1B[36m",
|
|
56
|
+
green: "\x1B[32m",
|
|
57
|
+
yellow: "\x1B[33m",
|
|
58
|
+
red: "\x1B[31m",
|
|
59
|
+
magenta: "\x1B[35m"
|
|
60
|
+
};
|
|
61
|
+
var LEVEL_COLORS = {
|
|
62
|
+
debug: COLORS.dim,
|
|
63
|
+
info: COLORS.cyan,
|
|
64
|
+
warn: COLORS.yellow,
|
|
65
|
+
error: COLORS.red
|
|
66
|
+
};
|
|
67
|
+
var LEVEL_LABELS = {
|
|
68
|
+
debug: "DBG",
|
|
69
|
+
info: "INF",
|
|
70
|
+
warn: "WRN",
|
|
71
|
+
error: "ERR"
|
|
72
|
+
};
|
|
73
|
+
var logFilePath = null;
|
|
74
|
+
function initLogFile(projectPath) {
|
|
75
|
+
const logDir = join(projectPath, ".stashes", "logs");
|
|
76
|
+
if (!existsSync(logDir)) {
|
|
77
|
+
mkdirSync(logDir, { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
const date = new Date().toISOString().substring(0, 10);
|
|
80
|
+
logFilePath = join(logDir, `stashes-${date}.log`);
|
|
81
|
+
}
|
|
82
|
+
function ts() {
|
|
83
|
+
return new Date().toISOString().substring(11, 23);
|
|
84
|
+
}
|
|
85
|
+
function log(level, category, message, data) {
|
|
86
|
+
const timestamp = ts();
|
|
87
|
+
const color = LEVEL_COLORS[level];
|
|
88
|
+
const label = LEVEL_LABELS[level];
|
|
89
|
+
const dataStr = data ? ` ${JSON.stringify(data)}` : "";
|
|
90
|
+
console.log(`${COLORS.dim}${timestamp}${COLORS.reset} ${color}${label}${COLORS.reset} ${COLORS.magenta}[${category}]${COLORS.reset} ${message}${data ? ` ${COLORS.dim}${JSON.stringify(data)}${COLORS.reset}` : ""}`);
|
|
91
|
+
if (logFilePath) {
|
|
92
|
+
const line = `${timestamp} ${label} [${category}] ${message}${dataStr}
|
|
93
|
+
`;
|
|
94
|
+
try {
|
|
95
|
+
appendFileSync(logFilePath, line);
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
var logger = {
|
|
100
|
+
debug: (category, message, data) => log("debug", category, message, data),
|
|
101
|
+
info: (category, message, data) => log("info", category, message, data),
|
|
102
|
+
warn: (category, message, data) => log("warn", category, message, data),
|
|
103
|
+
error: (category, message, data) => log("error", category, message, data)
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ../core/dist/worktree.js
|
|
107
|
+
var PREVIEW_PORT = STASH_PORT_START;
|
|
108
|
+
|
|
109
|
+
class WorktreeManager {
|
|
110
|
+
git;
|
|
111
|
+
projectPath;
|
|
112
|
+
worktrees = new Map;
|
|
113
|
+
previewPath = null;
|
|
114
|
+
constructor(projectPath) {
|
|
115
|
+
this.projectPath = projectPath;
|
|
116
|
+
this.git = simpleGit(projectPath);
|
|
117
|
+
this.cleanupStaleWorktrees();
|
|
118
|
+
}
|
|
119
|
+
async cleanupStaleWorktrees() {
|
|
120
|
+
const worktreesDir = join2(this.projectPath, ".stashes", "worktrees");
|
|
121
|
+
if (!existsSync2(worktreesDir))
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
const { readdirSync } = await import("fs");
|
|
125
|
+
const entries = readdirSync(worktreesDir);
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const worktreePath = join2(worktreesDir, entry);
|
|
128
|
+
logger.info("worktree", `cleaning up stale worktree: ${entry}`);
|
|
129
|
+
try {
|
|
130
|
+
await this.git.raw(["worktree", "remove", "--force", worktreePath]);
|
|
131
|
+
} catch {
|
|
132
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
await this.git.raw(["worktree", "prune"]);
|
|
136
|
+
logger.info("worktree", "stale worktree cleanup complete");
|
|
137
|
+
} catch (err) {
|
|
138
|
+
logger.warn("worktree", "stale worktree cleanup failed", {
|
|
139
|
+
error: err instanceof Error ? err.message : String(err)
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async createForGeneration(stashId) {
|
|
144
|
+
const branch = `stashes/${stashId}`;
|
|
145
|
+
const worktreePath = join2(this.projectPath, ".stashes", "worktrees", stashId);
|
|
146
|
+
if (existsSync2(worktreePath)) {
|
|
147
|
+
await this.removeGeneration(stashId);
|
|
148
|
+
}
|
|
149
|
+
logger.info("worktree", `creating generation: ${stashId}`, { branch, path: worktreePath });
|
|
150
|
+
await this.git.raw(["worktree", "add", "-b", branch, worktreePath]);
|
|
151
|
+
this.symlinkDeps(worktreePath);
|
|
152
|
+
const info = { path: worktreePath, branch };
|
|
153
|
+
this.worktrees.set(stashId, info);
|
|
154
|
+
logger.info("worktree", `created generation: ${stashId}`);
|
|
155
|
+
return info;
|
|
156
|
+
}
|
|
157
|
+
async createForVary(stashId, sourceBranch) {
|
|
158
|
+
const branch = `stashes/${stashId}`;
|
|
159
|
+
const worktreePath = join2(this.projectPath, ".stashes", "worktrees", stashId);
|
|
160
|
+
if (existsSync2(worktreePath)) {
|
|
161
|
+
await this.removeGeneration(stashId);
|
|
162
|
+
}
|
|
163
|
+
logger.info("worktree", `creating vary: ${stashId}`, { sourceBranch, branch });
|
|
164
|
+
await this.git.raw(["worktree", "add", "-b", branch, worktreePath, sourceBranch]);
|
|
165
|
+
this.symlinkDeps(worktreePath);
|
|
166
|
+
const info = { path: worktreePath, branch };
|
|
167
|
+
this.worktrees.set(stashId, info);
|
|
168
|
+
logger.info("worktree", `created vary: ${stashId}`);
|
|
169
|
+
return info;
|
|
170
|
+
}
|
|
171
|
+
async removeGeneration(stashId) {
|
|
172
|
+
const info = this.worktrees.get(stashId);
|
|
173
|
+
const worktreePath = info?.path ?? join2(this.projectPath, ".stashes", "worktrees", stashId);
|
|
174
|
+
logger.info("worktree", `removing generation worktree: ${stashId}`);
|
|
175
|
+
try {
|
|
176
|
+
await this.git.raw(["worktree", "remove", "--force", worktreePath]);
|
|
177
|
+
} catch {
|
|
178
|
+
if (existsSync2(worktreePath)) {
|
|
179
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.worktrees.delete(stashId);
|
|
183
|
+
}
|
|
184
|
+
async createPreview() {
|
|
185
|
+
const previewPath = join2(this.projectPath, ".stashes", "preview");
|
|
186
|
+
if (existsSync2(previewPath)) {
|
|
187
|
+
try {
|
|
188
|
+
await this.git.raw(["worktree", "remove", "--force", previewPath]);
|
|
189
|
+
} catch {
|
|
190
|
+
rmSync(previewPath, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
logger.info("worktree", "creating preview worktree");
|
|
194
|
+
await this.git.raw(["worktree", "add", "--detach", previewPath]);
|
|
195
|
+
this.symlinkDeps(previewPath);
|
|
196
|
+
this.previewPath = previewPath;
|
|
197
|
+
logger.info("worktree", "preview worktree created", { path: previewPath });
|
|
198
|
+
return previewPath;
|
|
199
|
+
}
|
|
200
|
+
async switchPreviewTo(stashId) {
|
|
201
|
+
if (!this.previewPath)
|
|
202
|
+
throw new Error("Preview worktree not created");
|
|
203
|
+
const branch = `stashes/${stashId}`;
|
|
204
|
+
logger.info("worktree", `switching preview to: ${branch}`);
|
|
205
|
+
const previewGit = simpleGit(this.previewPath);
|
|
206
|
+
try {
|
|
207
|
+
await previewGit.checkout(["-f", branch]);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
if (msg.includes("is already used by worktree")) {
|
|
211
|
+
logger.warn("worktree", `stale worktree blocking ${branch}, cleaning up`);
|
|
212
|
+
const staleDir = join2(this.projectPath, ".stashes", "worktrees", stashId);
|
|
213
|
+
try {
|
|
214
|
+
await this.git.raw(["worktree", "remove", "--force", staleDir]);
|
|
215
|
+
} catch {
|
|
216
|
+
if (existsSync2(staleDir)) {
|
|
217
|
+
rmSync(staleDir, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
await this.git.raw(["worktree", "prune"]);
|
|
221
|
+
await previewGit.checkout(["-f", branch]);
|
|
222
|
+
} else {
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
logger.info("worktree", `preview now on: ${branch}`);
|
|
227
|
+
}
|
|
228
|
+
getPreviewPath() {
|
|
229
|
+
return this.previewPath;
|
|
230
|
+
}
|
|
231
|
+
getPreviewPort() {
|
|
232
|
+
return PREVIEW_PORT;
|
|
233
|
+
}
|
|
234
|
+
async apply(stashId) {
|
|
235
|
+
const branch = `stashes/${stashId}`;
|
|
236
|
+
logger.info("worktree", `merging: ${stashId}`, { branch });
|
|
237
|
+
await this.git.merge([branch, "--no-ff", "-m", `stashes: apply stash ${stashId}`]);
|
|
238
|
+
logger.info("worktree", `merged: ${stashId}`);
|
|
239
|
+
}
|
|
240
|
+
async cleanupAll() {
|
|
241
|
+
logger.info("worktree", `cleaning up all`, { worktrees: this.worktrees.size, hasPreview: !!this.previewPath });
|
|
242
|
+
if (this.previewPath) {
|
|
243
|
+
try {
|
|
244
|
+
await this.git.raw(["worktree", "remove", "--force", this.previewPath]);
|
|
245
|
+
} catch {
|
|
246
|
+
if (existsSync2(this.previewPath)) {
|
|
247
|
+
rmSync(this.previewPath, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
this.previewPath = null;
|
|
251
|
+
}
|
|
252
|
+
const stashIds = [...this.worktrees.keys()];
|
|
253
|
+
for (const id of stashIds) {
|
|
254
|
+
await this.removeGeneration(id);
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const branches = await this.git.branch();
|
|
258
|
+
for (const branch of branches.all) {
|
|
259
|
+
if (branch.startsWith("stashes/")) {
|
|
260
|
+
try {
|
|
261
|
+
await this.git.raw(["branch", "-D", branch]);
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {}
|
|
266
|
+
const worktreesDir = join2(this.projectPath, ".stashes", "worktrees");
|
|
267
|
+
if (existsSync2(worktreesDir)) {
|
|
268
|
+
rmSync(worktreesDir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
const previewDir = join2(this.projectPath, ".stashes", "preview");
|
|
271
|
+
if (existsSync2(previewDir)) {
|
|
272
|
+
rmSync(previewDir, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
logger.info("worktree", `cleanup complete`);
|
|
275
|
+
}
|
|
276
|
+
symlinkDeps(worktreePath) {
|
|
277
|
+
const symlinks = ["node_modules", ".env", ".env.local"];
|
|
278
|
+
for (const name of symlinks) {
|
|
279
|
+
const source = join2(this.projectPath, name);
|
|
280
|
+
const target = join2(worktreePath, name);
|
|
281
|
+
if (existsSync2(source) && !existsSync2(target)) {
|
|
282
|
+
try {
|
|
283
|
+
symlinkSync(source, target);
|
|
284
|
+
} catch {}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ../core/dist/persistence.js
|
|
291
|
+
import { readFileSync, writeFileSync, mkdirSync as mkdirSync2, existsSync as existsSync3, rmSync as rmSync2 } from "fs";
|
|
292
|
+
import { join as join3, dirname } from "path";
|
|
293
|
+
var STASHES_DIR = ".stashes";
|
|
294
|
+
function ensureDir(dirPath) {
|
|
295
|
+
if (!existsSync3(dirPath)) {
|
|
296
|
+
mkdirSync2(dirPath, { recursive: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function readJson(filePath, fallback) {
|
|
300
|
+
if (!existsSync3(filePath))
|
|
301
|
+
return fallback;
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
304
|
+
} catch {
|
|
305
|
+
logger.warn("persistence", `failed to read ${filePath}, using fallback`);
|
|
306
|
+
return fallback;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function writeJson(filePath, data) {
|
|
310
|
+
ensureDir(dirname(filePath));
|
|
311
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
class PersistenceService {
|
|
315
|
+
basePath;
|
|
316
|
+
constructor(projectPath) {
|
|
317
|
+
this.basePath = join3(projectPath, STASHES_DIR);
|
|
318
|
+
ensureDir(this.basePath);
|
|
319
|
+
this.ensureGitignore(projectPath);
|
|
320
|
+
}
|
|
321
|
+
listProjects() {
|
|
322
|
+
return readJson(join3(this.basePath, "projects.json"), []);
|
|
323
|
+
}
|
|
324
|
+
getProject(projectId) {
|
|
325
|
+
return this.listProjects().find((p) => p.id === projectId);
|
|
326
|
+
}
|
|
327
|
+
saveProject(project) {
|
|
328
|
+
const projects = [...this.listProjects()];
|
|
329
|
+
const index = projects.findIndex((p) => p.id === project.id);
|
|
330
|
+
if (index >= 0) {
|
|
331
|
+
projects[index] = project;
|
|
332
|
+
} else {
|
|
333
|
+
projects.push(project);
|
|
334
|
+
}
|
|
335
|
+
writeJson(join3(this.basePath, "projects.json"), projects);
|
|
336
|
+
}
|
|
337
|
+
deleteProject(projectId) {
|
|
338
|
+
const projects = this.listProjects().filter((p) => p.id !== projectId);
|
|
339
|
+
writeJson(join3(this.basePath, "projects.json"), projects);
|
|
340
|
+
const projectDir = join3(this.basePath, "projects", projectId);
|
|
341
|
+
if (existsSync3(projectDir)) {
|
|
342
|
+
rmSync2(projectDir, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
listStashes(projectId) {
|
|
346
|
+
const filePath = join3(this.basePath, "projects", projectId, "stashes.json");
|
|
347
|
+
return readJson(filePath, []);
|
|
348
|
+
}
|
|
349
|
+
saveStash(stash) {
|
|
350
|
+
const stashes = [...this.listStashes(stash.projectId)];
|
|
351
|
+
const index = stashes.findIndex((s) => s.id === stash.id);
|
|
352
|
+
if (index >= 0) {
|
|
353
|
+
stashes[index] = stash;
|
|
354
|
+
} else {
|
|
355
|
+
stashes.push(stash);
|
|
356
|
+
}
|
|
357
|
+
const filePath = join3(this.basePath, "projects", stash.projectId, "stashes.json");
|
|
358
|
+
writeJson(filePath, stashes);
|
|
359
|
+
}
|
|
360
|
+
deleteStash(projectId, stashId) {
|
|
361
|
+
const stashes = this.listStashes(projectId).filter((s) => s.id !== stashId);
|
|
362
|
+
const filePath = join3(this.basePath, "projects", projectId, "stashes.json");
|
|
363
|
+
writeJson(filePath, stashes);
|
|
364
|
+
}
|
|
365
|
+
getChatHistory(projectId) {
|
|
366
|
+
const filePath = join3(this.basePath, "projects", projectId, "chat.json");
|
|
367
|
+
return readJson(filePath, []);
|
|
368
|
+
}
|
|
369
|
+
saveChatMessage(projectId, message) {
|
|
370
|
+
const messages = [...this.getChatHistory(projectId), message];
|
|
371
|
+
const filePath = join3(this.basePath, "projects", projectId, "chat.json");
|
|
372
|
+
writeJson(filePath, messages);
|
|
373
|
+
}
|
|
374
|
+
ensureGitignore(projectPath) {
|
|
375
|
+
const gitignorePath = join3(projectPath, ".gitignore");
|
|
376
|
+
if (existsSync3(gitignorePath)) {
|
|
377
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
378
|
+
if (!content.includes(".stashes/")) {
|
|
379
|
+
writeFileSync(gitignorePath, content.trimEnd() + `
|
|
380
|
+
.stashes/
|
|
381
|
+
`, "utf-8");
|
|
382
|
+
logger.info("persistence", "added .stashes/ to .gitignore");
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
writeFileSync(gitignorePath, `.stashes/
|
|
386
|
+
`, "utf-8");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ../core/dist/ai-process.js
|
|
392
|
+
var {spawn } = globalThis.Bun;
|
|
393
|
+
var CLAUDE_BIN = "/opt/homebrew/bin/claude";
|
|
394
|
+
var processes = new Map;
|
|
395
|
+
function startAiProcess(id, prompt, cwd) {
|
|
396
|
+
killAiProcess(id);
|
|
397
|
+
logger.info("claude", `spawning process: ${id}`, {
|
|
398
|
+
cwd,
|
|
399
|
+
promptLength: prompt.length,
|
|
400
|
+
promptPreview: prompt.substring(0, 100)
|
|
401
|
+
});
|
|
402
|
+
const proc = spawn({
|
|
403
|
+
cmd: [CLAUDE_BIN, "-p", prompt, "--output-format=stream-json", "--verbose", "--dangerously-skip-permissions"],
|
|
404
|
+
stdin: "ignore",
|
|
405
|
+
stdout: "pipe",
|
|
406
|
+
stderr: "pipe",
|
|
407
|
+
cwd,
|
|
408
|
+
env: { ...process.env }
|
|
409
|
+
});
|
|
410
|
+
proc.exited.then((code) => {
|
|
411
|
+
logger.info("claude", `process exited: ${id}`, { exitCode: code });
|
|
412
|
+
});
|
|
413
|
+
const aiProcess = { process: proc, id };
|
|
414
|
+
processes.set(id, aiProcess);
|
|
415
|
+
return aiProcess;
|
|
416
|
+
}
|
|
417
|
+
function killAiProcess(id) {
|
|
418
|
+
const existing = processes.get(id);
|
|
419
|
+
if (existing) {
|
|
420
|
+
logger.info("claude", `killing process: ${id}`);
|
|
421
|
+
try {
|
|
422
|
+
existing.process.kill();
|
|
423
|
+
} catch {}
|
|
424
|
+
processes.delete(id);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
async function* parseClaudeStream(proc) {
|
|
430
|
+
const stdout = proc.stdout;
|
|
431
|
+
if (!stdout || typeof stdout === "number") {
|
|
432
|
+
throw new Error("Process stdout is not a readable stream");
|
|
433
|
+
}
|
|
434
|
+
const reader = stdout.getReader();
|
|
435
|
+
const decoder = new TextDecoder;
|
|
436
|
+
let buffer = "";
|
|
437
|
+
let chunkCount = 0;
|
|
438
|
+
try {
|
|
439
|
+
while (true) {
|
|
440
|
+
const { done, value } = await reader.read();
|
|
441
|
+
if (done)
|
|
442
|
+
break;
|
|
443
|
+
buffer += decoder.decode(value, { stream: true });
|
|
444
|
+
const lines = buffer.split(`
|
|
445
|
+
`);
|
|
446
|
+
buffer = lines.pop() || "";
|
|
447
|
+
for (const line of lines) {
|
|
448
|
+
if (!line.trim())
|
|
449
|
+
continue;
|
|
450
|
+
let parsed;
|
|
451
|
+
try {
|
|
452
|
+
parsed = JSON.parse(line);
|
|
453
|
+
} catch {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (parsed.type === "assistant" && parsed.message) {
|
|
457
|
+
const message = parsed.message;
|
|
458
|
+
for (const block of message.content || []) {
|
|
459
|
+
chunkCount++;
|
|
460
|
+
if (block.type === "text" && block.text) {
|
|
461
|
+
yield { type: "text", content: block.text };
|
|
462
|
+
} else if (block.type === "thinking" && block.thinking) {
|
|
463
|
+
yield { type: "thinking", content: block.thinking };
|
|
464
|
+
} else if (block.type === "tool_use") {
|
|
465
|
+
logger.debug("claude", `tool_use: ${block.name}`);
|
|
466
|
+
yield { type: "tool_use", content: JSON.stringify({ tool: block.name, input: block.input }) };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
logger.debug("claude", `stream ended`, { totalChunks: chunkCount });
|
|
474
|
+
reader.releaseLock();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ../core/dist/screenshot.js
|
|
479
|
+
var {spawn: spawn2 } = globalThis.Bun;
|
|
480
|
+
import { join as join4 } from "path";
|
|
481
|
+
import { mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
|
|
482
|
+
var SCREENSHOTS_DIR = ".stashes/screenshots";
|
|
483
|
+
async function captureScreenshot(port, projectPath, stashId) {
|
|
484
|
+
const screenshotsDir = join4(projectPath, SCREENSHOTS_DIR);
|
|
485
|
+
if (!existsSync4(screenshotsDir)) {
|
|
486
|
+
mkdirSync3(screenshotsDir, { recursive: true });
|
|
487
|
+
}
|
|
488
|
+
const filename = `${stashId}.png`;
|
|
489
|
+
const outputPath = join4(screenshotsDir, filename);
|
|
490
|
+
const playwrightScript = `
|
|
491
|
+
const { chromium } = require('playwright');
|
|
492
|
+
(async () => {
|
|
493
|
+
const browser = await chromium.launch({ headless: true });
|
|
494
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
495
|
+
try {
|
|
496
|
+
await page.goto('http://localhost:${port}', { waitUntil: 'networkidle', timeout: 20000 });
|
|
497
|
+
await page.waitForTimeout(2000);
|
|
498
|
+
} catch(e) {
|
|
499
|
+
await page.goto('http://localhost:${port}', { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
500
|
+
await page.waitForTimeout(3000);
|
|
501
|
+
}
|
|
502
|
+
await page.screenshot({ path: '${outputPath.replace(/'/g, "\\'")}', type: 'png' });
|
|
503
|
+
await browser.close();
|
|
504
|
+
})();
|
|
505
|
+
`;
|
|
506
|
+
const proc = spawn2({
|
|
507
|
+
cmd: ["node", "-e", playwrightScript],
|
|
508
|
+
stdin: "ignore",
|
|
509
|
+
stdout: "pipe",
|
|
510
|
+
stderr: "pipe"
|
|
511
|
+
});
|
|
512
|
+
const exitCode = await proc.exited;
|
|
513
|
+
if (exitCode !== 0) {
|
|
514
|
+
const stderr = await new Response(proc.stderr).text();
|
|
515
|
+
throw new Error(`Screenshot failed: ${stderr.substring(0, 200)}`);
|
|
516
|
+
}
|
|
517
|
+
return outputPath;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ../core/dist/prompt.js
|
|
521
|
+
function buildStashPrompt(component, sourceCode, userPrompt, directive) {
|
|
522
|
+
const parts = [
|
|
523
|
+
"You are modifying a UI component in an existing project.",
|
|
524
|
+
"",
|
|
525
|
+
"## Component",
|
|
526
|
+
`- Name: ${component.name}`,
|
|
527
|
+
`- File: ${component.filePath}`,
|
|
528
|
+
`- DOM: ${component.domSelector}`
|
|
529
|
+
];
|
|
530
|
+
if (sourceCode) {
|
|
531
|
+
parts.push("", "## Current Source Code", "```", sourceCode, "```");
|
|
532
|
+
} else {
|
|
533
|
+
parts.push("", "## HTML Snippet", "```html", component.htmlSnippet || "N/A", "```", "", "Find and modify the component file that renders this section.");
|
|
534
|
+
}
|
|
535
|
+
parts.push("", "## User Request", userPrompt, "", "## Creative Direction", directive, "", "## Rules", "- ONLY modify files related to this component and its direct children", "- Keep all existing functionality, routing, state management, and API calls intact", "- Respect the existing design system", "- Ensure the result is production-ready, accessible, and responsive", "- Do not modify test files, configuration files, or unrelated components");
|
|
536
|
+
return parts.join(`
|
|
537
|
+
`);
|
|
538
|
+
}
|
|
539
|
+
function buildFreeformStashPrompt(userPrompt, directive, filePath, sourceCode) {
|
|
540
|
+
const parts = [
|
|
541
|
+
"You are modifying the UI of an existing project."
|
|
542
|
+
];
|
|
543
|
+
if (filePath && sourceCode) {
|
|
544
|
+
parts.push("", "## Target File", `File: ${filePath}`, "", "## Current Source Code", "```", sourceCode, "```");
|
|
545
|
+
} else if (filePath) {
|
|
546
|
+
parts.push("", `## Target File`, `Focus your changes on: ${filePath}`);
|
|
547
|
+
}
|
|
548
|
+
parts.push("", "## User Request", userPrompt, "", "## Creative Direction", directive, "", "## Rules", "- Keep all existing functionality, routing, state management, and API calls intact", "- Respect the existing design system", "- Ensure the result is production-ready, accessible, and responsive", "- Do not modify test files, configuration files, or unrelated components");
|
|
549
|
+
return parts.join(`
|
|
550
|
+
`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ../core/dist/generation.js
|
|
554
|
+
function emit(onProgress, event) {
|
|
555
|
+
if (onProgress)
|
|
556
|
+
onProgress(event);
|
|
557
|
+
}
|
|
558
|
+
async function waitForPort(port, timeout) {
|
|
559
|
+
const start = Date.now();
|
|
560
|
+
while (Date.now() - start < timeout) {
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
563
|
+
if (res.ok || res.status < 500)
|
|
564
|
+
return;
|
|
565
|
+
} catch {}
|
|
566
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
567
|
+
}
|
|
568
|
+
throw new Error(`Port ${port} not ready within ${timeout}ms`);
|
|
569
|
+
}
|
|
570
|
+
async function captureEphemeralScreenshot(worktreePath, projectPath, stashId, port) {
|
|
571
|
+
const devServer = spawn3({
|
|
572
|
+
cmd: ["npm", "run", "dev", "--", "--port", String(port)],
|
|
573
|
+
cwd: worktreePath,
|
|
574
|
+
stdin: "ignore",
|
|
575
|
+
stdout: "pipe",
|
|
576
|
+
stderr: "pipe",
|
|
577
|
+
env: { ...process.env, PORT: String(port) }
|
|
578
|
+
});
|
|
579
|
+
try {
|
|
580
|
+
await waitForPort(port, 60000);
|
|
581
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
582
|
+
return await captureScreenshot(port, projectPath, stashId);
|
|
583
|
+
} finally {
|
|
584
|
+
devServer.kill();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function allocatePort() {
|
|
588
|
+
for (let port = 4010;port <= 4030; port++) {
|
|
589
|
+
try {
|
|
590
|
+
await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(500) });
|
|
591
|
+
} catch {
|
|
592
|
+
return port;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
throw new Error("No available ports in range 4010-4030");
|
|
596
|
+
}
|
|
597
|
+
async function generate(opts) {
|
|
598
|
+
const { projectPath, projectId, prompt, component, count = DEFAULT_STASH_COUNT, directives = DEFAULT_DIRECTIVES, onProgress } = opts;
|
|
599
|
+
const worktreeManager = new WorktreeManager(projectPath);
|
|
600
|
+
const persistence = new PersistenceService(projectPath);
|
|
601
|
+
const selectedDirectives = directives.slice(0, count);
|
|
602
|
+
let sourceCode = "";
|
|
603
|
+
if (component?.filePath) {
|
|
604
|
+
const sourceFile = join5(projectPath, component.filePath);
|
|
605
|
+
if (existsSync5(sourceFile)) {
|
|
606
|
+
sourceCode = readFileSync2(sourceFile, "utf-8");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const completedStashes = [];
|
|
610
|
+
const stashPromises = selectedDirectives.map(async (directive) => {
|
|
611
|
+
const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
|
|
612
|
+
const worktree = await worktreeManager.createForGeneration(stashId);
|
|
613
|
+
const stash = {
|
|
614
|
+
id: stashId,
|
|
615
|
+
projectId,
|
|
616
|
+
prompt,
|
|
617
|
+
componentPath: component?.filePath,
|
|
618
|
+
branch: worktree.branch,
|
|
619
|
+
worktreePath: worktree.path,
|
|
620
|
+
port: null,
|
|
621
|
+
screenshotUrl: null,
|
|
622
|
+
status: "generating",
|
|
623
|
+
error: null,
|
|
624
|
+
relatedTo: [],
|
|
625
|
+
createdAt: new Date().toISOString()
|
|
626
|
+
};
|
|
627
|
+
persistence.saveStash(stash);
|
|
628
|
+
emit(onProgress, { type: "generating", stashId });
|
|
629
|
+
let stashPrompt;
|
|
630
|
+
if (component?.filePath) {
|
|
631
|
+
stashPrompt = buildStashPrompt({ name: component.exportName || component.filePath, filePath: component.filePath, domSelector: "" }, sourceCode, prompt, directive);
|
|
632
|
+
} else {
|
|
633
|
+
stashPrompt = buildFreeformStashPrompt(prompt, directive);
|
|
634
|
+
}
|
|
635
|
+
const aiProcess = startAiProcess(stashId, stashPrompt, worktree.path);
|
|
636
|
+
try {
|
|
637
|
+
for await (const chunk of parseClaudeStream(aiProcess.process)) {
|
|
638
|
+
emit(onProgress, {
|
|
639
|
+
type: "ai_stream",
|
|
640
|
+
stashId,
|
|
641
|
+
content: chunk.content,
|
|
642
|
+
streamType: chunk.type
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
await aiProcess.process.exited;
|
|
646
|
+
const wtGit = simpleGit2(worktree.path);
|
|
647
|
+
try {
|
|
648
|
+
await wtGit.add("-A");
|
|
649
|
+
await wtGit.commit(`stashes: stash ${stashId}`);
|
|
650
|
+
} catch {}
|
|
651
|
+
await worktreeManager.removeGeneration(stashId);
|
|
652
|
+
const readyStash = { ...stash, status: "ready" };
|
|
653
|
+
completedStashes.push(readyStash);
|
|
654
|
+
persistence.saveStash(readyStash);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
657
|
+
persistence.saveStash({ ...stash, status: "error", error: errorMsg });
|
|
658
|
+
emit(onProgress, { type: "error", stashId, error: errorMsg });
|
|
659
|
+
} finally {
|
|
660
|
+
killAiProcess(stashId);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
await Promise.all(stashPromises);
|
|
664
|
+
for (const stash of completedStashes) {
|
|
665
|
+
emit(onProgress, { type: "screenshotting", stashId: stash.id });
|
|
666
|
+
try {
|
|
667
|
+
const port = await allocatePort();
|
|
668
|
+
const worktree = await worktreeManager.createForGeneration(`screenshot-${stash.id}`);
|
|
669
|
+
const screenshotGit = simpleGit2(worktree.path);
|
|
670
|
+
await screenshotGit.checkout(["-f", stash.branch]);
|
|
671
|
+
const screenshotPath = await captureEphemeralScreenshot(worktree.path, projectPath, stash.id, port);
|
|
672
|
+
await worktreeManager.removeGeneration(`screenshot-${stash.id}`);
|
|
673
|
+
const updatedStash = { ...stash, screenshotUrl: screenshotPath };
|
|
674
|
+
persistence.saveStash(updatedStash);
|
|
675
|
+
const idx = completedStashes.indexOf(stash);
|
|
676
|
+
completedStashes[idx] = updatedStash;
|
|
677
|
+
emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath });
|
|
678
|
+
} catch (err) {
|
|
679
|
+
logger.error("generation", `screenshot failed for ${stash.id}`, {
|
|
680
|
+
error: err instanceof Error ? err.message : String(err)
|
|
681
|
+
});
|
|
682
|
+
emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath: "" });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return completedStashes;
|
|
686
|
+
}
|
|
687
|
+
// ../core/dist/vary.js
|
|
688
|
+
var {spawn: spawn4 } = globalThis.Bun;
|
|
689
|
+
import simpleGit3 from "simple-git";
|
|
690
|
+
function emit2(onProgress, event) {
|
|
691
|
+
if (onProgress)
|
|
692
|
+
onProgress(event);
|
|
693
|
+
}
|
|
694
|
+
async function waitForPort2(port, timeout) {
|
|
695
|
+
const start = Date.now();
|
|
696
|
+
while (Date.now() - start < timeout) {
|
|
697
|
+
try {
|
|
698
|
+
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
699
|
+
if (res.ok || res.status < 500)
|
|
700
|
+
return;
|
|
701
|
+
} catch {}
|
|
702
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
703
|
+
}
|
|
704
|
+
throw new Error(`Port ${port} not ready within ${timeout}ms`);
|
|
705
|
+
}
|
|
706
|
+
async function allocatePort2() {
|
|
707
|
+
for (let port = 4010;port <= 4030; port++) {
|
|
708
|
+
try {
|
|
709
|
+
await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(500) });
|
|
710
|
+
} catch {
|
|
711
|
+
return port;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
throw new Error("No available ports in range 4010-4030");
|
|
715
|
+
}
|
|
716
|
+
async function vary(opts) {
|
|
717
|
+
const { projectPath, sourceStashId, prompt, onProgress } = opts;
|
|
718
|
+
const persistence = new PersistenceService(projectPath);
|
|
719
|
+
const worktreeManager = new WorktreeManager(projectPath);
|
|
720
|
+
let sourceStash;
|
|
721
|
+
let projectId = "";
|
|
722
|
+
for (const project of persistence.listProjects()) {
|
|
723
|
+
const stashes = persistence.listStashes(project.id);
|
|
724
|
+
sourceStash = stashes.find((s) => s.id === sourceStashId);
|
|
725
|
+
if (sourceStash) {
|
|
726
|
+
projectId = project.id;
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (!sourceStash)
|
|
731
|
+
throw new Error(`Source stash ${sourceStashId} not found`);
|
|
732
|
+
const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
|
|
733
|
+
const worktree = await worktreeManager.createForVary(stashId, sourceStash.branch);
|
|
734
|
+
const stash = {
|
|
735
|
+
id: stashId,
|
|
736
|
+
projectId,
|
|
737
|
+
prompt,
|
|
738
|
+
componentPath: sourceStash.componentPath,
|
|
739
|
+
branch: worktree.branch,
|
|
740
|
+
worktreePath: worktree.path,
|
|
741
|
+
port: null,
|
|
742
|
+
screenshotUrl: null,
|
|
743
|
+
status: "generating",
|
|
744
|
+
error: null,
|
|
745
|
+
relatedTo: [sourceStashId],
|
|
746
|
+
createdAt: new Date().toISOString()
|
|
747
|
+
};
|
|
748
|
+
persistence.saveStash(stash);
|
|
749
|
+
emit2(onProgress, { type: "generating", stashId });
|
|
750
|
+
const varyPrompt = `The user wants to vary the current UI. Apply this change: ${prompt}`;
|
|
751
|
+
const aiProcess = startAiProcess(stashId, varyPrompt, worktree.path);
|
|
752
|
+
try {
|
|
753
|
+
for await (const chunk of parseClaudeStream(aiProcess.process)) {
|
|
754
|
+
emit2(onProgress, {
|
|
755
|
+
type: "ai_stream",
|
|
756
|
+
stashId,
|
|
757
|
+
content: chunk.content,
|
|
758
|
+
streamType: chunk.type
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
await aiProcess.process.exited;
|
|
762
|
+
const wtGit = simpleGit3(worktree.path);
|
|
763
|
+
try {
|
|
764
|
+
await wtGit.add("-A");
|
|
765
|
+
await wtGit.commit(`stashes: vary ${stashId} from ${sourceStashId}`);
|
|
766
|
+
} catch {}
|
|
767
|
+
await worktreeManager.removeGeneration(stashId);
|
|
768
|
+
emit2(onProgress, { type: "screenshotting", stashId });
|
|
769
|
+
let screenshotPath = "";
|
|
770
|
+
try {
|
|
771
|
+
const port = await allocatePort2();
|
|
772
|
+
const screenshotWorktree = await worktreeManager.createForGeneration(`screenshot-${stashId}`);
|
|
773
|
+
const screenshotGit = simpleGit3(screenshotWorktree.path);
|
|
774
|
+
await screenshotGit.checkout(["-f", stash.branch]);
|
|
775
|
+
const devServer = spawn4({
|
|
776
|
+
cmd: ["npm", "run", "dev", "--", "--port", String(port)],
|
|
777
|
+
cwd: screenshotWorktree.path,
|
|
778
|
+
stdin: "ignore",
|
|
779
|
+
stdout: "pipe",
|
|
780
|
+
stderr: "pipe",
|
|
781
|
+
env: { ...process.env, PORT: String(port) }
|
|
782
|
+
});
|
|
783
|
+
try {
|
|
784
|
+
await waitForPort2(port, 60000);
|
|
785
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
786
|
+
screenshotPath = await captureScreenshot(port, projectPath, stashId);
|
|
787
|
+
} finally {
|
|
788
|
+
devServer.kill();
|
|
789
|
+
}
|
|
790
|
+
await worktreeManager.removeGeneration(`screenshot-${stashId}`);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
logger.error("vary", `screenshot failed for ${stashId}`, {
|
|
793
|
+
error: err instanceof Error ? err.message : String(err)
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
const readyStash = { ...stash, status: "ready", screenshotUrl: screenshotPath || null };
|
|
797
|
+
persistence.saveStash(readyStash);
|
|
798
|
+
emit2(onProgress, { type: "ready", stashId, screenshotPath });
|
|
799
|
+
return readyStash;
|
|
800
|
+
} catch (error) {
|
|
801
|
+
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
802
|
+
const errorStash = { ...stash, status: "error", error: errorMsg };
|
|
803
|
+
persistence.saveStash(errorStash);
|
|
804
|
+
emit2(onProgress, { type: "error", stashId, error: errorMsg });
|
|
805
|
+
return errorStash;
|
|
806
|
+
} finally {
|
|
807
|
+
killAiProcess(stashId);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// ../core/dist/apply.js
|
|
811
|
+
async function apply(opts) {
|
|
812
|
+
const { projectPath, stashId } = opts;
|
|
813
|
+
logger.info("apply", `applying stash: ${stashId}`);
|
|
814
|
+
const worktreeManager = new WorktreeManager(projectPath);
|
|
815
|
+
await worktreeManager.apply(stashId);
|
|
816
|
+
await worktreeManager.cleanupAll();
|
|
817
|
+
logger.info("apply", `stash ${stashId} applied and worktrees cleaned up`);
|
|
818
|
+
}
|
|
819
|
+
// ../core/dist/manage.js
|
|
820
|
+
import simpleGit4 from "simple-git";
|
|
821
|
+
async function list(projectPath) {
|
|
822
|
+
const persistence = new PersistenceService(projectPath);
|
|
823
|
+
const projects = persistence.listProjects();
|
|
824
|
+
const allStashes = [];
|
|
825
|
+
for (const project of projects) {
|
|
826
|
+
const stashes = persistence.listStashes(project.id);
|
|
827
|
+
allStashes.push(...stashes);
|
|
828
|
+
}
|
|
829
|
+
return allStashes;
|
|
830
|
+
}
|
|
831
|
+
async function remove(projectPath, stashId) {
|
|
832
|
+
const persistence = new PersistenceService(projectPath);
|
|
833
|
+
for (const project of persistence.listProjects()) {
|
|
834
|
+
const stashes = persistence.listStashes(project.id);
|
|
835
|
+
if (stashes.some((s) => s.id === stashId)) {
|
|
836
|
+
persistence.deleteStash(project.id, stashId);
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
const git = simpleGit4(projectPath);
|
|
842
|
+
await git.raw(["branch", "-D", `stashes/${stashId}`]);
|
|
843
|
+
} catch {}
|
|
844
|
+
logger.info("manage", `removed stash: ${stashId}`);
|
|
845
|
+
}
|
|
846
|
+
// ../mcp/src/tools/generate.ts
|
|
847
|
+
var generateParams = {
|
|
848
|
+
prompt: z.string().describe('What UI changes to generate (e.g. "make the hero section bolder")'),
|
|
849
|
+
count: z.number().min(1).max(5).optional().describe("Number of stashes to generate (1-5, default 3)"),
|
|
850
|
+
filePath: z.string().optional().describe('Optional: scope changes to a specific file (e.g. "src/components/Hero.tsx")'),
|
|
851
|
+
exportName: z.string().optional().describe("Optional: specific component export name within the file")
|
|
852
|
+
};
|
|
853
|
+
async function handleGenerate(args, projectPath) {
|
|
854
|
+
const { prompt, filePath, exportName } = args;
|
|
855
|
+
const count = args.count ?? 3;
|
|
856
|
+
initLogFile(projectPath);
|
|
857
|
+
const persistence = new PersistenceService(projectPath);
|
|
858
|
+
let projects = persistence.listProjects();
|
|
859
|
+
let project;
|
|
860
|
+
if (projects.length > 0) {
|
|
861
|
+
project = projects[0];
|
|
862
|
+
} else {
|
|
863
|
+
project = {
|
|
864
|
+
id: `proj_${crypto.randomUUID().substring(0, 8)}`,
|
|
865
|
+
name: "Default",
|
|
866
|
+
createdAt: new Date().toISOString(),
|
|
867
|
+
updatedAt: new Date().toISOString()
|
|
868
|
+
};
|
|
869
|
+
persistence.saveProject(project);
|
|
870
|
+
}
|
|
871
|
+
const stashes = await generate({
|
|
872
|
+
projectPath,
|
|
873
|
+
projectId: project.id,
|
|
874
|
+
prompt,
|
|
875
|
+
count,
|
|
876
|
+
component: filePath ? { filePath, exportName } : undefined,
|
|
877
|
+
onProgress: () => {}
|
|
878
|
+
});
|
|
879
|
+
return {
|
|
880
|
+
content: [{
|
|
881
|
+
type: "text",
|
|
882
|
+
text: JSON.stringify({
|
|
883
|
+
message: `Generated ${stashes.length} stashes`,
|
|
884
|
+
stashes: stashes.map((s) => ({
|
|
885
|
+
id: s.id,
|
|
886
|
+
status: s.status,
|
|
887
|
+
branch: s.branch,
|
|
888
|
+
screenshotPath: s.screenshotUrl
|
|
889
|
+
})),
|
|
890
|
+
hint: "Use stashes_list to see all stashes, stashes_browse to interact live, or stashes_apply to merge one."
|
|
891
|
+
}, null, 2)
|
|
892
|
+
}]
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ../mcp/src/tools/list.ts
|
|
897
|
+
var listParams = {};
|
|
898
|
+
async function handleList(args, projectPath) {
|
|
899
|
+
initLogFile(projectPath);
|
|
900
|
+
const stashes = await list(projectPath);
|
|
901
|
+
if (stashes.length === 0) {
|
|
902
|
+
return {
|
|
903
|
+
content: [{ type: "text", text: "No stashes found. Use stashes_generate to create some." }]
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
return {
|
|
907
|
+
content: [{
|
|
908
|
+
type: "text",
|
|
909
|
+
text: JSON.stringify({
|
|
910
|
+
stashes: stashes.map((s) => ({
|
|
911
|
+
id: s.id,
|
|
912
|
+
prompt: s.prompt,
|
|
913
|
+
status: s.status,
|
|
914
|
+
branch: s.branch,
|
|
915
|
+
screenshotPath: s.screenshotUrl,
|
|
916
|
+
createdAt: s.createdAt
|
|
917
|
+
}))
|
|
918
|
+
}, null, 2)
|
|
919
|
+
}]
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ../mcp/src/tools/apply.ts
|
|
924
|
+
import { z as z2 } from "zod";
|
|
925
|
+
var applyParams = {
|
|
926
|
+
stashId: z2.string().describe('The stash ID to apply (e.g. "stash_a1b2c3d4")')
|
|
927
|
+
};
|
|
928
|
+
async function handleApply(args, projectPath) {
|
|
929
|
+
const { stashId } = args;
|
|
930
|
+
initLogFile(projectPath);
|
|
931
|
+
await apply({ projectPath, stashId });
|
|
932
|
+
return {
|
|
933
|
+
content: [{
|
|
934
|
+
type: "text",
|
|
935
|
+
text: `Stash ${stashId} merged into current branch. All worktrees cleaned up.`
|
|
936
|
+
}]
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ../mcp/src/tools/vary.ts
|
|
941
|
+
import { z as z3 } from "zod";
|
|
942
|
+
var varyParams = {
|
|
943
|
+
stashId: z3.string().describe("The source stash ID to vary from"),
|
|
944
|
+
prompt: z3.string().describe("What changes to apply on top of the source stash")
|
|
945
|
+
};
|
|
946
|
+
async function handleVary(args, projectPath) {
|
|
947
|
+
const { stashId, prompt } = args;
|
|
948
|
+
initLogFile(projectPath);
|
|
949
|
+
const stash = await vary({
|
|
950
|
+
projectPath,
|
|
951
|
+
sourceStashId: stashId,
|
|
952
|
+
prompt
|
|
953
|
+
});
|
|
954
|
+
return {
|
|
955
|
+
content: [{
|
|
956
|
+
type: "text",
|
|
957
|
+
text: JSON.stringify({
|
|
958
|
+
message: `Created variation ${stash.id} from ${stashId}`,
|
|
959
|
+
stash: {
|
|
960
|
+
id: stash.id,
|
|
961
|
+
status: stash.status,
|
|
962
|
+
branch: stash.branch,
|
|
963
|
+
screenshotPath: stash.screenshotUrl
|
|
964
|
+
}
|
|
965
|
+
}, null, 2)
|
|
966
|
+
}]
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ../mcp/src/tools/remove.ts
|
|
971
|
+
import { z as z4 } from "zod";
|
|
972
|
+
var removeParams = {
|
|
973
|
+
stashId: z4.string().describe("The stash ID to remove")
|
|
974
|
+
};
|
|
975
|
+
async function handleRemove(args, projectPath) {
|
|
976
|
+
const { stashId } = args;
|
|
977
|
+
initLogFile(projectPath);
|
|
978
|
+
await remove(projectPath, stashId);
|
|
979
|
+
return {
|
|
980
|
+
content: [{ type: "text", text: `Stash ${stashId} removed.` }]
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ../server/dist/index.js
|
|
985
|
+
import { Hono as Hono2 } from "hono";
|
|
986
|
+
import { cors } from "hono/cors";
|
|
987
|
+
import { join as join8, dirname as dirname2 } from "path";
|
|
988
|
+
import { fileURLToPath } from "url";
|
|
989
|
+
import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
|
|
990
|
+
|
|
991
|
+
// ../server/dist/routes/api.js
|
|
992
|
+
import { Hono } from "hono";
|
|
993
|
+
import { join as join6 } from "path";
|
|
994
|
+
import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
|
|
995
|
+
var app = new Hono;
|
|
996
|
+
app.get("/health", (c) => c.json({ status: "ok", service: "stashes" }));
|
|
997
|
+
app.get("/projects", (c) => {
|
|
998
|
+
const persistence = getPersistence();
|
|
999
|
+
const projects = persistence.listProjects();
|
|
1000
|
+
const projectsWithCounts = projects.map((p) => ({
|
|
1001
|
+
...p,
|
|
1002
|
+
stashCount: persistence.listStashes(p.id).length,
|
|
1003
|
+
recentScreenshots: persistence.listStashes(p.id).filter((s) => s.screenshotUrl).slice(-4).map((s) => s.screenshotUrl)
|
|
1004
|
+
}));
|
|
1005
|
+
return c.json({ data: projectsWithCounts });
|
|
1006
|
+
});
|
|
1007
|
+
app.post("/projects", async (c) => {
|
|
1008
|
+
const { name, description } = await c.req.json();
|
|
1009
|
+
const project = {
|
|
1010
|
+
id: `proj_${crypto.randomUUID().substring(0, 8)}`,
|
|
1011
|
+
name,
|
|
1012
|
+
description,
|
|
1013
|
+
createdAt: new Date().toISOString(),
|
|
1014
|
+
updatedAt: new Date().toISOString()
|
|
1015
|
+
};
|
|
1016
|
+
getPersistence().saveProject(project);
|
|
1017
|
+
return c.json({ data: project }, 201);
|
|
1018
|
+
});
|
|
1019
|
+
app.get("/projects/:id", (c) => {
|
|
1020
|
+
const persistence = getPersistence();
|
|
1021
|
+
const project = persistence.getProject(c.req.param("id"));
|
|
1022
|
+
if (!project)
|
|
1023
|
+
return c.json({ error: "Project not found" }, 404);
|
|
1024
|
+
const stashes = persistence.listStashes(project.id);
|
|
1025
|
+
const chat = persistence.getChatHistory(project.id);
|
|
1026
|
+
return c.json({ data: { ...project, stashes, chat } });
|
|
1027
|
+
});
|
|
1028
|
+
app.delete("/projects/:id", (c) => {
|
|
1029
|
+
const id = c.req.param("id");
|
|
1030
|
+
getPersistence().deleteProject(id);
|
|
1031
|
+
return c.json({ data: { deleted: id } });
|
|
1032
|
+
});
|
|
1033
|
+
app.get("/screenshots/:filename", (c) => {
|
|
1034
|
+
const filename = c.req.param("filename");
|
|
1035
|
+
const filePath = join6(serverState.projectPath, ".stashes", "screenshots", filename);
|
|
1036
|
+
if (!existsSync6(filePath))
|
|
1037
|
+
return c.json({ error: "Not found" }, 404);
|
|
1038
|
+
const content = readFileSync3(filePath);
|
|
1039
|
+
return new Response(content, {
|
|
1040
|
+
headers: { "content-type": "image/png", "cache-control": "no-cache" }
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
var apiRoutes = app;
|
|
1044
|
+
|
|
1045
|
+
// ../server/dist/services/stash-service.js
|
|
1046
|
+
import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
|
|
1047
|
+
import { join as join7 } from "path";
|
|
1048
|
+
class StashService {
|
|
1049
|
+
projectPath;
|
|
1050
|
+
worktreeManager;
|
|
1051
|
+
persistence;
|
|
1052
|
+
broadcast;
|
|
1053
|
+
selectedComponent = null;
|
|
1054
|
+
previewServer = null;
|
|
1055
|
+
activePreviewStashId = null;
|
|
1056
|
+
constructor(projectPath, worktreeManager, persistence, broadcast) {
|
|
1057
|
+
this.projectPath = projectPath;
|
|
1058
|
+
this.worktreeManager = worktreeManager;
|
|
1059
|
+
this.persistence = persistence;
|
|
1060
|
+
this.broadcast = broadcast;
|
|
1061
|
+
}
|
|
1062
|
+
setSelectedComponent(component) {
|
|
1063
|
+
this.selectedComponent = component;
|
|
1064
|
+
if (component.filePath === "auto-detect") {
|
|
1065
|
+
this.resolveComponentFile(component);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async resolveComponentFile(component) {
|
|
1069
|
+
const prompt = [
|
|
1070
|
+
"I need you to identify which source file renders a specific UI section.",
|
|
1071
|
+
"Look at this HTML snippet from the running app:",
|
|
1072
|
+
"",
|
|
1073
|
+
"```html",
|
|
1074
|
+
component.htmlSnippet || component.domSelector,
|
|
1075
|
+
"```",
|
|
1076
|
+
"",
|
|
1077
|
+
`The element is a <${component.tag || "div"}> with description: ${component.name}`,
|
|
1078
|
+
`DOM selector: ${component.domSelector}`,
|
|
1079
|
+
"",
|
|
1080
|
+
"Search the src/ directory and find the component file that renders this section.",
|
|
1081
|
+
"Reply with ONLY the file path relative to the project root."
|
|
1082
|
+
].join(`
|
|
1083
|
+
`);
|
|
1084
|
+
const aiProcess = startAiProcess("resolve-component", prompt, this.projectPath);
|
|
1085
|
+
let resolvedPath = "";
|
|
1086
|
+
try {
|
|
1087
|
+
for await (const chunk of parseClaudeStream(aiProcess.process)) {
|
|
1088
|
+
if (chunk.type === "text")
|
|
1089
|
+
resolvedPath += chunk.content;
|
|
1090
|
+
}
|
|
1091
|
+
await aiProcess.process.exited;
|
|
1092
|
+
} finally {
|
|
1093
|
+
killAiProcess("resolve-component");
|
|
1094
|
+
}
|
|
1095
|
+
const match = resolvedPath.match(/(?:src\/[^\s\n"`']+\.(?:tsx|ts|jsx|js))/);
|
|
1096
|
+
if (match) {
|
|
1097
|
+
this.selectedComponent = { ...component, filePath: match[0] };
|
|
1098
|
+
this.broadcast({
|
|
1099
|
+
type: "ai_stream",
|
|
1100
|
+
content: `Identified component: ${match[0]}`,
|
|
1101
|
+
streamType: "text",
|
|
1102
|
+
source: "system"
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
async chat(projectId, message) {
|
|
1107
|
+
const component = this.selectedComponent;
|
|
1108
|
+
let sourceCode = "";
|
|
1109
|
+
const filePath = component?.filePath || "";
|
|
1110
|
+
if (filePath && filePath !== "auto-detect") {
|
|
1111
|
+
const sourceFile = join7(this.projectPath, filePath);
|
|
1112
|
+
if (existsSync7(sourceFile)) {
|
|
1113
|
+
sourceCode = readFileSync4(sourceFile, "utf-8");
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
const chatPrompt = [
|
|
1117
|
+
"The user is asking about a UI component in their project. Answer concisely.",
|
|
1118
|
+
"Do NOT modify any files.",
|
|
1119
|
+
"",
|
|
1120
|
+
component ? `Component: ${component.name}` : "",
|
|
1121
|
+
filePath !== "auto-detect" ? `File: ${filePath}` : "",
|
|
1122
|
+
sourceCode ? `
|
|
1123
|
+
Source:
|
|
1124
|
+
\`\`\`
|
|
1125
|
+
${sourceCode.substring(0, 3000)}
|
|
1126
|
+
\`\`\`` : "",
|
|
1127
|
+
"",
|
|
1128
|
+
`User question: ${message}`
|
|
1129
|
+
].filter(Boolean).join(`
|
|
1130
|
+
`);
|
|
1131
|
+
const aiProcess = startAiProcess("chat", chatPrompt, this.projectPath);
|
|
1132
|
+
let fullResponse = "";
|
|
1133
|
+
try {
|
|
1134
|
+
for await (const chunk of parseClaudeStream(aiProcess.process)) {
|
|
1135
|
+
if (chunk.type === "text") {
|
|
1136
|
+
fullResponse += chunk.content;
|
|
1137
|
+
this.broadcast({ type: "ai_stream", content: chunk.content, streamType: "text", source: "chat" });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
await aiProcess.process.exited;
|
|
1141
|
+
if (fullResponse) {
|
|
1142
|
+
this.persistence.saveChatMessage(projectId, {
|
|
1143
|
+
id: crypto.randomUUID(),
|
|
1144
|
+
role: "assistant",
|
|
1145
|
+
content: fullResponse,
|
|
1146
|
+
type: "text",
|
|
1147
|
+
createdAt: new Date().toISOString()
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
this.broadcast({
|
|
1152
|
+
type: "ai_stream",
|
|
1153
|
+
content: `Chat error: ${err instanceof Error ? err.message : String(err)}`,
|
|
1154
|
+
streamType: "text",
|
|
1155
|
+
source: "chat"
|
|
1156
|
+
});
|
|
1157
|
+
} finally {
|
|
1158
|
+
killAiProcess("chat");
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
progressToBroadcast(event) {
|
|
1162
|
+
switch (event.type) {
|
|
1163
|
+
case "generating":
|
|
1164
|
+
case "screenshotting":
|
|
1165
|
+
case "ready":
|
|
1166
|
+
this.broadcast({ type: "stash:status", stashId: event.stashId, status: event.type === "ready" ? "ready" : event.type });
|
|
1167
|
+
if (event.type === "ready" && "screenshotPath" in event && event.screenshotPath) {
|
|
1168
|
+
this.broadcast({ type: "stash:screenshot", stashId: event.stashId, url: `/api/screenshots/${event.stashId}.png` });
|
|
1169
|
+
}
|
|
1170
|
+
break;
|
|
1171
|
+
case "error":
|
|
1172
|
+
this.broadcast({ type: "stash:error", stashId: event.stashId, error: event.error });
|
|
1173
|
+
break;
|
|
1174
|
+
case "ai_stream":
|
|
1175
|
+
this.broadcast({ type: "ai_stream", content: event.content, streamType: event.streamType });
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT) {
|
|
1180
|
+
if (!this.selectedComponent) {
|
|
1181
|
+
throw new Error("No component selected");
|
|
1182
|
+
}
|
|
1183
|
+
await generate({
|
|
1184
|
+
projectPath: this.projectPath,
|
|
1185
|
+
projectId,
|
|
1186
|
+
prompt,
|
|
1187
|
+
component: {
|
|
1188
|
+
filePath: this.selectedComponent.filePath,
|
|
1189
|
+
exportName: this.selectedComponent.name
|
|
1190
|
+
},
|
|
1191
|
+
count: stashCount,
|
|
1192
|
+
onProgress: (event) => this.progressToBroadcast(event)
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
async vary(sourceStashId, prompt) {
|
|
1196
|
+
await vary({
|
|
1197
|
+
projectPath: this.projectPath,
|
|
1198
|
+
sourceStashId,
|
|
1199
|
+
prompt,
|
|
1200
|
+
onProgress: (event) => this.progressToBroadcast(event)
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
async switchPreview(stashId) {
|
|
1204
|
+
const previewPort = this.worktreeManager.getPreviewPort();
|
|
1205
|
+
let previewPath = this.worktreeManager.getPreviewPath();
|
|
1206
|
+
if (!previewPath) {
|
|
1207
|
+
previewPath = await this.worktreeManager.createPreview();
|
|
1208
|
+
}
|
|
1209
|
+
await this.ensurePreviewServer(previewPath);
|
|
1210
|
+
if (this.activePreviewStashId === stashId) {
|
|
1211
|
+
this.broadcast({ type: "stash:port", stashId, port: previewPort });
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
await this.worktreeManager.switchPreviewTo(stashId);
|
|
1215
|
+
this.activePreviewStashId = stashId;
|
|
1216
|
+
await this.waitForRecompile(previewPort, 15000);
|
|
1217
|
+
this.broadcast({ type: "stash:port", stashId, port: previewPort });
|
|
1218
|
+
}
|
|
1219
|
+
async applyStash(stashId) {
|
|
1220
|
+
this.stopPreviewServer();
|
|
1221
|
+
await apply({ projectPath: this.projectPath, stashId });
|
|
1222
|
+
this.activePreviewStashId = null;
|
|
1223
|
+
this.broadcast({ type: "stash:applied", stashId });
|
|
1224
|
+
}
|
|
1225
|
+
async deleteStash(stashId) {
|
|
1226
|
+
if (this.activePreviewStashId === stashId) {
|
|
1227
|
+
this.activePreviewStashId = null;
|
|
1228
|
+
}
|
|
1229
|
+
await remove(this.projectPath, stashId);
|
|
1230
|
+
}
|
|
1231
|
+
async ensurePreviewServer(previewPath) {
|
|
1232
|
+
if (this.previewServer) {
|
|
1233
|
+
const port = this.worktreeManager.getPreviewPort();
|
|
1234
|
+
try {
|
|
1235
|
+
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
1236
|
+
if (res.ok || res.status < 500)
|
|
1237
|
+
return;
|
|
1238
|
+
} catch {
|
|
1239
|
+
this.previewServer = null;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
await this.startPreviewServer(previewPath);
|
|
1243
|
+
}
|
|
1244
|
+
async startPreviewServer(previewPath) {
|
|
1245
|
+
const port = this.worktreeManager.getPreviewPort();
|
|
1246
|
+
this.previewServer = Bun.spawn({
|
|
1247
|
+
cmd: ["npm", "run", "dev", "--", "--port", String(port)],
|
|
1248
|
+
cwd: previewPath,
|
|
1249
|
+
stdin: "ignore",
|
|
1250
|
+
stdout: "pipe",
|
|
1251
|
+
stderr: "pipe",
|
|
1252
|
+
env: { ...process.env, PORT: String(port) }
|
|
1253
|
+
});
|
|
1254
|
+
await this.waitForPort(port, 60000);
|
|
1255
|
+
}
|
|
1256
|
+
stopPreviewServer() {
|
|
1257
|
+
if (this.previewServer) {
|
|
1258
|
+
this.previewServer.kill();
|
|
1259
|
+
this.previewServer = null;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
async waitForRecompile(port, timeout) {
|
|
1263
|
+
const start = Date.now();
|
|
1264
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1265
|
+
while (Date.now() - start < timeout) {
|
|
1266
|
+
try {
|
|
1267
|
+
const res = await fetch(`http://localhost:${port}`, {
|
|
1268
|
+
signal: AbortSignal.timeout(3000),
|
|
1269
|
+
headers: { "cache-control": "no-cache" }
|
|
1270
|
+
});
|
|
1271
|
+
if (res.ok) {
|
|
1272
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
} catch {}
|
|
1276
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
async waitForPort(port, timeout) {
|
|
1280
|
+
const start = Date.now();
|
|
1281
|
+
while (Date.now() - start < timeout) {
|
|
1282
|
+
try {
|
|
1283
|
+
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
1284
|
+
if (res.ok || res.status < 500)
|
|
1285
|
+
return;
|
|
1286
|
+
} catch {}
|
|
1287
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1288
|
+
}
|
|
1289
|
+
throw new Error(`Port ${port} not ready within ${timeout}ms`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ../server/dist/services/websocket.js
|
|
1294
|
+
var worktreeManager;
|
|
1295
|
+
var stashService;
|
|
1296
|
+
var persistence;
|
|
1297
|
+
var clients = new Set;
|
|
1298
|
+
function broadcast(event) {
|
|
1299
|
+
const data = JSON.stringify(event);
|
|
1300
|
+
for (const client of clients) {
|
|
1301
|
+
client.send(data);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function getPersistenceFromWs() {
|
|
1305
|
+
return persistence;
|
|
1306
|
+
}
|
|
1307
|
+
function createWebSocketHandler(projectPath, userDevPort) {
|
|
1308
|
+
worktreeManager = new WorktreeManager(projectPath);
|
|
1309
|
+
persistence = new PersistenceService(projectPath);
|
|
1310
|
+
stashService = new StashService(projectPath, worktreeManager, persistence, broadcast);
|
|
1311
|
+
return {
|
|
1312
|
+
open(ws) {
|
|
1313
|
+
clients.add(ws);
|
|
1314
|
+
logger.info("ws", "client connected", { total: clients.size });
|
|
1315
|
+
ws.send(JSON.stringify({ type: "server_ready", port: userDevPort }));
|
|
1316
|
+
},
|
|
1317
|
+
async message(ws, message) {
|
|
1318
|
+
const raw = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
1319
|
+
let event;
|
|
1320
|
+
try {
|
|
1321
|
+
const parsed = JSON.parse(raw);
|
|
1322
|
+
if (!parsed.type)
|
|
1323
|
+
return;
|
|
1324
|
+
event = parsed;
|
|
1325
|
+
} catch {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
logger.info("ws", `received: ${event.type}`);
|
|
1329
|
+
try {
|
|
1330
|
+
switch (event.type) {
|
|
1331
|
+
case "select_component":
|
|
1332
|
+
stashService.setSelectedComponent(event.component);
|
|
1333
|
+
break;
|
|
1334
|
+
case "chat":
|
|
1335
|
+
persistence.saveChatMessage(event.projectId, {
|
|
1336
|
+
id: crypto.randomUUID(),
|
|
1337
|
+
role: "user",
|
|
1338
|
+
content: event.message,
|
|
1339
|
+
type: "text",
|
|
1340
|
+
createdAt: new Date().toISOString()
|
|
1341
|
+
});
|
|
1342
|
+
await stashService.chat(event.projectId, event.message);
|
|
1343
|
+
break;
|
|
1344
|
+
case "generate":
|
|
1345
|
+
persistence.saveChatMessage(event.projectId, {
|
|
1346
|
+
id: crypto.randomUUID(),
|
|
1347
|
+
role: "user",
|
|
1348
|
+
content: event.prompt,
|
|
1349
|
+
type: "text",
|
|
1350
|
+
createdAt: new Date().toISOString()
|
|
1351
|
+
});
|
|
1352
|
+
await stashService.generate(event.projectId, event.prompt, event.stashCount);
|
|
1353
|
+
break;
|
|
1354
|
+
case "vary":
|
|
1355
|
+
await stashService.vary(event.sourceStashId, event.prompt);
|
|
1356
|
+
break;
|
|
1357
|
+
case "interact":
|
|
1358
|
+
await stashService.switchPreview(event.stashId);
|
|
1359
|
+
break;
|
|
1360
|
+
case "apply_stash":
|
|
1361
|
+
await stashService.applyStash(event.stashId);
|
|
1362
|
+
break;
|
|
1363
|
+
case "delete_stash":
|
|
1364
|
+
await stashService.deleteStash(event.stashId);
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
logger.error("ws", `handler failed for ${event.type}`, {
|
|
1369
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
close(ws) {
|
|
1374
|
+
clients.delete(ws);
|
|
1375
|
+
logger.info("ws", "client disconnected", { remaining: clients.size });
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ../server/dist/index.js
|
|
1381
|
+
var serverState = {
|
|
1382
|
+
projectPath: "",
|
|
1383
|
+
userDevPort: 3000
|
|
1384
|
+
};
|
|
1385
|
+
function getPersistence() {
|
|
1386
|
+
return getPersistenceFromWs();
|
|
1387
|
+
}
|
|
1388
|
+
var app2 = new Hono2;
|
|
1389
|
+
app2.use("/*", cors());
|
|
1390
|
+
app2.route("/api", apiRoutes);
|
|
1391
|
+
async function proxyToUserApp(c, targetPath, injectOverlay = false) {
|
|
1392
|
+
const userDevPort = serverState.userDevPort;
|
|
1393
|
+
const url = new URL(c.req.url);
|
|
1394
|
+
const targetUrl = `http://localhost:${userDevPort}${targetPath}${url.search}`;
|
|
1395
|
+
try {
|
|
1396
|
+
const headers = new Headers;
|
|
1397
|
+
for (const [key, value] of Object.entries(c.req.header())) {
|
|
1398
|
+
if (!["host", "accept-encoding"].includes(key.toLowerCase()) && value) {
|
|
1399
|
+
headers.set(key, value);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
headers.set("host", `localhost:${userDevPort}`);
|
|
1403
|
+
const response = await fetch(targetUrl, {
|
|
1404
|
+
method: c.req.method,
|
|
1405
|
+
headers,
|
|
1406
|
+
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.arrayBuffer() : undefined,
|
|
1407
|
+
redirect: "manual"
|
|
1408
|
+
});
|
|
1409
|
+
const contentType = response.headers.get("content-type") || "";
|
|
1410
|
+
if (injectOverlay && contentType.includes("text/html")) {
|
|
1411
|
+
const html = await response.text();
|
|
1412
|
+
const injectedHtml = injectOverlayScript(html);
|
|
1413
|
+
const respHeaders2 = new Headers(response.headers);
|
|
1414
|
+
respHeaders2.delete("content-encoding");
|
|
1415
|
+
respHeaders2.delete("content-length");
|
|
1416
|
+
return new Response(injectedHtml, {
|
|
1417
|
+
status: response.status,
|
|
1418
|
+
headers: respHeaders2
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
const respHeaders = new Headers(response.headers);
|
|
1422
|
+
respHeaders.delete("content-encoding");
|
|
1423
|
+
return new Response(response.body, {
|
|
1424
|
+
status: response.status,
|
|
1425
|
+
headers: respHeaders
|
|
1426
|
+
});
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
|
|
1429
|
+
status: 502,
|
|
1430
|
+
headers: { "content-type": "application/json" }
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
app2.all("/app/*", (c) => {
|
|
1435
|
+
const path = c.req.path.replace(/^\/app/, "") || "/";
|
|
1436
|
+
return proxyToUserApp(c, path, true);
|
|
1437
|
+
});
|
|
1438
|
+
app2.all("/_next/*", (c) => proxyToUserApp(c, c.req.path));
|
|
1439
|
+
app2.all("/__nextjs*", (c) => proxyToUserApp(c, c.req.path));
|
|
1440
|
+
app2.all("/__next*", (c) => proxyToUserApp(c, c.req.path));
|
|
1441
|
+
app2.get("/*", async (c) => {
|
|
1442
|
+
const path = c.req.path;
|
|
1443
|
+
const selfDir = dirname2(fileURLToPath(import.meta.url));
|
|
1444
|
+
const bundledWebDir = join8(selfDir, "web");
|
|
1445
|
+
const monorepoWebDir = join8(selfDir, "../../web/dist");
|
|
1446
|
+
const webDistDir = existsSync8(join8(bundledWebDir, "index.html")) ? bundledWebDir : monorepoWebDir;
|
|
1447
|
+
const requestPath = path === "/" ? "/index.html" : path;
|
|
1448
|
+
const filePath = join8(webDistDir, requestPath);
|
|
1449
|
+
if (existsSync8(filePath) && !filePath.includes("..")) {
|
|
1450
|
+
const content = readFileSync5(filePath);
|
|
1451
|
+
const ext = filePath.split(".").pop() || "";
|
|
1452
|
+
const contentTypes = {
|
|
1453
|
+
html: "text/html; charset=utf-8",
|
|
1454
|
+
js: "application/javascript",
|
|
1455
|
+
css: "text/css",
|
|
1456
|
+
json: "application/json",
|
|
1457
|
+
png: "image/png",
|
|
1458
|
+
jpg: "image/jpeg",
|
|
1459
|
+
svg: "image/svg+xml",
|
|
1460
|
+
ico: "image/x-icon",
|
|
1461
|
+
woff: "font/woff",
|
|
1462
|
+
woff2: "font/woff2"
|
|
1463
|
+
};
|
|
1464
|
+
return new Response(content, {
|
|
1465
|
+
headers: { "content-type": contentTypes[ext] || "application/octet-stream" }
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
const indexPath = join8(webDistDir, "index.html");
|
|
1469
|
+
if (existsSync8(indexPath)) {
|
|
1470
|
+
const html = readFileSync5(indexPath, "utf-8");
|
|
1471
|
+
return new Response(html, {
|
|
1472
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
return new Response(JSON.stringify({ error: "Web UI not built. Run: bun run build" }), {
|
|
1476
|
+
status: 404,
|
|
1477
|
+
headers: { "content-type": "application/json" }
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
1480
|
+
function startServer(projectPath, userDevPort, port = STASHES_PORT) {
|
|
1481
|
+
serverState = { projectPath, userDevPort };
|
|
1482
|
+
initLogFile(projectPath);
|
|
1483
|
+
const wsHandler = createWebSocketHandler(projectPath, userDevPort);
|
|
1484
|
+
const server = Bun.serve({
|
|
1485
|
+
port,
|
|
1486
|
+
fetch(req, server2) {
|
|
1487
|
+
if (req.headers.get("upgrade") === "websocket") {
|
|
1488
|
+
const success = server2.upgrade(req, {
|
|
1489
|
+
data: { projectPath, userDevPort }
|
|
1490
|
+
});
|
|
1491
|
+
if (success)
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
return app2.fetch(req);
|
|
1495
|
+
},
|
|
1496
|
+
websocket: wsHandler
|
|
1497
|
+
});
|
|
1498
|
+
logger.info("server", `Stashes running at http://localhost:${port}`);
|
|
1499
|
+
logger.info("server", `Proxying user app from http://localhost:${userDevPort}`);
|
|
1500
|
+
logger.info("server", `Project: ${projectPath}`);
|
|
1501
|
+
return server;
|
|
1502
|
+
}
|
|
1503
|
+
function injectOverlayScript(html) {
|
|
1504
|
+
const overlayScript = `
|
|
1505
|
+
<script data-stashes-overlay>
|
|
1506
|
+
(function() {
|
|
1507
|
+
var highlightOverlay = null;
|
|
1508
|
+
var pickerEnabled = false;
|
|
1509
|
+
var precisionMode = false;
|
|
1510
|
+
|
|
1511
|
+
function createOverlay() {
|
|
1512
|
+
var overlay = document.createElement('div');
|
|
1513
|
+
overlay.id = 'stashes-highlight';
|
|
1514
|
+
overlay.style.cssText = 'position:fixed;pointer-events:none;border:2px solid #6366f1;background:rgba(99,102,241,0.1);z-index:99999;transition:all 0.1s ease;display:none;border-radius:4px;';
|
|
1515
|
+
var tooltip = document.createElement('div');
|
|
1516
|
+
tooltip.id = 'stashes-tooltip';
|
|
1517
|
+
tooltip.style.cssText = 'position:fixed;background:#1e1b4b;color:#e0e7ff;padding:4px 10px;border-radius:6px;font-size:11px;font-family:ui-monospace,monospace;z-index:100000;pointer-events:none;display:none;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-width:400px;overflow:hidden;text-overflow:ellipsis;';
|
|
1518
|
+
document.body.appendChild(overlay);
|
|
1519
|
+
document.body.appendChild(tooltip);
|
|
1520
|
+
return overlay;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
var SEMANTIC_TAGS = ['header','nav','main','section','article','aside','footer','form','dialog'];
|
|
1524
|
+
|
|
1525
|
+
function findTarget(el, precise) {
|
|
1526
|
+
if (precise) return el;
|
|
1527
|
+
var current = el;
|
|
1528
|
+
var best = el;
|
|
1529
|
+
while (current && current !== document.body) {
|
|
1530
|
+
var tag = current.tagName.toLowerCase();
|
|
1531
|
+
if (SEMANTIC_TAGS.indexOf(tag) !== -1) { best = current; break; }
|
|
1532
|
+
if (current.id) { best = current; break; }
|
|
1533
|
+
if (current.getAttribute('role')) { best = current; break; }
|
|
1534
|
+
if (current.getAttribute('data-testid')) { best = current; break; }
|
|
1535
|
+
if (current.children && current.children.length > 1 && current.getBoundingClientRect().height > 50) {
|
|
1536
|
+
best = current;
|
|
1537
|
+
}
|
|
1538
|
+
current = current.parentElement;
|
|
1539
|
+
}
|
|
1540
|
+
return best;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function describeElement(el) {
|
|
1544
|
+
var tag = el.tagName.toLowerCase();
|
|
1545
|
+
var id = el.id ? '#' + el.id : '';
|
|
1546
|
+
var role = el.getAttribute('role') || '';
|
|
1547
|
+
var testId = el.getAttribute('data-testid') || '';
|
|
1548
|
+
var cls = (el.className && typeof el.className === 'string') ? el.className.trim().split(/[ ]+/).slice(0, 2).join('.') : '';
|
|
1549
|
+
var text = (el.textContent || '').trim().substring(0, 40);
|
|
1550
|
+
var label = tag;
|
|
1551
|
+
if (SEMANTIC_TAGS.indexOf(tag) !== -1) label = tag.toUpperCase();
|
|
1552
|
+
if (id) label += ' ' + id;
|
|
1553
|
+
else if (cls) label += '.' + cls;
|
|
1554
|
+
if (role) label += ' [' + role + ']';
|
|
1555
|
+
if (testId) label += ' [' + testId + ']';
|
|
1556
|
+
if (!id && !cls && text) label += ' "' + text.substring(0, 25) + '"';
|
|
1557
|
+
return label;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function generateSelector(el) {
|
|
1561
|
+
if (el.id) return '#' + el.id;
|
|
1562
|
+
var parts = [];
|
|
1563
|
+
var current = el;
|
|
1564
|
+
var depth = 0;
|
|
1565
|
+
while (current && current !== document.body && depth < 5) {
|
|
1566
|
+
var sel = current.tagName.toLowerCase();
|
|
1567
|
+
if (current.id) { parts.unshift('#' + current.id); break; }
|
|
1568
|
+
if (current.className && typeof current.className === 'string') {
|
|
1569
|
+
var c = current.className.trim().split(/[ ]+/).slice(0, 2).join('.');
|
|
1570
|
+
if (c) sel += '.' + c;
|
|
1571
|
+
}
|
|
1572
|
+
parts.unshift(sel);
|
|
1573
|
+
current = current.parentElement;
|
|
1574
|
+
depth++;
|
|
1575
|
+
}
|
|
1576
|
+
return parts.join(' > ');
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function onMouseMove(e) {
|
|
1580
|
+
if (!pickerEnabled) return;
|
|
1581
|
+
if (!highlightOverlay) highlightOverlay = createOverlay();
|
|
1582
|
+
precisionMode = e.shiftKey;
|
|
1583
|
+
var target = findTarget(e.target, precisionMode);
|
|
1584
|
+
var overlay = document.getElementById('stashes-highlight');
|
|
1585
|
+
var tooltip = document.getElementById('stashes-tooltip');
|
|
1586
|
+
if (target) {
|
|
1587
|
+
var rect = target.getBoundingClientRect();
|
|
1588
|
+
overlay.style.display = 'block';
|
|
1589
|
+
overlay.style.top = rect.top + 'px';
|
|
1590
|
+
overlay.style.left = rect.left + 'px';
|
|
1591
|
+
overlay.style.width = rect.width + 'px';
|
|
1592
|
+
overlay.style.height = rect.height + 'px';
|
|
1593
|
+
overlay.style.borderColor = precisionMode ? '#f59e0b' : '#6366f1';
|
|
1594
|
+
tooltip.style.display = 'block';
|
|
1595
|
+
tooltip.style.top = Math.max(0, rect.top - 30) + 'px';
|
|
1596
|
+
tooltip.style.left = Math.max(0, rect.left) + 'px';
|
|
1597
|
+
tooltip.textContent = (precisionMode ? '[precise] ' : '') + describeElement(target);
|
|
1598
|
+
} else {
|
|
1599
|
+
overlay.style.display = 'none';
|
|
1600
|
+
tooltip.style.display = 'none';
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function onClick(e) {
|
|
1605
|
+
if (!pickerEnabled) return;
|
|
1606
|
+
e.preventDefault();
|
|
1607
|
+
e.stopPropagation();
|
|
1608
|
+
var target = findTarget(e.target, e.shiftKey);
|
|
1609
|
+
if (target) {
|
|
1610
|
+
var desc = describeElement(target);
|
|
1611
|
+
var selector = generateSelector(target);
|
|
1612
|
+
var tag = target.tagName.toLowerCase();
|
|
1613
|
+
var outerSnippet = target.outerHTML.substring(0, 500);
|
|
1614
|
+
window.parent.postMessage({
|
|
1615
|
+
type: 'stashes:component_selected',
|
|
1616
|
+
component: {
|
|
1617
|
+
name: desc,
|
|
1618
|
+
filePath: 'auto-detect',
|
|
1619
|
+
domSelector: selector,
|
|
1620
|
+
htmlSnippet: outerSnippet,
|
|
1621
|
+
tag: tag
|
|
1622
|
+
}
|
|
1623
|
+
}, '*');
|
|
1624
|
+
var overlay = document.getElementById('stashes-highlight');
|
|
1625
|
+
if (overlay) {
|
|
1626
|
+
overlay.style.borderColor = '#22c55e';
|
|
1627
|
+
overlay.style.background = 'rgba(34,197,94,0.1)';
|
|
1628
|
+
setTimeout(function() {
|
|
1629
|
+
overlay.style.borderColor = '#6366f1';
|
|
1630
|
+
overlay.style.background = 'rgba(99,102,241,0.1)';
|
|
1631
|
+
}, 500);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
window.addEventListener('message', function(e) {
|
|
1637
|
+
if (e.data && e.data.type === 'stashes:toggle_picker') {
|
|
1638
|
+
pickerEnabled = e.data.enabled;
|
|
1639
|
+
if (!pickerEnabled) {
|
|
1640
|
+
var ov = document.getElementById('stashes-highlight');
|
|
1641
|
+
var tp = document.getElementById('stashes-tooltip');
|
|
1642
|
+
if (ov) ov.style.display = 'none';
|
|
1643
|
+
if (tp) tp.style.display = 'none';
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
document.addEventListener('click', function(e) {
|
|
1649
|
+
if (pickerEnabled) return;
|
|
1650
|
+
var link = e.target;
|
|
1651
|
+
while (link && link.tagName !== 'A') link = link.parentElement;
|
|
1652
|
+
if (link && link.href) {
|
|
1653
|
+
var url;
|
|
1654
|
+
try { url = new URL(link.href); } catch(_) { return; }
|
|
1655
|
+
if (url.origin === window.location.origin) {
|
|
1656
|
+
var path = url.pathname;
|
|
1657
|
+
if (!path.startsWith('/app/')) {
|
|
1658
|
+
e.preventDefault();
|
|
1659
|
+
window.location.href = '/app' + path + url.search + url.hash;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}, false);
|
|
1664
|
+
|
|
1665
|
+
document.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
1666
|
+
document.addEventListener('click', onClick, true);
|
|
1667
|
+
})();
|
|
1668
|
+
</script>`;
|
|
1669
|
+
if (html.includes("</body>")) {
|
|
1670
|
+
return html.replace("</body>", () => overlayScript + `
|
|
1671
|
+
</body>`);
|
|
1672
|
+
}
|
|
1673
|
+
return html + overlayScript;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// ../mcp/src/tools/browse.ts
|
|
1677
|
+
import open from "open";
|
|
1678
|
+
|
|
1679
|
+
// ../server/dist/services/detector.js
|
|
1680
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
|
|
1681
|
+
import { join as join9 } from "path";
|
|
1682
|
+
function detectFramework(projectPath) {
|
|
1683
|
+
const packageJsonPath = join9(projectPath, "package.json");
|
|
1684
|
+
if (!existsSync9(packageJsonPath)) {
|
|
1685
|
+
return {
|
|
1686
|
+
framework: "unknown",
|
|
1687
|
+
devCommand: "npm run dev",
|
|
1688
|
+
devPort: 3000,
|
|
1689
|
+
configFile: null
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
const packageJson = JSON.parse(readFileSync6(packageJsonPath, "utf-8"));
|
|
1693
|
+
const deps = {
|
|
1694
|
+
...packageJson.dependencies,
|
|
1695
|
+
...packageJson.devDependencies
|
|
1696
|
+
};
|
|
1697
|
+
if (deps["next"]) {
|
|
1698
|
+
return {
|
|
1699
|
+
framework: "nextjs",
|
|
1700
|
+
devCommand: getDevCommand(packageJson, "next dev"),
|
|
1701
|
+
devPort: 3000,
|
|
1702
|
+
configFile: findConfig(projectPath, ["next.config.ts", "next.config.mjs", "next.config.js"])
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
if (deps["@remix-run/react"]) {
|
|
1706
|
+
return {
|
|
1707
|
+
framework: "remix",
|
|
1708
|
+
devCommand: getDevCommand(packageJson, "remix dev"),
|
|
1709
|
+
devPort: 3000,
|
|
1710
|
+
configFile: findConfig(projectPath, ["remix.config.ts", "remix.config.js"])
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
if (deps["vite"] && deps["react"]) {
|
|
1714
|
+
return {
|
|
1715
|
+
framework: "vite-react",
|
|
1716
|
+
devCommand: getDevCommand(packageJson, "vite"),
|
|
1717
|
+
devPort: 5173,
|
|
1718
|
+
configFile: findConfig(projectPath, ["vite.config.ts", "vite.config.js"])
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
if (deps["react-scripts"]) {
|
|
1722
|
+
return {
|
|
1723
|
+
framework: "cra",
|
|
1724
|
+
devCommand: getDevCommand(packageJson, "react-scripts start"),
|
|
1725
|
+
devPort: 3000,
|
|
1726
|
+
configFile: null
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
return {
|
|
1730
|
+
framework: "unknown",
|
|
1731
|
+
devCommand: getDevCommand(packageJson, "npm run dev"),
|
|
1732
|
+
devPort: 3000,
|
|
1733
|
+
configFile: null
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
function getDevCommand(packageJson, fallback) {
|
|
1737
|
+
const scripts = packageJson.scripts;
|
|
1738
|
+
if (scripts?.dev)
|
|
1739
|
+
return "bun run dev";
|
|
1740
|
+
if (scripts?.start)
|
|
1741
|
+
return "bun run start";
|
|
1742
|
+
return fallback;
|
|
1743
|
+
}
|
|
1744
|
+
function findConfig(projectPath, candidates) {
|
|
1745
|
+
for (const candidate of candidates) {
|
|
1746
|
+
if (existsSync9(join9(projectPath, candidate))) {
|
|
1747
|
+
return candidate;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// ../mcp/src/tools/browse.ts
|
|
1754
|
+
var browseParams = {};
|
|
1755
|
+
async function handleBrowse(args, projectPath) {
|
|
1756
|
+
const detected = detectFramework(projectPath);
|
|
1757
|
+
const port = STASHES_PORT;
|
|
1758
|
+
startServer(projectPath, detected.devPort, port);
|
|
1759
|
+
await open(`http://localhost:${port}`);
|
|
1760
|
+
return {
|
|
1761
|
+
content: [{
|
|
1762
|
+
type: "text",
|
|
1763
|
+
text: `Stashes server started at http://localhost:${port}. Opened in browser.`
|
|
1764
|
+
}]
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// ../mcp/src/index.ts
|
|
1769
|
+
var projectPath = process.cwd();
|
|
1770
|
+
var server = new McpServer({
|
|
1771
|
+
name: "stashes",
|
|
1772
|
+
version: "0.1.0"
|
|
1773
|
+
});
|
|
1774
|
+
server.tool("stashes_generate", "Generate multiple AI-powered UI design explorations (stashes) for a given prompt. Each stash applies a different creative direction.", generateParams, async (args) => handleGenerate(args, projectPath));
|
|
1775
|
+
server.tool("stashes_list", "List all existing stashes in the current project. Shows ID, prompt, status, branch, and screenshot path.", listParams, async (args) => handleList(args, projectPath));
|
|
1776
|
+
server.tool("stashes_apply", "Merge a stash branch into the current git branch. Applies the AI-generated UI changes and cleans up all worktrees.", applyParams, async (args) => handleApply(args, projectPath));
|
|
1777
|
+
server.tool("stashes_vary", "Create a variation of an existing stash by applying additional changes on top of it.", varyParams, async (args) => handleVary(args, projectPath));
|
|
1778
|
+
server.tool("stashes_remove", "Delete a stash \u2014 removes its persistence entry and git branch.", removeParams, async (args) => handleRemove(args, projectPath));
|
|
1779
|
+
server.tool("stashes_browse", "Start the Stashes web server and open the browser for interactive browsing of generated stashes.", browseParams, async (args) => handleBrowse(args, projectPath));
|
|
1780
|
+
var transport = new StdioServerTransport;
|
|
1781
|
+
await server.connect(transport);
|