stashes 0.1.8 → 0.1.10
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 +234 -82
- package/dist/mcp.js +234 -82
- package/dist/web/assets/index-BjonyU1P.css +1 -0
- package/dist/web/assets/{index-8mMmnYX5.js → index-R4H2ja1O.js} +13 -13
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-BEubPa-l.css +0 -1
- package/dist/web/assets/index-B_JpGQr9.css +0 -1
- package/dist/web/assets/index-BiVQXVVI.js +0 -62
- package/dist/web/assets/index-BzwYXF3-.js +0 -62
- package/dist/web/assets/index-DBsH8rVY.css +0 -1
package/dist/cli.js
CHANGED
|
@@ -39,6 +39,10 @@ var STASHES_PORT = 4000;
|
|
|
39
39
|
var DEFAULT_STASH_COUNT = 3;
|
|
40
40
|
var APP_PROXY_PORT = STASHES_PORT + 1;
|
|
41
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;
|
|
42
46
|
var DEFAULT_DIRECTIVES = [
|
|
43
47
|
"Minimal and clean \u2014 reduce visual noise, emphasize whitespace, limit to 2-3 colors, typography-driven hierarchy",
|
|
44
48
|
"Bold and expressive \u2014 strong visual identity, use motion/animation, rich interactions, distinctive layout",
|
|
@@ -296,6 +300,34 @@ class WorktreeManager {
|
|
|
296
300
|
getPreviewPort() {
|
|
297
301
|
return PREVIEW_PORT;
|
|
298
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
|
+
}
|
|
299
331
|
async apply(stashId) {
|
|
300
332
|
const branch = `stashes/${stashId}`;
|
|
301
333
|
logger.info("worktree", `merging: ${stashId}`, { branch });
|
|
@@ -336,6 +368,10 @@ class WorktreeManager {
|
|
|
336
368
|
if (existsSync3(previewDir)) {
|
|
337
369
|
rmSync(previewDir, { recursive: true, force: true });
|
|
338
370
|
}
|
|
371
|
+
const previewsDir = join3(this.projectPath, ".stashes", "previews");
|
|
372
|
+
if (existsSync3(previewsDir)) {
|
|
373
|
+
rmSync(previewsDir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
339
375
|
logger.info("worktree", `cleanup complete`);
|
|
340
376
|
}
|
|
341
377
|
symlinkDeps(worktreePath) {
|
|
@@ -919,19 +955,201 @@ async function cleanup(projectPath) {
|
|
|
919
955
|
// ../server/dist/services/stash-service.js
|
|
920
956
|
import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
|
|
921
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
|
|
922
1140
|
class StashService {
|
|
923
1141
|
projectPath;
|
|
924
1142
|
worktreeManager;
|
|
925
1143
|
persistence;
|
|
926
1144
|
broadcast;
|
|
1145
|
+
previewPool;
|
|
927
1146
|
selectedComponent = null;
|
|
928
|
-
previewServer = null;
|
|
929
|
-
activePreviewStashId = null;
|
|
930
1147
|
constructor(projectPath, worktreeManager, persistence, broadcast) {
|
|
931
1148
|
this.projectPath = projectPath;
|
|
932
1149
|
this.worktreeManager = worktreeManager;
|
|
933
1150
|
this.persistence = persistence;
|
|
934
1151
|
this.broadcast = broadcast;
|
|
1152
|
+
this.previewPool = new PreviewPool(worktreeManager, broadcast);
|
|
935
1153
|
}
|
|
936
1154
|
setSelectedComponent(component) {
|
|
937
1155
|
this.selectedComponent = component;
|
|
@@ -1090,94 +1308,25 @@ ${refDescriptions.join(`
|
|
|
1090
1308
|
onProgress: (event) => this.progressToBroadcast(event)
|
|
1091
1309
|
});
|
|
1092
1310
|
}
|
|
1093
|
-
async switchPreview(stashId) {
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
if (
|
|
1097
|
-
|
|
1098
|
-
}
|
|
1099
|
-
await this.ensurePreviewServer(previewPath);
|
|
1100
|
-
if (this.activePreviewStashId === stashId) {
|
|
1101
|
-
this.broadcast({ type: "stash:port", stashId, port: previewPort });
|
|
1102
|
-
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);
|
|
1103
1316
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
this.broadcast({ type: "stash:port", stashId, port: previewPort });
|
|
1317
|
+
}
|
|
1318
|
+
previewHeartbeat(stashId) {
|
|
1319
|
+
this.previewPool.heartbeat(stashId);
|
|
1108
1320
|
}
|
|
1109
1321
|
async applyStash(stashId) {
|
|
1110
|
-
this.
|
|
1322
|
+
await this.previewPool.shutdown();
|
|
1111
1323
|
await apply({ projectPath: this.projectPath, stashId });
|
|
1112
|
-
this.activePreviewStashId = null;
|
|
1113
1324
|
this.broadcast({ type: "stash:applied", stashId });
|
|
1114
1325
|
}
|
|
1115
1326
|
async deleteStash(stashId) {
|
|
1116
|
-
|
|
1117
|
-
this.activePreviewStashId = null;
|
|
1118
|
-
}
|
|
1327
|
+
await this.previewPool.stop(stashId);
|
|
1119
1328
|
await remove(this.projectPath, stashId);
|
|
1120
1329
|
}
|
|
1121
|
-
async ensurePreviewServer(previewPath) {
|
|
1122
|
-
if (this.previewServer) {
|
|
1123
|
-
const port = this.worktreeManager.getPreviewPort();
|
|
1124
|
-
try {
|
|
1125
|
-
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
1126
|
-
if (res.ok || res.status < 500)
|
|
1127
|
-
return;
|
|
1128
|
-
} catch {
|
|
1129
|
-
this.previewServer = null;
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
await this.startPreviewServer(previewPath);
|
|
1133
|
-
}
|
|
1134
|
-
async startPreviewServer(previewPath) {
|
|
1135
|
-
const port = this.worktreeManager.getPreviewPort();
|
|
1136
|
-
this.previewServer = Bun.spawn({
|
|
1137
|
-
cmd: ["npm", "run", "dev"],
|
|
1138
|
-
cwd: previewPath,
|
|
1139
|
-
stdin: "ignore",
|
|
1140
|
-
stdout: "pipe",
|
|
1141
|
-
stderr: "pipe",
|
|
1142
|
-
env: { ...process.env, PORT: String(port), BROWSER: "none" }
|
|
1143
|
-
});
|
|
1144
|
-
await this.waitForPort(port, 60000);
|
|
1145
|
-
}
|
|
1146
|
-
stopPreviewServer() {
|
|
1147
|
-
if (this.previewServer) {
|
|
1148
|
-
this.previewServer.kill();
|
|
1149
|
-
this.previewServer = null;
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
async waitForRecompile(port, timeout) {
|
|
1153
|
-
const start = Date.now();
|
|
1154
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1155
|
-
while (Date.now() - start < timeout) {
|
|
1156
|
-
try {
|
|
1157
|
-
const res = await fetch(`http://localhost:${port}`, {
|
|
1158
|
-
signal: AbortSignal.timeout(3000),
|
|
1159
|
-
headers: { "cache-control": "no-cache" }
|
|
1160
|
-
});
|
|
1161
|
-
if (res.ok) {
|
|
1162
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
1163
|
-
return;
|
|
1164
|
-
}
|
|
1165
|
-
} catch {}
|
|
1166
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
async waitForPort(port, timeout) {
|
|
1170
|
-
const start = Date.now();
|
|
1171
|
-
while (Date.now() - start < timeout) {
|
|
1172
|
-
try {
|
|
1173
|
-
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
|
1174
|
-
if (res.ok || res.status < 500)
|
|
1175
|
-
return;
|
|
1176
|
-
} catch {}
|
|
1177
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
1178
|
-
}
|
|
1179
|
-
throw new Error(`Port ${port} not ready within ${timeout}ms`);
|
|
1180
|
-
}
|
|
1181
1330
|
}
|
|
1182
1331
|
|
|
1183
1332
|
// ../server/dist/services/websocket.js
|
|
@@ -1245,7 +1394,10 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
|
|
|
1245
1394
|
await stashService.vary(event.sourceStashId, event.prompt);
|
|
1246
1395
|
break;
|
|
1247
1396
|
case "interact":
|
|
1248
|
-
await stashService.switchPreview(event.stashId);
|
|
1397
|
+
await stashService.switchPreview(event.stashId, event.sortedStashIds);
|
|
1398
|
+
break;
|
|
1399
|
+
case "preview_heartbeat":
|
|
1400
|
+
stashService.previewHeartbeat(event.stashId);
|
|
1249
1401
|
break;
|
|
1250
1402
|
case "apply_stash":
|
|
1251
1403
|
await stashService.applyStash(event.stashId);
|