stashes 0.1.27 → 0.1.28
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 +389 -206
- package/dist/mcp.js +372 -189
- package/dist/web/assets/{index-leC0pOEQ.js → index-DFTLJjq2.js} +19 -19
- package/dist/web/assets/index-DUXYFG67.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Yo4820v-.css +0 -1
package/dist/mcp.js
CHANGED
|
@@ -26,10 +26,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
26
26
|
import { z } from "zod";
|
|
27
27
|
|
|
28
28
|
// ../core/dist/generation.js
|
|
29
|
-
import { readFileSync as readFileSync2, existsSync as
|
|
30
|
-
import { join as
|
|
29
|
+
import { readFileSync as readFileSync2, existsSync as existsSync6 } from "fs";
|
|
30
|
+
import { join as join6 } from "path";
|
|
31
31
|
var {spawn: spawn3 } = globalThis.Bun;
|
|
32
|
-
import
|
|
32
|
+
import simpleGit3 from "simple-git";
|
|
33
33
|
// ../shared/dist/constants/index.js
|
|
34
34
|
var STASHES_PORT = 4000;
|
|
35
35
|
var DEFAULT_STASH_COUNT = 3;
|
|
@@ -424,6 +424,14 @@ class PersistenceService {
|
|
|
424
424
|
const filePath = join3(this.basePath, "projects", projectId, "stashes.json");
|
|
425
425
|
writeJson(filePath, stashes);
|
|
426
426
|
}
|
|
427
|
+
getProjectSettings(projectId) {
|
|
428
|
+
const filePath = join3(this.basePath, "projects", projectId, "settings.json");
|
|
429
|
+
return readJson(filePath, {});
|
|
430
|
+
}
|
|
431
|
+
saveProjectSettings(projectId, settings) {
|
|
432
|
+
const filePath = join3(this.basePath, "projects", projectId, "settings.json");
|
|
433
|
+
writeJson(filePath, settings);
|
|
434
|
+
}
|
|
427
435
|
listChats(projectId) {
|
|
428
436
|
const dir = join3(this.basePath, "projects", projectId, "chats");
|
|
429
437
|
if (!existsSync3(dir))
|
|
@@ -504,18 +512,22 @@ class PersistenceService {
|
|
|
504
512
|
var {spawn } = globalThis.Bun;
|
|
505
513
|
var CLAUDE_BIN = "/opt/homebrew/bin/claude";
|
|
506
514
|
var processes = new Map;
|
|
507
|
-
function startAiProcess(id, prompt, cwd, resumeSessionId) {
|
|
515
|
+
function startAiProcess(id, prompt, cwd, resumeSessionId, model) {
|
|
508
516
|
killAiProcess(id);
|
|
509
517
|
logger.info("claude", `spawning process: ${id}`, {
|
|
510
518
|
cwd,
|
|
511
519
|
promptLength: prompt.length,
|
|
512
520
|
promptPreview: prompt.substring(0, 100),
|
|
513
|
-
resumeSessionId
|
|
521
|
+
resumeSessionId,
|
|
522
|
+
model
|
|
514
523
|
});
|
|
515
524
|
const cmd = [CLAUDE_BIN, "-p", prompt, "--output-format=stream-json", "--verbose", "--dangerously-skip-permissions"];
|
|
516
525
|
if (resumeSessionId) {
|
|
517
526
|
cmd.push("--resume", resumeSessionId);
|
|
518
527
|
}
|
|
528
|
+
if (model) {
|
|
529
|
+
cmd.push("--model", model);
|
|
530
|
+
}
|
|
519
531
|
const proc = spawn({
|
|
520
532
|
cmd,
|
|
521
533
|
stdin: "ignore",
|
|
@@ -639,6 +651,11 @@ async function* parseClaudeStream(proc) {
|
|
|
639
651
|
}
|
|
640
652
|
}
|
|
641
653
|
|
|
654
|
+
// ../core/dist/smart-screenshot.js
|
|
655
|
+
import { join as join5 } from "path";
|
|
656
|
+
import { mkdirSync as mkdirSync4, existsSync as existsSync5 } from "fs";
|
|
657
|
+
import simpleGit2 from "simple-git";
|
|
658
|
+
|
|
642
659
|
// ../core/dist/screenshot.js
|
|
643
660
|
var {spawn: spawn2 } = globalThis.Bun;
|
|
644
661
|
import { join as join4 } from "path";
|
|
@@ -681,6 +698,171 @@ async function captureScreenshot(port, projectPath, stashId) {
|
|
|
681
698
|
return `/api/screenshots/${filename}`;
|
|
682
699
|
}
|
|
683
700
|
|
|
701
|
+
// ../core/dist/smart-screenshot.js
|
|
702
|
+
var SCREENSHOTS_DIR2 = ".stashes/screenshots";
|
|
703
|
+
var DEFAULT_TIMEOUT = 120000;
|
|
704
|
+
var DIFF_MAX_CHARS = 1e4;
|
|
705
|
+
async function getStashDiff(worktreePath, parentBranch) {
|
|
706
|
+
const git = simpleGit2(worktreePath);
|
|
707
|
+
try {
|
|
708
|
+
const diff = await git.diff([parentBranch, "HEAD"]);
|
|
709
|
+
if (diff.length <= DIFF_MAX_CHARS)
|
|
710
|
+
return diff;
|
|
711
|
+
const diffStat = await git.diff([parentBranch, "HEAD", "--stat"]);
|
|
712
|
+
const truncatedDiff = diff.substring(0, DIFF_MAX_CHARS);
|
|
713
|
+
return `## Changed files:
|
|
714
|
+
${diffStat}
|
|
715
|
+
|
|
716
|
+
## Partial diff (truncated):
|
|
717
|
+
${truncatedDiff}`;
|
|
718
|
+
} catch (err) {
|
|
719
|
+
logger.warn("smart-screenshot", `git diff failed, falling back`, {
|
|
720
|
+
error: err instanceof Error ? err.message : String(err)
|
|
721
|
+
});
|
|
722
|
+
return "";
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
function buildScreenshotPrompt(port, diff, screenshotDir, stashId) {
|
|
726
|
+
return [
|
|
727
|
+
"You are a screenshot assistant. A developer made UI changes to a web app.",
|
|
728
|
+
"Your job: navigate the running app, find the pages affected by the changes, and take screenshots.",
|
|
729
|
+
"",
|
|
730
|
+
`## The app is running at: http://localhost:${port}`,
|
|
731
|
+
"",
|
|
732
|
+
"## Git diff of changes:",
|
|
733
|
+
"```",
|
|
734
|
+
diff,
|
|
735
|
+
"```",
|
|
736
|
+
"",
|
|
737
|
+
"## Instructions:",
|
|
738
|
+
"1. Analyze the diff to identify which components/pages changed",
|
|
739
|
+
"2. Determine the URL routes where these changes are visible",
|
|
740
|
+
"3. For each route:",
|
|
741
|
+
" - Navigate to it using browser_navigate",
|
|
742
|
+
" - If the change is behind an interaction (tab, modal, accordion), perform that interaction",
|
|
743
|
+
" - Wait for the page to settle",
|
|
744
|
+
" - Take a screenshot using browser_take_screenshot",
|
|
745
|
+
"4. Decide which screenshot shows the MOST visually significant change \u2014 mark it as primary",
|
|
746
|
+
"",
|
|
747
|
+
`## Screenshot save paths:`,
|
|
748
|
+
`- Primary: ${join5(screenshotDir, `${stashId}.png`)}`,
|
|
749
|
+
`- Additional: ${join5(screenshotDir, `${stashId}-1.png`)}, ${join5(screenshotDir, `${stashId}-2.png`)}, etc.`,
|
|
750
|
+
"",
|
|
751
|
+
"## Output format (after all screenshots are taken):",
|
|
752
|
+
"Respond with ONLY this JSON (no markdown fences):",
|
|
753
|
+
"{",
|
|
754
|
+
' "screenshots": [',
|
|
755
|
+
" {",
|
|
756
|
+
` "path": "${join5(screenshotDir, `${stashId}.png`)}",`,
|
|
757
|
+
' "label": "Short description of what is shown",',
|
|
758
|
+
' "route": "/the-url-path",',
|
|
759
|
+
' "isPrimary": true',
|
|
760
|
+
" }",
|
|
761
|
+
" ]",
|
|
762
|
+
"}"
|
|
763
|
+
].join(`
|
|
764
|
+
`);
|
|
765
|
+
}
|
|
766
|
+
function parseAiResult(text) {
|
|
767
|
+
try {
|
|
768
|
+
return JSON.parse(text);
|
|
769
|
+
} catch {
|
|
770
|
+
const jsonMatch = text.match(/\{[\s\S]*"screenshots"[\s\S]*\}/);
|
|
771
|
+
if (jsonMatch) {
|
|
772
|
+
try {
|
|
773
|
+
return JSON.parse(jsonMatch[0]);
|
|
774
|
+
} catch {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function captureSmartScreenshots(opts) {
|
|
782
|
+
const { projectPath, stashId, stashBranch, parentBranch, worktreePath, port, model = "haiku", timeout = DEFAULT_TIMEOUT } = opts;
|
|
783
|
+
const screenshotDir = join5(projectPath, SCREENSHOTS_DIR2);
|
|
784
|
+
if (!existsSync5(screenshotDir)) {
|
|
785
|
+
mkdirSync4(screenshotDir, { recursive: true });
|
|
786
|
+
}
|
|
787
|
+
const diff = await getStashDiff(worktreePath, parentBranch);
|
|
788
|
+
if (!diff) {
|
|
789
|
+
logger.info("smart-screenshot", `No diff found for ${stashId}, using simple screenshot`);
|
|
790
|
+
const url = await captureScreenshot(port, projectPath, stashId);
|
|
791
|
+
return {
|
|
792
|
+
primary: url,
|
|
793
|
+
screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const processId = `screenshot-ai-${stashId}`;
|
|
797
|
+
const prompt = buildScreenshotPrompt(port, diff, screenshotDir, stashId);
|
|
798
|
+
const modelFlag = model === "sonnet" ? "sonnet" : "haiku";
|
|
799
|
+
const aiProcess = startAiProcess(processId, prompt, worktreePath, undefined, modelFlag);
|
|
800
|
+
let textOutput = "";
|
|
801
|
+
let timedOut = false;
|
|
802
|
+
const timeoutId = setTimeout(() => {
|
|
803
|
+
timedOut = true;
|
|
804
|
+
killAiProcess(processId);
|
|
805
|
+
}, timeout);
|
|
806
|
+
try {
|
|
807
|
+
for await (const chunk of parseClaudeStream(aiProcess.process)) {
|
|
808
|
+
if (chunk.type === "text") {
|
|
809
|
+
textOutput += chunk.content;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
await aiProcess.process.exited;
|
|
813
|
+
} catch (err) {
|
|
814
|
+
logger.warn("smart-screenshot", `AI process error for ${stashId}`, {
|
|
815
|
+
error: err instanceof Error ? err.message : String(err),
|
|
816
|
+
timedOut
|
|
817
|
+
});
|
|
818
|
+
} finally {
|
|
819
|
+
clearTimeout(timeoutId);
|
|
820
|
+
killAiProcess(processId);
|
|
821
|
+
}
|
|
822
|
+
const result = parseAiResult(textOutput);
|
|
823
|
+
if (!result || !result.screenshots || result.screenshots.length === 0) {
|
|
824
|
+
logger.info("smart-screenshot", `AI returned no screenshots for ${stashId}, falling back`);
|
|
825
|
+
const url = await captureScreenshot(port, projectPath, stashId);
|
|
826
|
+
return {
|
|
827
|
+
primary: url,
|
|
828
|
+
screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
const screenshots = [];
|
|
832
|
+
let primaryUrl = "";
|
|
833
|
+
for (const shot of result.screenshots) {
|
|
834
|
+
const filename = shot.path.split("/").pop() || "";
|
|
835
|
+
if (!existsSync5(shot.path)) {
|
|
836
|
+
logger.warn("smart-screenshot", `Screenshot file not found: ${shot.path}`);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const url = `/api/screenshots/${filename}`;
|
|
840
|
+
const screenshot = {
|
|
841
|
+
url,
|
|
842
|
+
label: shot.label,
|
|
843
|
+
route: shot.route,
|
|
844
|
+
isPrimary: shot.isPrimary
|
|
845
|
+
};
|
|
846
|
+
screenshots.push(screenshot);
|
|
847
|
+
if (shot.isPrimary)
|
|
848
|
+
primaryUrl = url;
|
|
849
|
+
}
|
|
850
|
+
if (screenshots.length === 0) {
|
|
851
|
+
logger.info("smart-screenshot", `No valid screenshots for ${stashId}, falling back`);
|
|
852
|
+
const url = await captureScreenshot(port, projectPath, stashId);
|
|
853
|
+
return {
|
|
854
|
+
primary: url,
|
|
855
|
+
screenshots: [{ url, label: "Homepage", route: "/", isPrimary: true }]
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
if (!primaryUrl) {
|
|
859
|
+
primaryUrl = screenshots[0].url;
|
|
860
|
+
screenshots[0] = { ...screenshots[0], isPrimary: true };
|
|
861
|
+
}
|
|
862
|
+
logger.info("smart-screenshot", `Captured ${screenshots.length} smart screenshots for ${stashId}`);
|
|
863
|
+
return { primary: primaryUrl, screenshots };
|
|
864
|
+
}
|
|
865
|
+
|
|
684
866
|
// ../core/dist/prompt.js
|
|
685
867
|
function buildStashPrompt(component, sourceCode, userPrompt, directive) {
|
|
686
868
|
const parts = [
|
|
@@ -731,23 +913,6 @@ async function waitForPort(port, timeout) {
|
|
|
731
913
|
}
|
|
732
914
|
throw new Error(`Port ${port} not ready within ${timeout}ms`);
|
|
733
915
|
}
|
|
734
|
-
async function captureEphemeralScreenshot(worktreePath, projectPath, stashId, port) {
|
|
735
|
-
const devServer = spawn3({
|
|
736
|
-
cmd: ["npm", "run", "dev"],
|
|
737
|
-
cwd: worktreePath,
|
|
738
|
-
stdin: "ignore",
|
|
739
|
-
stdout: "pipe",
|
|
740
|
-
stderr: "pipe",
|
|
741
|
-
env: { ...process.env, PORT: String(port), BROWSER: "none" }
|
|
742
|
-
});
|
|
743
|
-
try {
|
|
744
|
-
await waitForPort(port, 60000);
|
|
745
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
746
|
-
return await captureScreenshot(port, projectPath, stashId);
|
|
747
|
-
} finally {
|
|
748
|
-
devServer.kill();
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
916
|
async function allocatePort() {
|
|
752
917
|
for (let port = 4010;port <= 4030; port++) {
|
|
753
918
|
try {
|
|
@@ -765,8 +930,8 @@ async function generate(opts) {
|
|
|
765
930
|
const selectedDirectives = directives.slice(0, count);
|
|
766
931
|
let sourceCode = "";
|
|
767
932
|
if (component?.filePath) {
|
|
768
|
-
const sourceFile =
|
|
769
|
-
if (
|
|
933
|
+
const sourceFile = join6(projectPath, component.filePath);
|
|
934
|
+
if (existsSync6(sourceFile)) {
|
|
770
935
|
sourceCode = readFileSync2(sourceFile, "utf-8");
|
|
771
936
|
}
|
|
772
937
|
}
|
|
@@ -788,6 +953,7 @@ async function generate(opts) {
|
|
|
788
953
|
worktreePath: worktree.path,
|
|
789
954
|
port: null,
|
|
790
955
|
screenshotUrl: null,
|
|
956
|
+
screenshots: [],
|
|
791
957
|
status: "generating",
|
|
792
958
|
error: null,
|
|
793
959
|
relatedTo: [],
|
|
@@ -812,7 +978,7 @@ async function generate(opts) {
|
|
|
812
978
|
});
|
|
813
979
|
}
|
|
814
980
|
await aiProcess.process.exited;
|
|
815
|
-
const wtGit =
|
|
981
|
+
const wtGit = simpleGit3(worktree.path);
|
|
816
982
|
try {
|
|
817
983
|
await wtGit.add("-A");
|
|
818
984
|
await wtGit.commit(`stashes: stash ${stashId}`);
|
|
@@ -835,15 +1001,40 @@ async function generate(opts) {
|
|
|
835
1001
|
try {
|
|
836
1002
|
const port = await allocatePort();
|
|
837
1003
|
const worktree = await worktreeManager.createForGeneration(`screenshot-${stash.id}`);
|
|
838
|
-
const screenshotGit =
|
|
1004
|
+
const screenshotGit = simpleGit3(worktree.path);
|
|
839
1005
|
await screenshotGit.checkout(["-f", stash.branch]);
|
|
840
|
-
const
|
|
1006
|
+
const devServer = spawn3({
|
|
1007
|
+
cmd: ["npm", "run", "dev"],
|
|
1008
|
+
cwd: worktree.path,
|
|
1009
|
+
stdin: "ignore",
|
|
1010
|
+
stdout: "pipe",
|
|
1011
|
+
stderr: "pipe",
|
|
1012
|
+
env: { ...process.env, PORT: String(port), BROWSER: "none" }
|
|
1013
|
+
});
|
|
1014
|
+
try {
|
|
1015
|
+
await waitForPort(port, 60000);
|
|
1016
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1017
|
+
const mainGit = simpleGit3(projectPath);
|
|
1018
|
+
const parentBranch = (await mainGit.revparse(["HEAD"])).trim();
|
|
1019
|
+
const { primary, screenshots } = await captureSmartScreenshots({
|
|
1020
|
+
projectPath,
|
|
1021
|
+
stashId: stash.id,
|
|
1022
|
+
stashBranch: stash.branch,
|
|
1023
|
+
parentBranch,
|
|
1024
|
+
worktreePath: worktree.path,
|
|
1025
|
+
port,
|
|
1026
|
+
model: opts.screenshotModel,
|
|
1027
|
+
timeout: opts.screenshotTimeout
|
|
1028
|
+
});
|
|
1029
|
+
const updatedStash = { ...stash, screenshotUrl: primary, screenshots };
|
|
1030
|
+
persistence.saveStash(updatedStash);
|
|
1031
|
+
const idx = completedStashes.indexOf(stash);
|
|
1032
|
+
completedStashes[idx] = updatedStash;
|
|
1033
|
+
emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath: primary });
|
|
1034
|
+
} finally {
|
|
1035
|
+
devServer.kill();
|
|
1036
|
+
}
|
|
841
1037
|
await worktreeManager.removeGeneration(`screenshot-${stash.id}`);
|
|
842
|
-
const updatedStash = { ...stash, screenshotUrl: screenshotPath };
|
|
843
|
-
persistence.saveStash(updatedStash);
|
|
844
|
-
const idx = completedStashes.indexOf(stash);
|
|
845
|
-
completedStashes[idx] = updatedStash;
|
|
846
|
-
emit(onProgress, { type: "ready", stashId: stash.id, screenshotPath });
|
|
847
1038
|
} catch (err) {
|
|
848
1039
|
logger.error("generation", `screenshot failed for ${stash.id}`, {
|
|
849
1040
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -855,7 +1046,7 @@ async function generate(opts) {
|
|
|
855
1046
|
}
|
|
856
1047
|
// ../core/dist/vary.js
|
|
857
1048
|
var {spawn: spawn4 } = globalThis.Bun;
|
|
858
|
-
import
|
|
1049
|
+
import simpleGit4 from "simple-git";
|
|
859
1050
|
function emit2(onProgress, event) {
|
|
860
1051
|
if (onProgress)
|
|
861
1052
|
onProgress(event);
|
|
@@ -913,6 +1104,7 @@ async function vary(opts) {
|
|
|
913
1104
|
worktreePath: worktree.path,
|
|
914
1105
|
port: null,
|
|
915
1106
|
screenshotUrl: null,
|
|
1107
|
+
screenshots: [],
|
|
916
1108
|
status: "generating",
|
|
917
1109
|
error: null,
|
|
918
1110
|
relatedTo: [sourceStashId],
|
|
@@ -932,7 +1124,7 @@ async function vary(opts) {
|
|
|
932
1124
|
});
|
|
933
1125
|
}
|
|
934
1126
|
await aiProcess.process.exited;
|
|
935
|
-
const wtGit =
|
|
1127
|
+
const wtGit = simpleGit4(worktree.path);
|
|
936
1128
|
try {
|
|
937
1129
|
await wtGit.add("-A");
|
|
938
1130
|
await wtGit.commit(`stashes: vary ${stashId} from ${sourceStashId}`);
|
|
@@ -940,10 +1132,11 @@ async function vary(opts) {
|
|
|
940
1132
|
await worktreeManager.removeGeneration(stashId);
|
|
941
1133
|
emit2(onProgress, { type: "screenshotting", stashId });
|
|
942
1134
|
let screenshotPath = "";
|
|
1135
|
+
let screenshots = [];
|
|
943
1136
|
try {
|
|
944
1137
|
const port = await allocatePort2();
|
|
945
1138
|
const screenshotWorktree = await worktreeManager.createForGeneration(`screenshot-${stashId}`);
|
|
946
|
-
const screenshotGit =
|
|
1139
|
+
const screenshotGit = simpleGit4(screenshotWorktree.path);
|
|
947
1140
|
await screenshotGit.checkout(["-f", stash.branch]);
|
|
948
1141
|
const devServer = spawn4({
|
|
949
1142
|
cmd: ["npm", "run", "dev"],
|
|
@@ -956,7 +1149,18 @@ async function vary(opts) {
|
|
|
956
1149
|
try {
|
|
957
1150
|
await waitForPort2(port, 60000);
|
|
958
1151
|
await new Promise((r) => setTimeout(r, 2000));
|
|
959
|
-
|
|
1152
|
+
const result = await captureSmartScreenshots({
|
|
1153
|
+
projectPath,
|
|
1154
|
+
stashId,
|
|
1155
|
+
stashBranch: stash.branch,
|
|
1156
|
+
parentBranch: sourceStash.branch,
|
|
1157
|
+
worktreePath: screenshotWorktree.path,
|
|
1158
|
+
port,
|
|
1159
|
+
model: opts.screenshotModel,
|
|
1160
|
+
timeout: opts.screenshotTimeout
|
|
1161
|
+
});
|
|
1162
|
+
screenshotPath = result.primary;
|
|
1163
|
+
screenshots = [...result.screenshots];
|
|
960
1164
|
} finally {
|
|
961
1165
|
devServer.kill();
|
|
962
1166
|
}
|
|
@@ -966,7 +1170,7 @@ async function vary(opts) {
|
|
|
966
1170
|
error: err instanceof Error ? err.message : String(err)
|
|
967
1171
|
});
|
|
968
1172
|
}
|
|
969
|
-
const readyStash = { ...stash, status: "ready", screenshotUrl: screenshotPath || null };
|
|
1173
|
+
const readyStash = { ...stash, status: "ready", screenshotUrl: screenshotPath || null, screenshots };
|
|
970
1174
|
persistence.saveStash(readyStash);
|
|
971
1175
|
emit2(onProgress, { type: "ready", stashId, screenshotPath });
|
|
972
1176
|
return readyStash;
|
|
@@ -990,7 +1194,7 @@ async function apply(opts) {
|
|
|
990
1194
|
logger.info("apply", `stash ${stashId} applied and worktrees cleaned up`);
|
|
991
1195
|
}
|
|
992
1196
|
// ../core/dist/manage.js
|
|
993
|
-
import
|
|
1197
|
+
import simpleGit5 from "simple-git";
|
|
994
1198
|
async function list(projectPath) {
|
|
995
1199
|
const persistence = new PersistenceService(projectPath);
|
|
996
1200
|
const projects = persistence.listProjects();
|
|
@@ -1011,7 +1215,7 @@ async function remove(projectPath, stashId) {
|
|
|
1011
1215
|
}
|
|
1012
1216
|
}
|
|
1013
1217
|
try {
|
|
1014
|
-
const git =
|
|
1218
|
+
const git = simpleGit5(projectPath);
|
|
1015
1219
|
await git.raw(["branch", "-D", `stashes/${stashId}`]);
|
|
1016
1220
|
} catch {}
|
|
1017
1221
|
logger.info("manage", `removed stash: ${stashId}`);
|
|
@@ -1026,7 +1230,7 @@ async function show(projectPath, stashId) {
|
|
|
1026
1230
|
}
|
|
1027
1231
|
if (!found)
|
|
1028
1232
|
return null;
|
|
1029
|
-
const git =
|
|
1233
|
+
const git = simpleGit5(projectPath);
|
|
1030
1234
|
const branch = found.branch || `stashes/${stashId}`;
|
|
1031
1235
|
let diff = "";
|
|
1032
1236
|
let files = [];
|
|
@@ -1223,14 +1427,14 @@ async function handleRemove(args, projectPath) {
|
|
|
1223
1427
|
// ../server/dist/index.js
|
|
1224
1428
|
import { Hono as Hono2 } from "hono";
|
|
1225
1429
|
import { cors } from "hono/cors";
|
|
1226
|
-
import { join as
|
|
1430
|
+
import { join as join9, dirname as dirname2 } from "path";
|
|
1227
1431
|
import { fileURLToPath } from "url";
|
|
1228
|
-
import { existsSync as
|
|
1432
|
+
import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
|
|
1229
1433
|
|
|
1230
1434
|
// ../server/dist/routes/api.js
|
|
1231
1435
|
import { Hono } from "hono";
|
|
1232
|
-
import { join as
|
|
1233
|
-
import { existsSync as
|
|
1436
|
+
import { join as join7, basename } from "path";
|
|
1437
|
+
import { existsSync as existsSync7, readFileSync as readFileSync3 } from "fs";
|
|
1234
1438
|
var app = new Hono;
|
|
1235
1439
|
app.get("/health", (c) => c.json({ status: "ok", service: "stashes" }));
|
|
1236
1440
|
app.get("/projects", (c) => {
|
|
@@ -1311,8 +1515,8 @@ app.delete("/chats/:chatId", (c) => {
|
|
|
1311
1515
|
});
|
|
1312
1516
|
app.get("/screenshots/:filename", (c) => {
|
|
1313
1517
|
const filename = c.req.param("filename");
|
|
1314
|
-
const filePath =
|
|
1315
|
-
if (!
|
|
1518
|
+
const filePath = join7(serverState.projectPath, ".stashes", "screenshots", filename);
|
|
1519
|
+
if (!existsSync7(filePath))
|
|
1316
1520
|
return c.json({ error: "Not found" }, 404);
|
|
1317
1521
|
const content = readFileSync3(filePath);
|
|
1318
1522
|
return new Response(content, {
|
|
@@ -1336,8 +1540,8 @@ function ensureProject(persistence) {
|
|
|
1336
1540
|
var apiRoutes = app;
|
|
1337
1541
|
|
|
1338
1542
|
// ../server/dist/services/stash-service.js
|
|
1339
|
-
import { readFileSync as readFileSync4, existsSync as
|
|
1340
|
-
import { join as
|
|
1543
|
+
import { readFileSync as readFileSync4, existsSync as existsSync8 } from "fs";
|
|
1544
|
+
import { join as join8 } from "path";
|
|
1341
1545
|
|
|
1342
1546
|
// ../server/dist/services/preview-pool.js
|
|
1343
1547
|
class PreviewPool {
|
|
@@ -1600,8 +1804,8 @@ class StashService {
|
|
|
1600
1804
|
let sourceCode = "";
|
|
1601
1805
|
const filePath = component?.filePath || "";
|
|
1602
1806
|
if (filePath && filePath !== "auto-detect") {
|
|
1603
|
-
const sourceFile =
|
|
1604
|
-
if (
|
|
1807
|
+
const sourceFile = join8(this.projectPath, filePath);
|
|
1808
|
+
if (existsSync8(sourceFile)) {
|
|
1605
1809
|
sourceCode = readFileSync4(sourceFile, "utf-8");
|
|
1606
1810
|
}
|
|
1607
1811
|
}
|
|
@@ -1776,7 +1980,8 @@ ${sourceCode.substring(0, 3000)}
|
|
|
1776
1980
|
this.broadcast({
|
|
1777
1981
|
type: "stash:screenshot",
|
|
1778
1982
|
stashId: stash.id,
|
|
1779
|
-
url: stash.screenshotUrl
|
|
1983
|
+
url: stash.screenshotUrl,
|
|
1984
|
+
screenshots: stash.screenshots?.length ? [...stash.screenshots] : undefined
|
|
1780
1985
|
});
|
|
1781
1986
|
}
|
|
1782
1987
|
}
|
|
@@ -1793,7 +1998,12 @@ ${sourceCode.substring(0, 3000)}
|
|
|
1793
1998
|
..."number" in event ? { number: event.number } : {}
|
|
1794
1999
|
});
|
|
1795
2000
|
if (event.type === "ready" && "screenshotPath" in event && event.screenshotPath) {
|
|
1796
|
-
this.broadcast({
|
|
2001
|
+
this.broadcast({
|
|
2002
|
+
type: "stash:screenshot",
|
|
2003
|
+
stashId: event.stashId,
|
|
2004
|
+
url: event.screenshotPath,
|
|
2005
|
+
screenshots: "screenshots" in event ? event.screenshots : undefined
|
|
2006
|
+
});
|
|
1797
2007
|
}
|
|
1798
2008
|
break;
|
|
1799
2009
|
case "error":
|
|
@@ -1807,6 +2017,7 @@ ${sourceCode.substring(0, 3000)}
|
|
|
1807
2017
|
}
|
|
1808
2018
|
}
|
|
1809
2019
|
async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
|
|
2020
|
+
const settings = this.persistence.getProjectSettings(projectId);
|
|
1810
2021
|
let enrichedPrompt = prompt;
|
|
1811
2022
|
if (referenceStashIds?.length) {
|
|
1812
2023
|
const refDescriptions = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
|
|
@@ -1824,14 +2035,27 @@ ${refDescriptions.join(`
|
|
|
1824
2035
|
prompt: enrichedPrompt,
|
|
1825
2036
|
component: this.selectedComponent ? { filePath: this.selectedComponent.filePath, exportName: this.selectedComponent.name } : undefined,
|
|
1826
2037
|
count: stashCount,
|
|
2038
|
+
screenshotModel: settings.screenshotModel,
|
|
2039
|
+
screenshotTimeout: settings.screenshotTimeout,
|
|
1827
2040
|
onProgress: (event) => this.progressToBroadcast(event)
|
|
1828
2041
|
});
|
|
1829
2042
|
}
|
|
1830
2043
|
async vary(sourceStashId, prompt) {
|
|
2044
|
+
let projectId = "";
|
|
2045
|
+
for (const project of this.persistence.listProjects()) {
|
|
2046
|
+
const stashes = this.persistence.listStashes(project.id);
|
|
2047
|
+
if (stashes.find((s) => s.id === sourceStashId)) {
|
|
2048
|
+
projectId = project.id;
|
|
2049
|
+
break;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const settings = projectId ? this.persistence.getProjectSettings(projectId) : {};
|
|
1831
2053
|
await vary({
|
|
1832
2054
|
projectPath: this.projectPath,
|
|
1833
2055
|
sourceStashId,
|
|
1834
2056
|
prompt,
|
|
2057
|
+
screenshotModel: settings.screenshotModel,
|
|
2058
|
+
screenshotTimeout: settings.screenshotTimeout,
|
|
1835
2059
|
onProgress: (event) => this.progressToBroadcast(event)
|
|
1836
2060
|
});
|
|
1837
2061
|
}
|
|
@@ -1945,105 +2169,93 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
|
|
|
1945
2169
|
}
|
|
1946
2170
|
|
|
1947
2171
|
// ../server/dist/services/app-proxy.js
|
|
2172
|
+
import { spawn as spawn5 } from "child_process";
|
|
1948
2173
|
function startAppProxy(userDevPort, proxyPort, injectOverlay) {
|
|
1949
|
-
const
|
|
1950
|
-
const
|
|
1951
|
-
const
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
const contentType = response.headers.get("content-type") || "";
|
|
1983
|
-
const respHeaders = new Headers(response.headers);
|
|
1984
|
-
if (response.status >= 300 && response.status < 400) {
|
|
1985
|
-
const location = respHeaders.get("location");
|
|
1986
|
-
if (location?.startsWith(upstreamOrigin)) {
|
|
1987
|
-
respHeaders.set("location", proxyOrigin + location.substring(upstreamOrigin.length));
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
respHeaders.delete("content-encoding");
|
|
1991
|
-
return new Response(response.body, {
|
|
1992
|
-
status: response.status,
|
|
1993
|
-
headers: respHeaders
|
|
1994
|
-
});
|
|
1995
|
-
} catch (err) {
|
|
1996
|
-
return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
|
|
1997
|
-
status: 502,
|
|
1998
|
-
headers: { "content-type": "application/json" }
|
|
1999
|
-
});
|
|
2000
|
-
}
|
|
2001
|
-
},
|
|
2002
|
-
websocket: {
|
|
2003
|
-
open(ws) {
|
|
2004
|
-
const { data } = ws;
|
|
2005
|
-
const upstream = new WebSocket(`ws://localhost:${userDevPort}${data.path}`);
|
|
2006
|
-
upstream.addEventListener("open", () => {
|
|
2007
|
-
data.upstream = upstream;
|
|
2008
|
-
data.ready = true;
|
|
2009
|
-
for (const msg of data.buffer) {
|
|
2010
|
-
upstream.send(msg);
|
|
2011
|
-
}
|
|
2012
|
-
data.buffer = [];
|
|
2013
|
-
});
|
|
2014
|
-
upstream.addEventListener("message", (event) => {
|
|
2015
|
-
try {
|
|
2016
|
-
ws.sendText(typeof event.data === "string" ? event.data : String(event.data));
|
|
2017
|
-
} catch {}
|
|
2018
|
-
});
|
|
2019
|
-
upstream.addEventListener("close", () => {
|
|
2020
|
-
try {
|
|
2021
|
-
ws.close();
|
|
2022
|
-
} catch {}
|
|
2023
|
-
});
|
|
2024
|
-
upstream.addEventListener("error", () => {
|
|
2025
|
-
try {
|
|
2026
|
-
ws.close();
|
|
2027
|
-
} catch {}
|
|
2028
|
-
});
|
|
2029
|
-
},
|
|
2030
|
-
message(ws, msg) {
|
|
2031
|
-
const { data } = ws;
|
|
2032
|
-
if (data.ready && data.upstream) {
|
|
2033
|
-
data.upstream.send(typeof msg === "string" ? msg : new Uint8Array(msg));
|
|
2034
|
-
} else {
|
|
2035
|
-
data.buffer.push(typeof msg === "string" ? msg : new Uint8Array(msg).buffer);
|
|
2174
|
+
const overlayScript = injectOverlay("", userDevPort, proxyPort);
|
|
2175
|
+
const overlayEscaped = JSON.stringify(overlayScript);
|
|
2176
|
+
const proxyScript = `
|
|
2177
|
+
const http = require('http');
|
|
2178
|
+
const net = require('net');
|
|
2179
|
+
const zlib = require('zlib');
|
|
2180
|
+
const UPSTREAM = ${userDevPort};
|
|
2181
|
+
const OVERLAY = ${overlayEscaped};
|
|
2182
|
+
|
|
2183
|
+
const server = http.createServer((clientReq, clientRes) => {
|
|
2184
|
+
const opts = {
|
|
2185
|
+
hostname: 'localhost',
|
|
2186
|
+
port: UPSTREAM,
|
|
2187
|
+
path: clientReq.url,
|
|
2188
|
+
method: clientReq.method,
|
|
2189
|
+
headers: clientReq.headers,
|
|
2190
|
+
};
|
|
2191
|
+
const proxyReq = http.request(opts, (proxyRes) => {
|
|
2192
|
+
const ct = proxyRes.headers['content-type'] || '';
|
|
2193
|
+
if (ct.includes('text/html')) {
|
|
2194
|
+
// Buffer HTML to inject overlay
|
|
2195
|
+
const chunks = [];
|
|
2196
|
+
proxyRes.on('data', c => chunks.push(c));
|
|
2197
|
+
proxyRes.on('end', () => {
|
|
2198
|
+
let html = Buffer.concat(chunks);
|
|
2199
|
+
const enc = proxyRes.headers['content-encoding'];
|
|
2200
|
+
// Decompress if needed
|
|
2201
|
+
if (enc === 'gzip') {
|
|
2202
|
+
try { html = zlib.gunzipSync(html); } catch {}
|
|
2203
|
+
} else if (enc === 'br') {
|
|
2204
|
+
try { html = zlib.brotliDecompressSync(html); } catch {}
|
|
2205
|
+
} else if (enc === 'deflate') {
|
|
2206
|
+
try { html = zlib.inflateSync(html); } catch {}
|
|
2036
2207
|
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2208
|
+
const hdrs = { ...proxyRes.headers };
|
|
2209
|
+
delete hdrs['content-length'];
|
|
2210
|
+
delete hdrs['content-encoding'];
|
|
2211
|
+
delete hdrs['transfer-encoding'];
|
|
2212
|
+
clientRes.writeHead(proxyRes.statusCode, hdrs);
|
|
2213
|
+
clientRes.write(html);
|
|
2214
|
+
clientRes.end(OVERLAY);
|
|
2215
|
+
});
|
|
2216
|
+
} else {
|
|
2217
|
+
// Non-HTML: stream through unchanged
|
|
2218
|
+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
2219
|
+
proxyRes.pipe(clientRes);
|
|
2043
2220
|
}
|
|
2221
|
+
proxyRes.on('error', () => clientRes.end());
|
|
2044
2222
|
});
|
|
2045
|
-
|
|
2046
|
-
|
|
2223
|
+
proxyReq.on('error', () => { try { clientRes.writeHead(502); clientRes.end(); } catch {} });
|
|
2224
|
+
clientReq.pipe(proxyReq);
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
// WebSocket upgrades: raw TCP pipe
|
|
2228
|
+
server.on('upgrade', (req, socket, head) => {
|
|
2229
|
+
const upstream = net.createConnection(UPSTREAM, 'localhost', () => {
|
|
2230
|
+
const lines = [req.method + ' ' + req.url + ' HTTP/1.1'];
|
|
2231
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
2232
|
+
lines.push(k + ': ' + (Array.isArray(v) ? v.join(', ') : v));
|
|
2233
|
+
}
|
|
2234
|
+
upstream.write(lines.join('\\r\\n') + '\\r\\n\\r\\n');
|
|
2235
|
+
if (head.length) upstream.write(head);
|
|
2236
|
+
socket.pipe(upstream);
|
|
2237
|
+
upstream.pipe(socket);
|
|
2238
|
+
});
|
|
2239
|
+
upstream.on('error', () => socket.destroy());
|
|
2240
|
+
socket.on('error', () => upstream.destroy());
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
server.listen(${proxyPort}, () => {
|
|
2244
|
+
if (process.send) process.send('ready');
|
|
2245
|
+
});
|
|
2246
|
+
`;
|
|
2247
|
+
const child = spawn5("node", ["-e", proxyScript], {
|
|
2248
|
+
stdio: ["ignore", "inherit", "inherit", "ipc"]
|
|
2249
|
+
});
|
|
2250
|
+
child.on("error", (err) => {
|
|
2251
|
+
logger.error("proxy", `Failed to start proxy: ${err.message}`);
|
|
2252
|
+
});
|
|
2253
|
+
child.on("message", (msg) => {
|
|
2254
|
+
if (msg === "ready") {
|
|
2255
|
+
logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
2258
|
+
return child;
|
|
2047
2259
|
}
|
|
2048
2260
|
|
|
2049
2261
|
// ../server/dist/index.js
|
|
@@ -2060,12 +2272,12 @@ app2.route("/api", apiRoutes);
|
|
|
2060
2272
|
app2.get("/*", async (c) => {
|
|
2061
2273
|
const path = c.req.path;
|
|
2062
2274
|
const selfDir = dirname2(fileURLToPath(import.meta.url));
|
|
2063
|
-
const bundledWebDir =
|
|
2064
|
-
const monorepoWebDir =
|
|
2065
|
-
const webDistDir =
|
|
2275
|
+
const bundledWebDir = join9(selfDir, "web");
|
|
2276
|
+
const monorepoWebDir = join9(selfDir, "../../web/dist");
|
|
2277
|
+
const webDistDir = existsSync9(join9(bundledWebDir, "index.html")) ? bundledWebDir : monorepoWebDir;
|
|
2066
2278
|
const requestPath = path === "/" ? "/index.html" : path;
|
|
2067
|
-
const filePath =
|
|
2068
|
-
if (
|
|
2279
|
+
const filePath = join9(webDistDir, requestPath);
|
|
2280
|
+
if (existsSync9(filePath) && !filePath.includes("..")) {
|
|
2069
2281
|
const content = readFileSync5(filePath);
|
|
2070
2282
|
const ext = filePath.split(".").pop() || "";
|
|
2071
2283
|
const contentTypes = {
|
|
@@ -2084,8 +2296,8 @@ app2.get("/*", async (c) => {
|
|
|
2084
2296
|
headers: { "content-type": contentTypes[ext] || "application/octet-stream" }
|
|
2085
2297
|
});
|
|
2086
2298
|
}
|
|
2087
|
-
const indexPath =
|
|
2088
|
-
if (
|
|
2299
|
+
const indexPath = join9(webDistDir, "index.html");
|
|
2300
|
+
if (existsSync9(indexPath)) {
|
|
2089
2301
|
const html = readFileSync5(indexPath, "utf-8");
|
|
2090
2302
|
return new Response(html, {
|
|
2091
2303
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
@@ -2121,7 +2333,7 @@ function startServer(projectPath, userDevPort, port = STASHES_PORT) {
|
|
|
2121
2333
|
logger.info("server", `Project: ${projectPath}`);
|
|
2122
2334
|
return server;
|
|
2123
2335
|
}
|
|
2124
|
-
function injectOverlayScript(html,
|
|
2336
|
+
function injectOverlayScript(html, _upstreamPort, _proxyPort) {
|
|
2125
2337
|
const overlayScript = `
|
|
2126
2338
|
<script data-stashes-overlay>
|
|
2127
2339
|
(function() {
|
|
@@ -2129,35 +2341,6 @@ function injectOverlayScript(html, upstreamPort, proxyPort) {
|
|
|
2129
2341
|
var pickerEnabled = false;
|
|
2130
2342
|
var precisionMode = false;
|
|
2131
2343
|
|
|
2132
|
-
// Rewrite cross-origin requests to the upstream dev server through the proxy
|
|
2133
|
-
var upstreamOrigin = 'http://localhost:${upstreamPort}';
|
|
2134
|
-
var proxyOrigin = window.location.origin;
|
|
2135
|
-
if (proxyOrigin !== upstreamOrigin) {
|
|
2136
|
-
function rewriteUrl(url) {
|
|
2137
|
-
if (typeof url === 'string' && (url.startsWith(upstreamOrigin + '/') || url === upstreamOrigin)) {
|
|
2138
|
-
return proxyOrigin + url.substring(upstreamOrigin.length);
|
|
2139
|
-
}
|
|
2140
|
-
return url;
|
|
2141
|
-
}
|
|
2142
|
-
var origFetch = window.fetch;
|
|
2143
|
-
window.fetch = function(input, init) {
|
|
2144
|
-
if (typeof input === 'string') {
|
|
2145
|
-
input = rewriteUrl(input);
|
|
2146
|
-
} else if (input instanceof Request) {
|
|
2147
|
-
var rewritten = rewriteUrl(input.url);
|
|
2148
|
-
if (rewritten !== input.url) { input = new Request(rewritten, input); }
|
|
2149
|
-
}
|
|
2150
|
-
return origFetch.call(window, input, init);
|
|
2151
|
-
};
|
|
2152
|
-
var origXhrOpen = XMLHttpRequest.prototype.open;
|
|
2153
|
-
XMLHttpRequest.prototype.open = function() {
|
|
2154
|
-
if (arguments.length >= 2 && typeof arguments[1] === 'string') {
|
|
2155
|
-
arguments[1] = rewriteUrl(arguments[1]);
|
|
2156
|
-
}
|
|
2157
|
-
return origXhrOpen.apply(this, arguments);
|
|
2158
|
-
};
|
|
2159
|
-
}
|
|
2160
|
-
|
|
2161
2344
|
function createOverlay() {
|
|
2162
2345
|
var overlay = document.createElement('div');
|
|
2163
2346
|
overlay.id = 'stashes-highlight';
|
|
@@ -2336,11 +2519,11 @@ function injectOverlayScript(html, upstreamPort, proxyPort) {
|
|
|
2336
2519
|
import open from "open";
|
|
2337
2520
|
|
|
2338
2521
|
// ../server/dist/services/detector.js
|
|
2339
|
-
import { existsSync as
|
|
2340
|
-
import { join as
|
|
2522
|
+
import { existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
|
|
2523
|
+
import { join as join10 } from "path";
|
|
2341
2524
|
function detectFramework(projectPath) {
|
|
2342
|
-
const packageJsonPath =
|
|
2343
|
-
if (!
|
|
2525
|
+
const packageJsonPath = join10(projectPath, "package.json");
|
|
2526
|
+
if (!existsSync10(packageJsonPath)) {
|
|
2344
2527
|
return {
|
|
2345
2528
|
framework: "unknown",
|
|
2346
2529
|
devCommand: "npm run dev",
|
|
@@ -2402,7 +2585,7 @@ function getDevCommand(packageJson, fallback) {
|
|
|
2402
2585
|
}
|
|
2403
2586
|
function findConfig(projectPath, candidates) {
|
|
2404
2587
|
for (const candidate of candidates) {
|
|
2405
|
-
if (
|
|
2588
|
+
if (existsSync10(join10(projectPath, candidate))) {
|
|
2406
2589
|
return candidate;
|
|
2407
2590
|
}
|
|
2408
2591
|
}
|