stashes 0.1.7 → 0.1.9
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 +395 -163
- package/dist/mcp.js +395 -163
- package/dist/web/assets/index-8mMmnYX5.js +62 -0
- package/dist/web/assets/index-BEubPa-l.css +1 -0
- package/dist/web/assets/index-BMBumaom.js +62 -0
- package/dist/web/assets/index-BzwYXF3-.js +62 -0
- package/dist/web/assets/index-DBsH8rVY.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -37,7 +37,12 @@ import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
|
|
|
37
37
|
// ../shared/dist/constants/index.js
|
|
38
38
|
var STASHES_PORT = 4000;
|
|
39
39
|
var DEFAULT_STASH_COUNT = 3;
|
|
40
|
+
var APP_PROXY_PORT = STASHES_PORT + 1;
|
|
40
41
|
var STASH_PORT_START = 4010;
|
|
42
|
+
var STASH_PORT_END = 4030;
|
|
43
|
+
var MAX_PREVIEW_SERVERS = 5;
|
|
44
|
+
var PREVIEW_TTL_MS = 300000;
|
|
45
|
+
var PREVIEW_REAPER_INTERVAL = 30000;
|
|
41
46
|
var DEFAULT_DIRECTIVES = [
|
|
42
47
|
"Minimal and clean \u2014 reduce visual noise, emphasize whitespace, limit to 2-3 colors, typography-driven hierarchy",
|
|
43
48
|
"Bold and expressive \u2014 strong visual identity, use motion/animation, rich interactions, distinctive layout",
|
|
@@ -295,6 +300,34 @@ class WorktreeManager {
|
|
|
295
300
|
getPreviewPort() {
|
|
296
301
|
return PREVIEW_PORT;
|
|
297
302
|
}
|
|
303
|
+
async createPreviewForPool(stashId) {
|
|
304
|
+
const previewPath = join3(this.projectPath, ".stashes", "previews", stashId);
|
|
305
|
+
const branch = `stashes/${stashId}`;
|
|
306
|
+
if (existsSync3(previewPath)) {
|
|
307
|
+
try {
|
|
308
|
+
await this.git.raw(["worktree", "remove", "--force", previewPath]);
|
|
309
|
+
} catch {
|
|
310
|
+
rmSync(previewPath, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
await this.git.raw(["worktree", "prune"]);
|
|
313
|
+
}
|
|
314
|
+
logger.info("worktree", `creating pool preview: ${stashId}`, { branch, path: previewPath });
|
|
315
|
+
await this.git.raw(["worktree", "add", previewPath, branch]);
|
|
316
|
+
this.symlinkDeps(previewPath);
|
|
317
|
+
logger.info("worktree", `pool preview created: ${stashId}`);
|
|
318
|
+
return previewPath;
|
|
319
|
+
}
|
|
320
|
+
async removePreviewForPool(stashId) {
|
|
321
|
+
const previewPath = join3(this.projectPath, ".stashes", "previews", stashId);
|
|
322
|
+
logger.info("worktree", `removing pool preview: ${stashId}`);
|
|
323
|
+
try {
|
|
324
|
+
await this.git.raw(["worktree", "remove", "--force", previewPath]);
|
|
325
|
+
} catch {
|
|
326
|
+
if (existsSync3(previewPath)) {
|
|
327
|
+
rmSync(previewPath, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
298
331
|
async apply(stashId) {
|
|
299
332
|
const branch = `stashes/${stashId}`;
|
|
300
333
|
logger.info("worktree", `merging: ${stashId}`, { branch });
|
|
@@ -335,6 +368,10 @@ class WorktreeManager {
|
|
|
335
368
|
if (existsSync3(previewDir)) {
|
|
336
369
|
rmSync(previewDir, { recursive: true, force: true });
|
|
337
370
|
}
|
|
371
|
+
const previewsDir = join3(this.projectPath, ".stashes", "previews");
|
|
372
|
+
if (existsSync3(previewsDir)) {
|
|
373
|
+
rmSync(previewsDir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
338
375
|
logger.info("worktree", `cleanup complete`);
|
|
339
376
|
}
|
|
340
377
|
symlinkDeps(worktreePath) {
|
|
@@ -410,6 +447,9 @@ class PersistenceService {
|
|
|
410
447
|
const filePath = join4(this.basePath, "projects", projectId, "stashes.json");
|
|
411
448
|
return readJson(filePath, []);
|
|
412
449
|
}
|
|
450
|
+
getStash(projectId, stashId) {
|
|
451
|
+
return this.listStashes(projectId).find((s) => s.id === stashId);
|
|
452
|
+
}
|
|
413
453
|
saveStash(stash) {
|
|
414
454
|
const stashes = [...this.listStashes(stash.projectId)];
|
|
415
455
|
const index = stashes.findIndex((s) => s.id === stash.id);
|
|
@@ -915,19 +955,201 @@ async function cleanup(projectPath) {
|
|
|
915
955
|
// ../server/dist/services/stash-service.js
|
|
916
956
|
import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
|
|
917
957
|
import { join as join7 } from "path";
|
|
958
|
+
|
|
959
|
+
// ../server/dist/services/preview-pool.js
|
|
960
|
+
class PreviewPool {
|
|
961
|
+
entries = new Map;
|
|
962
|
+
usedPorts = new Set;
|
|
963
|
+
maxSize;
|
|
964
|
+
ttlMs;
|
|
965
|
+
worktreeManager;
|
|
966
|
+
broadcast;
|
|
967
|
+
reaperInterval;
|
|
968
|
+
constructor(worktreeManager, broadcast, maxSize = MAX_PREVIEW_SERVERS, ttlMs = PREVIEW_TTL_MS) {
|
|
969
|
+
this.worktreeManager = worktreeManager;
|
|
970
|
+
this.broadcast = broadcast;
|
|
971
|
+
this.maxSize = maxSize;
|
|
972
|
+
this.ttlMs = ttlMs;
|
|
973
|
+
this.reaperInterval = setInterval(() => this.reap(), PREVIEW_REAPER_INTERVAL);
|
|
974
|
+
}
|
|
975
|
+
async getOrStart(stashId) {
|
|
976
|
+
const existing = this.entries.get(stashId);
|
|
977
|
+
if (existing) {
|
|
978
|
+
existing.lastHeartbeat = Date.now();
|
|
979
|
+
logger.info("pool", `warm hit: ${stashId} on port ${existing.port}`);
|
|
980
|
+
return existing.port;
|
|
981
|
+
}
|
|
982
|
+
if (this.entries.size >= this.maxSize) {
|
|
983
|
+
this.evictOldest();
|
|
984
|
+
}
|
|
985
|
+
const port = this.allocatePort();
|
|
986
|
+
const worktreePath = await this.worktreeManager.createPreviewForPool(stashId);
|
|
987
|
+
const process2 = Bun.spawn({
|
|
988
|
+
cmd: ["npm", "run", "dev"],
|
|
989
|
+
cwd: worktreePath,
|
|
990
|
+
stdin: "ignore",
|
|
991
|
+
stdout: "pipe",
|
|
992
|
+
stderr: "pipe",
|
|
993
|
+
env: { ...Bun.env, PORT: String(port), BROWSER: "none" }
|
|
994
|
+
});
|
|
995
|
+
const entry = {
|
|
996
|
+
stashId,
|
|
997
|
+
port,
|
|
998
|
+
process: process2,
|
|
999
|
+
worktreePath,
|
|
1000
|
+
lastHeartbeat: Date.now()
|
|
1001
|
+
};
|
|
1002
|
+
this.entries.set(stashId, entry);
|
|
1003
|
+
this.usedPorts.add(port);
|
|
1004
|
+
logger.info("pool", `cold start: ${stashId} on port ${port}`, { poolSize: this.entries.size });
|
|
1005
|
+
await this.waitForPort(port, 60000);
|
|
1006
|
+
return port;
|
|
1007
|
+
}
|
|
1008
|
+
heartbeat(stashId) {
|
|
1009
|
+
const entry = this.entries.get(stashId);
|
|
1010
|
+
if (entry) {
|
|
1011
|
+
entry.lastHeartbeat = Date.now();
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
isWarm(stashId) {
|
|
1015
|
+
return this.entries.has(stashId);
|
|
1016
|
+
}
|
|
1017
|
+
getPort(stashId) {
|
|
1018
|
+
return this.entries.get(stashId)?.port ?? null;
|
|
1019
|
+
}
|
|
1020
|
+
async stop(stashId) {
|
|
1021
|
+
const entry = this.entries.get(stashId);
|
|
1022
|
+
if (!entry)
|
|
1023
|
+
return;
|
|
1024
|
+
logger.info("pool", `stopping: ${stashId} on port ${entry.port}`);
|
|
1025
|
+
this.killEntry(entry);
|
|
1026
|
+
this.entries.delete(stashId);
|
|
1027
|
+
this.usedPorts.delete(entry.port);
|
|
1028
|
+
try {
|
|
1029
|
+
await this.worktreeManager.removePreviewForPool(stashId);
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
logger.warn("pool", `worktree removal failed for ${stashId}`, {
|
|
1032
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
prefetchNeighbors(currentStashId, sortedStashIds) {
|
|
1037
|
+
const currentIndex = sortedStashIds.indexOf(currentStashId);
|
|
1038
|
+
if (currentIndex === -1 || sortedStashIds.length < 2)
|
|
1039
|
+
return;
|
|
1040
|
+
const neighbors = [];
|
|
1041
|
+
const prevIndex = (currentIndex - 1 + sortedStashIds.length) % sortedStashIds.length;
|
|
1042
|
+
const nextIndex = (currentIndex + 1) % sortedStashIds.length;
|
|
1043
|
+
if (!this.entries.has(sortedStashIds[prevIndex])) {
|
|
1044
|
+
neighbors.push(sortedStashIds[prevIndex]);
|
|
1045
|
+
}
|
|
1046
|
+
if (!this.entries.has(sortedStashIds[nextIndex])) {
|
|
1047
|
+
neighbors.push(sortedStashIds[nextIndex]);
|
|
1048
|
+
}
|
|
1049
|
+
for (const stashId of neighbors) {
|
|
1050
|
+
if (this.entries.size >= this.maxSize)
|
|
1051
|
+
break;
|
|
1052
|
+
logger.info("pool", `prefetching neighbor: ${stashId}`);
|
|
1053
|
+
this.getOrStart(stashId).then((port) => {
|
|
1054
|
+
this.broadcast({ type: "stash:port", stashId, port });
|
|
1055
|
+
}).catch((err) => {
|
|
1056
|
+
logger.warn("pool", `prefetch failed for ${stashId}`, {
|
|
1057
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
async shutdown() {
|
|
1063
|
+
logger.info("pool", `shutting down all`, { poolSize: this.entries.size });
|
|
1064
|
+
clearInterval(this.reaperInterval);
|
|
1065
|
+
const stashIds = [...this.entries.keys()];
|
|
1066
|
+
for (const stashId of stashIds) {
|
|
1067
|
+
await this.stop(stashId);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
reap() {
|
|
1071
|
+
const now = Date.now();
|
|
1072
|
+
const expired = [];
|
|
1073
|
+
for (const [stashId, entry] of this.entries) {
|
|
1074
|
+
if (now - entry.lastHeartbeat > this.ttlMs) {
|
|
1075
|
+
expired.push(stashId);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
for (const stashId of expired) {
|
|
1079
|
+
logger.info("pool", `reaping inactive: ${stashId}`);
|
|
1080
|
+
const entry = this.entries.get(stashId);
|
|
1081
|
+
this.killEntry(entry);
|
|
1082
|
+
this.entries.delete(stashId);
|
|
1083
|
+
this.usedPorts.delete(entry.port);
|
|
1084
|
+
this.worktreeManager.removePreviewForPool(stashId).catch((err) => {
|
|
1085
|
+
logger.warn("pool", `reap worktree cleanup failed for ${stashId}`, {
|
|
1086
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
this.broadcast({ type: "stash:preview_stopped", stashId });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
evictOldest() {
|
|
1093
|
+
let oldest = null;
|
|
1094
|
+
for (const entry of this.entries.values()) {
|
|
1095
|
+
if (!oldest || entry.lastHeartbeat < oldest.lastHeartbeat) {
|
|
1096
|
+
oldest = entry;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (oldest) {
|
|
1100
|
+
logger.info("pool", `evicting: ${oldest.stashId} (oldest heartbeat)`);
|
|
1101
|
+
this.killEntry(oldest);
|
|
1102
|
+
this.entries.delete(oldest.stashId);
|
|
1103
|
+
this.usedPorts.delete(oldest.port);
|
|
1104
|
+
this.worktreeManager.removePreviewForPool(oldest.stashId).catch((err) => {
|
|
1105
|
+
logger.warn("pool", `evict worktree cleanup failed for ${oldest.stashId}`, {
|
|
1106
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
this.broadcast({ type: "stash:preview_stopped", stashId: oldest.stashId });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
allocatePort() {
|
|
1113
|
+
for (let port = STASH_PORT_START;port <= STASH_PORT_END; port++) {
|
|
1114
|
+
if (!this.usedPorts.has(port)) {
|
|
1115
|
+
return port;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
throw new Error(`No available ports in range ${STASH_PORT_START}-${STASH_PORT_END}`);
|
|
1119
|
+
}
|
|
1120
|
+
killEntry(entry) {
|
|
1121
|
+
try {
|
|
1122
|
+
entry.process.kill();
|
|
1123
|
+
} catch {}
|
|
1124
|
+
}
|
|
1125
|
+
async waitForPort(port, timeout) {
|
|
1126
|
+
const start = Date.now();
|
|
1127
|
+
while (Date.now() - start < timeout) {
|
|
1128
|
+
try {
|
|
1129
|
+
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
1130
|
+
if (res.ok || res.status < 500)
|
|
1131
|
+
return;
|
|
1132
|
+
} catch {}
|
|
1133
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1134
|
+
}
|
|
1135
|
+
throw new Error(`Port ${port} not ready within ${timeout}ms`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// ../server/dist/services/stash-service.js
|
|
918
1140
|
class StashService {
|
|
919
1141
|
projectPath;
|
|
920
1142
|
worktreeManager;
|
|
921
1143
|
persistence;
|
|
922
1144
|
broadcast;
|
|
1145
|
+
previewPool;
|
|
923
1146
|
selectedComponent = null;
|
|
924
|
-
previewServer = null;
|
|
925
|
-
activePreviewStashId = null;
|
|
926
1147
|
constructor(projectPath, worktreeManager, persistence, broadcast) {
|
|
927
1148
|
this.projectPath = projectPath;
|
|
928
1149
|
this.worktreeManager = worktreeManager;
|
|
929
1150
|
this.persistence = persistence;
|
|
930
1151
|
this.broadcast = broadcast;
|
|
1152
|
+
this.previewPool = new PreviewPool(worktreeManager, broadcast);
|
|
931
1153
|
}
|
|
932
1154
|
setSelectedComponent(component) {
|
|
933
1155
|
this.selectedComponent = component;
|
|
@@ -973,7 +1195,7 @@ class StashService {
|
|
|
973
1195
|
});
|
|
974
1196
|
}
|
|
975
1197
|
}
|
|
976
|
-
async chat(projectId, message) {
|
|
1198
|
+
async chat(projectId, message, referenceStashIds) {
|
|
977
1199
|
const component = this.selectedComponent;
|
|
978
1200
|
let sourceCode = "";
|
|
979
1201
|
const filePath = component?.filePath || "";
|
|
@@ -983,8 +1205,18 @@ class StashService {
|
|
|
983
1205
|
sourceCode = readFileSync4(sourceFile, "utf-8");
|
|
984
1206
|
}
|
|
985
1207
|
}
|
|
1208
|
+
let stashContext = "";
|
|
1209
|
+
if (referenceStashIds?.length) {
|
|
1210
|
+
const refs = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
|
|
1211
|
+
if (refs.length) {
|
|
1212
|
+
stashContext = `
|
|
1213
|
+
Referenced stashes:
|
|
1214
|
+
${refs.join(`
|
|
1215
|
+
`)}`;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
986
1218
|
const chatPrompt = [
|
|
987
|
-
"The user is asking about
|
|
1219
|
+
"The user is asking about their UI project. Answer concisely.",
|
|
988
1220
|
"Do NOT modify any files.",
|
|
989
1221
|
"",
|
|
990
1222
|
component ? `Component: ${component.name}` : "",
|
|
@@ -994,6 +1226,7 @@ Source:
|
|
|
994
1226
|
\`\`\`
|
|
995
1227
|
${sourceCode.substring(0, 3000)}
|
|
996
1228
|
\`\`\`` : "",
|
|
1229
|
+
stashContext,
|
|
997
1230
|
"",
|
|
998
1231
|
`User question: ${message}`
|
|
999
1232
|
].filter(Boolean).join(`
|
|
@@ -1046,18 +1279,23 @@ ${sourceCode.substring(0, 3000)}
|
|
|
1046
1279
|
break;
|
|
1047
1280
|
}
|
|
1048
1281
|
}
|
|
1049
|
-
async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT) {
|
|
1050
|
-
|
|
1051
|
-
|
|
1282
|
+
async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
|
|
1283
|
+
let enrichedPrompt = prompt;
|
|
1284
|
+
if (referenceStashIds?.length) {
|
|
1285
|
+
const refDescriptions = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
|
|
1286
|
+
if (refDescriptions.length) {
|
|
1287
|
+
enrichedPrompt = `${prompt}
|
|
1288
|
+
|
|
1289
|
+
## Reference Stashes (use as inspiration)
|
|
1290
|
+
${refDescriptions.join(`
|
|
1291
|
+
`)}`;
|
|
1292
|
+
}
|
|
1052
1293
|
}
|
|
1053
1294
|
await generate({
|
|
1054
1295
|
projectPath: this.projectPath,
|
|
1055
1296
|
projectId,
|
|
1056
|
-
prompt,
|
|
1057
|
-
component: {
|
|
1058
|
-
filePath: this.selectedComponent.filePath,
|
|
1059
|
-
exportName: this.selectedComponent.name
|
|
1060
|
-
},
|
|
1297
|
+
prompt: enrichedPrompt,
|
|
1298
|
+
component: this.selectedComponent ? { filePath: this.selectedComponent.filePath, exportName: this.selectedComponent.name } : undefined,
|
|
1061
1299
|
count: stashCount,
|
|
1062
1300
|
onProgress: (event) => this.progressToBroadcast(event)
|
|
1063
1301
|
});
|
|
@@ -1070,94 +1308,25 @@ ${sourceCode.substring(0, 3000)}
|
|
|
1070
1308
|
onProgress: (event) => this.progressToBroadcast(event)
|
|
1071
1309
|
});
|
|
1072
1310
|
}
|
|
1073
|
-
async switchPreview(stashId) {
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
if (
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1079
|
-
await this.ensurePreviewServer(previewPath);
|
|
1080
|
-
if (this.activePreviewStashId === stashId) {
|
|
1081
|
-
this.broadcast({ type: "stash:port", stashId, port: previewPort });
|
|
1082
|
-
return;
|
|
1311
|
+
async switchPreview(stashId, sortedStashIds) {
|
|
1312
|
+
const port = await this.previewPool.getOrStart(stashId);
|
|
1313
|
+
this.broadcast({ type: "stash:port", stashId, port });
|
|
1314
|
+
if (sortedStashIds && sortedStashIds.length > 1) {
|
|
1315
|
+
this.previewPool.prefetchNeighbors(stashId, sortedStashIds);
|
|
1083
1316
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
this.broadcast({ type: "stash:port", stashId, port: previewPort });
|
|
1317
|
+
}
|
|
1318
|
+
previewHeartbeat(stashId) {
|
|
1319
|
+
this.previewPool.heartbeat(stashId);
|
|
1088
1320
|
}
|
|
1089
1321
|
async applyStash(stashId) {
|
|
1090
|
-
this.
|
|
1322
|
+
await this.previewPool.shutdown();
|
|
1091
1323
|
await apply({ projectPath: this.projectPath, stashId });
|
|
1092
|
-
this.activePreviewStashId = null;
|
|
1093
1324
|
this.broadcast({ type: "stash:applied", stashId });
|
|
1094
1325
|
}
|
|
1095
1326
|
async deleteStash(stashId) {
|
|
1096
|
-
|
|
1097
|
-
this.activePreviewStashId = null;
|
|
1098
|
-
}
|
|
1327
|
+
await this.previewPool.stop(stashId);
|
|
1099
1328
|
await remove(this.projectPath, stashId);
|
|
1100
1329
|
}
|
|
1101
|
-
async ensurePreviewServer(previewPath) {
|
|
1102
|
-
if (this.previewServer) {
|
|
1103
|
-
const port = this.worktreeManager.getPreviewPort();
|
|
1104
|
-
try {
|
|
1105
|
-
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
1106
|
-
if (res.ok || res.status < 500)
|
|
1107
|
-
return;
|
|
1108
|
-
} catch {
|
|
1109
|
-
this.previewServer = null;
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
await this.startPreviewServer(previewPath);
|
|
1113
|
-
}
|
|
1114
|
-
async startPreviewServer(previewPath) {
|
|
1115
|
-
const port = this.worktreeManager.getPreviewPort();
|
|
1116
|
-
this.previewServer = Bun.spawn({
|
|
1117
|
-
cmd: ["npm", "run", "dev"],
|
|
1118
|
-
cwd: previewPath,
|
|
1119
|
-
stdin: "ignore",
|
|
1120
|
-
stdout: "pipe",
|
|
1121
|
-
stderr: "pipe",
|
|
1122
|
-
env: { ...process.env, PORT: String(port), BROWSER: "none" }
|
|
1123
|
-
});
|
|
1124
|
-
await this.waitForPort(port, 60000);
|
|
1125
|
-
}
|
|
1126
|
-
stopPreviewServer() {
|
|
1127
|
-
if (this.previewServer) {
|
|
1128
|
-
this.previewServer.kill();
|
|
1129
|
-
this.previewServer = null;
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
async waitForRecompile(port, timeout) {
|
|
1133
|
-
const start = Date.now();
|
|
1134
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1135
|
-
while (Date.now() - start < timeout) {
|
|
1136
|
-
try {
|
|
1137
|
-
const res = await fetch(`http://localhost:${port}`, {
|
|
1138
|
-
signal: AbortSignal.timeout(3000),
|
|
1139
|
-
headers: { "cache-control": "no-cache" }
|
|
1140
|
-
});
|
|
1141
|
-
if (res.ok) {
|
|
1142
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
1143
|
-
return;
|
|
1144
|
-
}
|
|
1145
|
-
} catch {}
|
|
1146
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
async waitForPort(port, timeout) {
|
|
1150
|
-
const start = Date.now();
|
|
1151
|
-
while (Date.now() - start < timeout) {
|
|
1152
|
-
try {
|
|
1153
|
-
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
1154
|
-
if (res.ok || res.status < 500)
|
|
1155
|
-
return;
|
|
1156
|
-
} catch {}
|
|
1157
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
1158
|
-
}
|
|
1159
|
-
throw new Error(`Port ${port} not ready within ${timeout}ms`);
|
|
1160
|
-
}
|
|
1161
1330
|
}
|
|
1162
1331
|
|
|
1163
1332
|
// ../server/dist/services/websocket.js
|
|
@@ -1174,7 +1343,7 @@ function broadcast(event) {
|
|
|
1174
1343
|
function getPersistenceFromWs() {
|
|
1175
1344
|
return persistence;
|
|
1176
1345
|
}
|
|
1177
|
-
function createWebSocketHandler(projectPath, userDevPort) {
|
|
1346
|
+
function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
|
|
1178
1347
|
worktreeManager = new WorktreeManager(projectPath);
|
|
1179
1348
|
persistence = new PersistenceService(projectPath);
|
|
1180
1349
|
stashService = new StashService(projectPath, worktreeManager, persistence, broadcast);
|
|
@@ -1182,7 +1351,7 @@ function createWebSocketHandler(projectPath, userDevPort) {
|
|
|
1182
1351
|
open(ws) {
|
|
1183
1352
|
clients.add(ws);
|
|
1184
1353
|
logger.info("ws", "client connected", { total: clients.size });
|
|
1185
|
-
ws.send(JSON.stringify({ type: "server_ready", port: userDevPort }));
|
|
1354
|
+
ws.send(JSON.stringify({ type: "server_ready", port: userDevPort, appProxyPort }));
|
|
1186
1355
|
},
|
|
1187
1356
|
async message(ws, message) {
|
|
1188
1357
|
const raw = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
@@ -1209,7 +1378,7 @@ function createWebSocketHandler(projectPath, userDevPort) {
|
|
|
1209
1378
|
type: "text",
|
|
1210
1379
|
createdAt: new Date().toISOString()
|
|
1211
1380
|
});
|
|
1212
|
-
await stashService.chat(event.projectId, event.message);
|
|
1381
|
+
await stashService.chat(event.projectId, event.message, event.referenceStashIds);
|
|
1213
1382
|
break;
|
|
1214
1383
|
case "generate":
|
|
1215
1384
|
persistence.saveChatMessage(event.projectId, {
|
|
@@ -1219,13 +1388,16 @@ function createWebSocketHandler(projectPath, userDevPort) {
|
|
|
1219
1388
|
type: "text",
|
|
1220
1389
|
createdAt: new Date().toISOString()
|
|
1221
1390
|
});
|
|
1222
|
-
await stashService.generate(event.projectId, event.prompt, event.stashCount);
|
|
1391
|
+
await stashService.generate(event.projectId, event.prompt, event.stashCount, event.referenceStashIds);
|
|
1223
1392
|
break;
|
|
1224
1393
|
case "vary":
|
|
1225
1394
|
await stashService.vary(event.sourceStashId, event.prompt);
|
|
1226
1395
|
break;
|
|
1227
1396
|
case "interact":
|
|
1228
|
-
await stashService.switchPreview(event.stashId);
|
|
1397
|
+
await stashService.switchPreview(event.stashId, event.sortedStashIds);
|
|
1398
|
+
break;
|
|
1399
|
+
case "preview_heartbeat":
|
|
1400
|
+
stashService.previewHeartbeat(event.stashId);
|
|
1229
1401
|
break;
|
|
1230
1402
|
case "apply_stash":
|
|
1231
1403
|
await stashService.applyStash(event.stashId);
|
|
@@ -1247,6 +1419,111 @@ function createWebSocketHandler(projectPath, userDevPort) {
|
|
|
1247
1419
|
};
|
|
1248
1420
|
}
|
|
1249
1421
|
|
|
1422
|
+
// ../server/dist/services/app-proxy.js
|
|
1423
|
+
function startAppProxy(userDevPort, proxyPort, injectOverlay) {
|
|
1424
|
+
const server = Bun.serve({
|
|
1425
|
+
port: proxyPort,
|
|
1426
|
+
async fetch(req, server2) {
|
|
1427
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
1428
|
+
const url2 = new URL(req.url);
|
|
1429
|
+
const success = server2.upgrade(req, {
|
|
1430
|
+
data: {
|
|
1431
|
+
path: url2.pathname + url2.search,
|
|
1432
|
+
upstream: null,
|
|
1433
|
+
ready: false,
|
|
1434
|
+
buffer: []
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
return success ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
1438
|
+
}
|
|
1439
|
+
const url = new URL(req.url);
|
|
1440
|
+
const targetUrl = `http://localhost:${userDevPort}${url.pathname}${url.search}`;
|
|
1441
|
+
try {
|
|
1442
|
+
const headers = new Headers;
|
|
1443
|
+
for (const [key, value] of req.headers.entries()) {
|
|
1444
|
+
if (!["host", "accept-encoding"].includes(key.toLowerCase())) {
|
|
1445
|
+
headers.set(key, value);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
headers.set("host", `localhost:${userDevPort}`);
|
|
1449
|
+
const response = await fetch(targetUrl, {
|
|
1450
|
+
method: req.method,
|
|
1451
|
+
headers,
|
|
1452
|
+
body: req.method !== "GET" && req.method !== "HEAD" ? await req.arrayBuffer() : undefined,
|
|
1453
|
+
redirect: "manual"
|
|
1454
|
+
});
|
|
1455
|
+
const contentType = response.headers.get("content-type") || "";
|
|
1456
|
+
if (contentType.includes("text/html")) {
|
|
1457
|
+
const html = await response.text();
|
|
1458
|
+
const injectedHtml = injectOverlay(html);
|
|
1459
|
+
const respHeaders2 = new Headers(response.headers);
|
|
1460
|
+
respHeaders2.delete("content-encoding");
|
|
1461
|
+
respHeaders2.delete("content-length");
|
|
1462
|
+
return new Response(injectedHtml, {
|
|
1463
|
+
status: response.status,
|
|
1464
|
+
headers: respHeaders2
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
const respHeaders = new Headers(response.headers);
|
|
1468
|
+
respHeaders.delete("content-encoding");
|
|
1469
|
+
return new Response(response.body, {
|
|
1470
|
+
status: response.status,
|
|
1471
|
+
headers: respHeaders
|
|
1472
|
+
});
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
|
|
1475
|
+
status: 502,
|
|
1476
|
+
headers: { "content-type": "application/json" }
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
},
|
|
1480
|
+
websocket: {
|
|
1481
|
+
open(ws) {
|
|
1482
|
+
const { data } = ws;
|
|
1483
|
+
const upstream = new WebSocket(`ws://localhost:${userDevPort}${data.path}`);
|
|
1484
|
+
upstream.addEventListener("open", () => {
|
|
1485
|
+
data.upstream = upstream;
|
|
1486
|
+
data.ready = true;
|
|
1487
|
+
for (const msg of data.buffer) {
|
|
1488
|
+
upstream.send(msg);
|
|
1489
|
+
}
|
|
1490
|
+
data.buffer = [];
|
|
1491
|
+
});
|
|
1492
|
+
upstream.addEventListener("message", (event) => {
|
|
1493
|
+
try {
|
|
1494
|
+
ws.sendText(typeof event.data === "string" ? event.data : String(event.data));
|
|
1495
|
+
} catch {}
|
|
1496
|
+
});
|
|
1497
|
+
upstream.addEventListener("close", () => {
|
|
1498
|
+
try {
|
|
1499
|
+
ws.close();
|
|
1500
|
+
} catch {}
|
|
1501
|
+
});
|
|
1502
|
+
upstream.addEventListener("error", () => {
|
|
1503
|
+
try {
|
|
1504
|
+
ws.close();
|
|
1505
|
+
} catch {}
|
|
1506
|
+
});
|
|
1507
|
+
},
|
|
1508
|
+
message(ws, msg) {
|
|
1509
|
+
const { data } = ws;
|
|
1510
|
+
if (data.ready && data.upstream) {
|
|
1511
|
+
data.upstream.send(typeof msg === "string" ? msg : new Uint8Array(msg));
|
|
1512
|
+
} else {
|
|
1513
|
+
data.buffer.push(typeof msg === "string" ? msg : new Uint8Array(msg).buffer);
|
|
1514
|
+
}
|
|
1515
|
+
},
|
|
1516
|
+
close(ws) {
|
|
1517
|
+
try {
|
|
1518
|
+
ws.data.upstream?.close();
|
|
1519
|
+
} catch {}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
|
|
1524
|
+
return server;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1250
1527
|
// ../server/dist/index.js
|
|
1251
1528
|
var serverState = {
|
|
1252
1529
|
projectPath: "",
|
|
@@ -1258,56 +1535,6 @@ function getPersistence() {
|
|
|
1258
1535
|
var app2 = new Hono2;
|
|
1259
1536
|
app2.use("/*", cors());
|
|
1260
1537
|
app2.route("/api", apiRoutes);
|
|
1261
|
-
async function proxyToUserApp(c, targetPath, injectOverlay = false) {
|
|
1262
|
-
const userDevPort = serverState.userDevPort;
|
|
1263
|
-
const url = new URL(c.req.url);
|
|
1264
|
-
const targetUrl = `http://localhost:${userDevPort}${targetPath}${url.search}`;
|
|
1265
|
-
try {
|
|
1266
|
-
const headers = new Headers;
|
|
1267
|
-
for (const [key, value] of Object.entries(c.req.header())) {
|
|
1268
|
-
if (!["host", "accept-encoding"].includes(key.toLowerCase()) && value) {
|
|
1269
|
-
headers.set(key, value);
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
headers.set("host", `localhost:${userDevPort}`);
|
|
1273
|
-
const response = await fetch(targetUrl, {
|
|
1274
|
-
method: c.req.method,
|
|
1275
|
-
headers,
|
|
1276
|
-
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.arrayBuffer() : undefined,
|
|
1277
|
-
redirect: "manual"
|
|
1278
|
-
});
|
|
1279
|
-
const contentType = response.headers.get("content-type") || "";
|
|
1280
|
-
if (injectOverlay && contentType.includes("text/html")) {
|
|
1281
|
-
const html = await response.text();
|
|
1282
|
-
const injectedHtml = injectOverlayScript(html);
|
|
1283
|
-
const respHeaders2 = new Headers(response.headers);
|
|
1284
|
-
respHeaders2.delete("content-encoding");
|
|
1285
|
-
respHeaders2.delete("content-length");
|
|
1286
|
-
return new Response(injectedHtml, {
|
|
1287
|
-
status: response.status,
|
|
1288
|
-
headers: respHeaders2
|
|
1289
|
-
});
|
|
1290
|
-
}
|
|
1291
|
-
const respHeaders = new Headers(response.headers);
|
|
1292
|
-
respHeaders.delete("content-encoding");
|
|
1293
|
-
return new Response(response.body, {
|
|
1294
|
-
status: response.status,
|
|
1295
|
-
headers: respHeaders
|
|
1296
|
-
});
|
|
1297
|
-
} catch (err) {
|
|
1298
|
-
return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
|
|
1299
|
-
status: 502,
|
|
1300
|
-
headers: { "content-type": "application/json" }
|
|
1301
|
-
});
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
app2.all("/app/*", (c) => {
|
|
1305
|
-
const path = c.req.path.replace(/^\/app/, "") || "/";
|
|
1306
|
-
return proxyToUserApp(c, path, true);
|
|
1307
|
-
});
|
|
1308
|
-
app2.all("/_next/*", (c) => proxyToUserApp(c, c.req.path));
|
|
1309
|
-
app2.all("/__nextjs*", (c) => proxyToUserApp(c, c.req.path));
|
|
1310
|
-
app2.all("/__next*", (c) => proxyToUserApp(c, c.req.path));
|
|
1311
1538
|
app2.get("/*", async (c) => {
|
|
1312
1539
|
const path = c.req.path;
|
|
1313
1540
|
const selfDir = dirname2(fileURLToPath(import.meta.url));
|
|
@@ -1350,7 +1577,9 @@ app2.get("/*", async (c) => {
|
|
|
1350
1577
|
function startServer(projectPath, userDevPort, port = STASHES_PORT) {
|
|
1351
1578
|
serverState = { projectPath, userDevPort };
|
|
1352
1579
|
initLogFile(projectPath);
|
|
1353
|
-
const
|
|
1580
|
+
const appProxyPort = port + 1;
|
|
1581
|
+
startAppProxy(userDevPort, appProxyPort, injectOverlayScript);
|
|
1582
|
+
const wsHandler = createWebSocketHandler(projectPath, userDevPort, appProxyPort);
|
|
1354
1583
|
const server = Bun.serve({
|
|
1355
1584
|
port,
|
|
1356
1585
|
fetch(req, server2) {
|
|
@@ -1515,22 +1744,25 @@ function injectOverlayScript(html) {
|
|
|
1515
1744
|
}
|
|
1516
1745
|
});
|
|
1517
1746
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1747
|
+
// Report current URL to parent for status bar display
|
|
1748
|
+
function reportUrl() {
|
|
1749
|
+
window.parent.postMessage({
|
|
1750
|
+
type: 'stashes:url_change',
|
|
1751
|
+
url: window.location.pathname + window.location.search + window.location.hash
|
|
1752
|
+
}, '*');
|
|
1753
|
+
}
|
|
1754
|
+
reportUrl();
|
|
1755
|
+
var origPush = history.pushState;
|
|
1756
|
+
history.pushState = function() {
|
|
1757
|
+
origPush.apply(this, arguments);
|
|
1758
|
+
setTimeout(reportUrl, 0);
|
|
1759
|
+
};
|
|
1760
|
+
var origReplace = history.replaceState;
|
|
1761
|
+
history.replaceState = function() {
|
|
1762
|
+
origReplace.apply(this, arguments);
|
|
1763
|
+
setTimeout(reportUrl, 0);
|
|
1764
|
+
};
|
|
1765
|
+
window.addEventListener('popstate', reportUrl);
|
|
1534
1766
|
|
|
1535
1767
|
document.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
1536
1768
|
document.addEventListener('click', onClick, true);
|