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