letmecook 0.0.21 → 0.0.23
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/index.ts +90 -256
- package/package.json +7 -2
- package/src/agents-md.ts +12 -15
- package/src/chat-logger.ts +220 -0
- package/src/chat-mode.ts +465 -0
- package/src/cli-mode.ts +366 -0
- package/src/config-builder.ts +147 -0
- package/src/env.ts +76 -0
- package/src/flows/add-repos.ts +51 -115
- package/src/flows/chat-to-config.ts +373 -0
- package/src/flows/new-session.ts +69 -145
- package/src/flows/resume-session.ts +33 -37
- package/src/git.ts +39 -77
- package/src/naming.ts +2 -2
- package/src/prompts/chat-prompt.ts +143 -0
- package/src/schemas.ts +82 -0
- package/src/splash.ts +199 -0
- package/src/tui-mode.ts +41 -0
- package/src/types.ts +16 -78
- package/src/ui/add-repos.ts +34 -26
- package/src/ui/agent-proposal.ts +13 -1
- package/src/ui/chat-confirmation.ts +151 -0
- package/src/ui/chat-with-sidebar.ts +524 -0
- package/src/ui/common/clipboard.ts +105 -0
- package/src/ui/common/keyboard.ts +7 -0
- package/src/ui/common/repo-formatter.ts +4 -4
- package/src/ui/cooking-indicator.ts +88 -0
- package/src/ui/main-menu.ts +8 -0
- package/src/ui/new-session.ts +2 -2
- package/src/ui/progress.ts +1 -1
- package/src/ui/renderer.ts +7 -14
- package/src/ui/session-settings.ts +4 -3
- package/src/validation.ts +152 -0
- package/src/reference-repo.ts +0 -288
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type CliRenderer, TextRenderable } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
// [uncooked, cooked] emoji pairs
|
|
4
|
+
const FOOD_PAIRS: [string, string][] = [
|
|
5
|
+
["🦐", "🍤"], // shrimp
|
|
6
|
+
["🥔", "🍟"], // potato
|
|
7
|
+
["🐷", "🥓"], // bacon
|
|
8
|
+
["🥚", "🍳"], // egg
|
|
9
|
+
["🥩", "🍖"], // meat
|
|
10
|
+
["🌽", "🍿"], // corn
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const COUNT = 5;
|
|
14
|
+
const FRAME_MS = 120;
|
|
15
|
+
|
|
16
|
+
export interface CookingIndicator {
|
|
17
|
+
start: () => void;
|
|
18
|
+
stop: () => void;
|
|
19
|
+
isRunning: () => boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createCookingIndicator(
|
|
23
|
+
renderer: CliRenderer,
|
|
24
|
+
parent: { add: (child: unknown) => void; remove: (id: string) => void },
|
|
25
|
+
): CookingIndicator {
|
|
26
|
+
const pair = FOOD_PAIRS[Math.floor(Math.random() * FOOD_PAIRS.length)] || ["🦐", "🍤"];
|
|
27
|
+
const raw = pair[0];
|
|
28
|
+
const cooked = pair[1];
|
|
29
|
+
|
|
30
|
+
const indicator = new TextRenderable(renderer, {
|
|
31
|
+
id: "cooking-indicator",
|
|
32
|
+
content: "",
|
|
33
|
+
fg: "#f8fafc",
|
|
34
|
+
});
|
|
35
|
+
parent.add(indicator);
|
|
36
|
+
|
|
37
|
+
let frame = 1;
|
|
38
|
+
let direction = 1;
|
|
39
|
+
let interval: NodeJS.Timeout | null = null;
|
|
40
|
+
|
|
41
|
+
const render = () => {
|
|
42
|
+
let content = "";
|
|
43
|
+
for (let i = 0; i < COUNT; i++) {
|
|
44
|
+
if (i < frame) {
|
|
45
|
+
content += cooked;
|
|
46
|
+
} else {
|
|
47
|
+
content += raw;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
indicator.content = content;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const tick = () => {
|
|
54
|
+
frame += direction;
|
|
55
|
+
if (frame > COUNT) {
|
|
56
|
+
frame = COUNT - 1;
|
|
57
|
+
direction = -1;
|
|
58
|
+
} else if (frame < 1) {
|
|
59
|
+
frame = 1;
|
|
60
|
+
direction = 1;
|
|
61
|
+
}
|
|
62
|
+
render();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const start = () => {
|
|
66
|
+
if (interval) return;
|
|
67
|
+
frame = 1;
|
|
68
|
+
direction = 1;
|
|
69
|
+
render();
|
|
70
|
+
interval = setInterval(tick, FRAME_MS);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const stop = () => {
|
|
74
|
+
if (interval) {
|
|
75
|
+
clearInterval(interval);
|
|
76
|
+
interval = null;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
parent.remove("cooking-indicator");
|
|
80
|
+
} catch {
|
|
81
|
+
// Already removed
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isRunning = () => interval !== null;
|
|
86
|
+
|
|
87
|
+
return { start, stop, isRunning };
|
|
88
|
+
}
|
package/src/ui/main-menu.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { showFooter, hideFooter } from "./common/footer";
|
|
|
12
12
|
import { isEscape, isArrowUp, isArrowDown } from "./common/keyboard";
|
|
13
13
|
|
|
14
14
|
export type MainMenuAction =
|
|
15
|
+
| { type: "chat" }
|
|
15
16
|
| { type: "new-session" }
|
|
16
17
|
| { type: "resume"; session: Session }
|
|
17
18
|
| { type: "delete"; session: Session }
|
|
@@ -89,6 +90,12 @@ export function showMainMenu(renderer: CliRenderer, sessions: Session[]): Promis
|
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
if (key.name === "c") {
|
|
94
|
+
cleanup();
|
|
95
|
+
resolve({ type: "chat" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
92
99
|
if (key.name === "n") {
|
|
93
100
|
cleanup();
|
|
94
101
|
resolve({ type: "new-session" });
|
|
@@ -119,6 +126,7 @@ export function showMainMenu(renderer: CliRenderer, sessions: Session[]): Promis
|
|
|
119
126
|
|
|
120
127
|
// Show footer with context-aware actions
|
|
121
128
|
const footerActions: string[] = [];
|
|
129
|
+
footerActions.push("c Chat");
|
|
122
130
|
if (sessions.length > 0) {
|
|
123
131
|
footerActions.push("Enter Open", "n New", "d Delete", "a Nuke");
|
|
124
132
|
} else {
|
package/src/ui/new-session.ts
CHANGED
|
@@ -27,10 +27,10 @@ export function showNewSessionPrompt(
|
|
|
27
27
|
|
|
28
28
|
repos.forEach((repo, i) => {
|
|
29
29
|
const branch = repo.branch ? ` (${repo.branch})` : " (default)";
|
|
30
|
-
const
|
|
30
|
+
const roMarker = repo.readOnly ? " [Read-only]" : "";
|
|
31
31
|
const repoText = new TextRenderable(renderer, {
|
|
32
32
|
id: `repo-${i}`,
|
|
33
|
-
content: ` - ${repo.owner}/${repo.name}${branch}${
|
|
33
|
+
content: ` - ${repo.owner}/${repo.name}${branch}${roMarker}`,
|
|
34
34
|
fg: "#94a3b8",
|
|
35
35
|
});
|
|
36
36
|
content.add(repoText);
|
package/src/ui/progress.ts
CHANGED
|
@@ -52,7 +52,7 @@ function getPhasePresentation(phase: ProgressPhase): { content: string; fg: stri
|
|
|
52
52
|
case "installing-skills":
|
|
53
53
|
return { content: "Installing skills...", fg: "#38bdf8" };
|
|
54
54
|
case "refreshing":
|
|
55
|
-
return { content: "Refreshing
|
|
55
|
+
return { content: "Refreshing read-only repositories...", fg: "#38bdf8" };
|
|
56
56
|
case "done":
|
|
57
57
|
return { content: "Ready!", fg: "#22c55e" };
|
|
58
58
|
default:
|
package/src/ui/renderer.ts
CHANGED
|
@@ -102,19 +102,12 @@ export function createBaseLayout(r: CliRenderer, subtitle?: string): LayoutEleme
|
|
|
102
102
|
|
|
103
103
|
export function clearLayout(r: CliRenderer): void {
|
|
104
104
|
// Remove known elements (ignore if they don't exist)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
} catch {
|
|
113
|
-
// Element doesn't exist
|
|
114
|
-
}
|
|
115
|
-
try {
|
|
116
|
-
r.root.remove("content");
|
|
117
|
-
} catch {
|
|
118
|
-
// Element doesn't exist
|
|
105
|
+
const elements = ["main-container", "title", "content"];
|
|
106
|
+
for (const id of elements) {
|
|
107
|
+
try {
|
|
108
|
+
r.root.remove(id);
|
|
109
|
+
} catch {
|
|
110
|
+
// Element doesn't exist
|
|
111
|
+
}
|
|
119
112
|
}
|
|
120
113
|
}
|
|
@@ -208,7 +208,7 @@ export function showSessionSettings(
|
|
|
208
208
|
if (selectedTarget === "goal") {
|
|
209
209
|
customActions.push("Enter Edit");
|
|
210
210
|
} else if (selectedTarget === "repo") {
|
|
211
|
-
customActions.push("
|
|
211
|
+
customActions.push("l Toggle Read-only", "a Add repos");
|
|
212
212
|
} else if (selectedTarget === "skill") {
|
|
213
213
|
customActions.push("x Remove", "a Add repos");
|
|
214
214
|
}
|
|
@@ -339,10 +339,11 @@ export function showSessionSettings(
|
|
|
339
339
|
return;
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
-
if (key.name === "
|
|
342
|
+
if (key.name === "l" && selectedTarget === "repo") {
|
|
343
343
|
const repo = updatedRepos[selectedRepoIndex];
|
|
344
344
|
if (repo) {
|
|
345
|
-
repo.
|
|
345
|
+
repo.latest = !repo.latest;
|
|
346
|
+
repo.readOnly = repo.latest;
|
|
346
347
|
updateReposList();
|
|
347
348
|
}
|
|
348
349
|
return;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RepoSpecSchema,
|
|
3
|
+
SessionManifestSchema,
|
|
4
|
+
NewSessionParamsSchema,
|
|
5
|
+
type RepoSpec,
|
|
6
|
+
type SessionManifest,
|
|
7
|
+
type NewSessionParams,
|
|
8
|
+
} from "./schemas";
|
|
9
|
+
|
|
10
|
+
export interface ValidationResult<T> {
|
|
11
|
+
success: boolean;
|
|
12
|
+
data?: T;
|
|
13
|
+
errors?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatZodError(error: unknown): string[] {
|
|
17
|
+
if (
|
|
18
|
+
typeof error === "object" &&
|
|
19
|
+
error !== null &&
|
|
20
|
+
"issues" in error &&
|
|
21
|
+
Array.isArray((error as { issues: unknown[] }).issues)
|
|
22
|
+
) {
|
|
23
|
+
const issues = (error as { issues: { path: PropertyKey[]; message: string }[] }).issues;
|
|
24
|
+
return issues.map((e) => {
|
|
25
|
+
const path = e.path.length > 0 ? String(e.path.join(".")) : "root";
|
|
26
|
+
return `${path}: ${e.message}`;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return ["Unknown validation error"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateRepoSpec(spec: string): RepoSpec {
|
|
33
|
+
const parsed = parseRepoSpec(spec);
|
|
34
|
+
const result = RepoSpecSchema.safeParse(parsed);
|
|
35
|
+
|
|
36
|
+
if (!result.success) {
|
|
37
|
+
const errors = formatZodError(result.error);
|
|
38
|
+
throw new Error(`Invalid repo spec: ${errors.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result.data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateRepoSpecSafe(spec: string): ValidationResult<RepoSpec> {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = parseRepoSpec(spec);
|
|
47
|
+
const result = RepoSpecSchema.safeParse(parsed);
|
|
48
|
+
|
|
49
|
+
if (!result.success) {
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
errors: formatZodError(result.error),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
success: true,
|
|
58
|
+
data: result.data,
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
errors: [error instanceof Error ? error.message : "Unknown error"],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validateSessionManifest(manifest: unknown): SessionManifest {
|
|
69
|
+
const result = SessionManifestSchema.safeParse(manifest);
|
|
70
|
+
|
|
71
|
+
if (!result.success) {
|
|
72
|
+
const errors = formatZodError(result.error);
|
|
73
|
+
throw new Error(`Invalid session manifest: ${errors.join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result.data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateSessionManifestSafe(manifest: unknown): ValidationResult<SessionManifest> {
|
|
80
|
+
const result = SessionManifestSchema.safeParse(manifest);
|
|
81
|
+
|
|
82
|
+
if (!result.success) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
errors: formatZodError(result.error),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
data: result.data,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateNewSessionParams(params: unknown): NewSessionParams {
|
|
96
|
+
const result = NewSessionParamsSchema.safeParse(params);
|
|
97
|
+
|
|
98
|
+
if (!result.success) {
|
|
99
|
+
const errors = formatZodError(result.error);
|
|
100
|
+
throw new Error(`Invalid session params: ${errors.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result.data;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function validateNewSessionParamsSafe(params: unknown): ValidationResult<NewSessionParams> {
|
|
107
|
+
const result = NewSessionParamsSchema.safeParse(params);
|
|
108
|
+
|
|
109
|
+
if (!result.success) {
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
errors: formatZodError(result.error),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
data: result.data,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseRepoSpec(spec: string): {
|
|
123
|
+
spec: string;
|
|
124
|
+
owner: string;
|
|
125
|
+
name: string;
|
|
126
|
+
branch?: string;
|
|
127
|
+
dir: string;
|
|
128
|
+
} {
|
|
129
|
+
const colonIndex = spec.indexOf(":");
|
|
130
|
+
const repoPath = colonIndex === -1 ? spec : spec.slice(0, colonIndex);
|
|
131
|
+
const branch = colonIndex === -1 ? undefined : spec.slice(colonIndex + 1);
|
|
132
|
+
|
|
133
|
+
const slashIndex = repoPath.indexOf("/");
|
|
134
|
+
if (slashIndex === -1) {
|
|
135
|
+
throw new Error(`Invalid repo format: ${spec} (expected owner/repo or owner/repo:branch)`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const owner = repoPath.slice(0, slashIndex);
|
|
139
|
+
const name = repoPath.slice(slashIndex + 1);
|
|
140
|
+
|
|
141
|
+
if (!owner || !name) {
|
|
142
|
+
throw new Error(`Invalid repo format: ${spec} (expected owner/repo or owner/repo:branch)`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
spec,
|
|
147
|
+
owner,
|
|
148
|
+
name,
|
|
149
|
+
branch,
|
|
150
|
+
dir: name,
|
|
151
|
+
};
|
|
152
|
+
}
|
package/src/reference-repo.ts
DELETED
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { mkdir, symlink, readlink, lstat, readdir, rm } from "node:fs/promises";
|
|
4
|
-
import type { RepoSpec } from "./types";
|
|
5
|
-
import { readProcessOutputWithBuffer } from "./utils/stream";
|
|
6
|
-
|
|
7
|
-
const LETMECOOK_DIR = join(homedir(), ".letmecook");
|
|
8
|
-
export const REFERENCES_DIR = join(LETMECOOK_DIR, "references");
|
|
9
|
-
|
|
10
|
-
export interface RefreshResult {
|
|
11
|
-
repo: RepoSpec;
|
|
12
|
-
status: "updated" | "up-to-date" | "skipped" | "error";
|
|
13
|
-
reason?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type RefreshProgressStatus = "refreshing" | "updated" | "up-to-date" | "skipped" | "error";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Get the path where a reference repo should be cached.
|
|
20
|
-
* Format: ~/.letmecook/references/{owner}/{name}/{branch|HEAD}
|
|
21
|
-
*/
|
|
22
|
-
export function getReferencePath(repo: RepoSpec): string {
|
|
23
|
-
const branchDir = repo.branch || "HEAD";
|
|
24
|
-
return join(REFERENCES_DIR, repo.owner, repo.name, branchDir);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Ensure the references directory exists.
|
|
29
|
-
*/
|
|
30
|
-
export async function ensureReferencesDir(): Promise<void> {
|
|
31
|
-
await mkdir(REFERENCES_DIR, { recursive: true });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Check if a path is a symlink.
|
|
36
|
-
*/
|
|
37
|
-
export async function isSymlink(path: string): Promise<boolean> {
|
|
38
|
-
try {
|
|
39
|
-
const stats = await lstat(path);
|
|
40
|
-
return stats.isSymbolicLink();
|
|
41
|
-
} catch {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Clone a repo to the reference cache if not already present.
|
|
48
|
-
* Returns the path to the cached repo.
|
|
49
|
-
*/
|
|
50
|
-
export async function ensureReferenceRepo(
|
|
51
|
-
repo: RepoSpec,
|
|
52
|
-
onProgress?: (status: "cloning" | "done" | "error", outputLines?: string[]) => void,
|
|
53
|
-
): Promise<string> {
|
|
54
|
-
const refPath = getReferencePath(repo);
|
|
55
|
-
|
|
56
|
-
// Check if already cached
|
|
57
|
-
const gitDir = join(refPath, ".git");
|
|
58
|
-
const gitDirFile = Bun.file(gitDir);
|
|
59
|
-
if (await gitDirFile.exists()) {
|
|
60
|
-
onProgress?.("done", ["Already cached"]);
|
|
61
|
-
return refPath;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Ensure parent directories exist
|
|
65
|
-
await mkdir(join(REFERENCES_DIR, repo.owner, repo.name), { recursive: true });
|
|
66
|
-
|
|
67
|
-
onProgress?.("cloning");
|
|
68
|
-
|
|
69
|
-
const url = `https://github.com/${repo.owner}/${repo.name}.git`;
|
|
70
|
-
const args = repo.branch
|
|
71
|
-
? [
|
|
72
|
-
"git",
|
|
73
|
-
"clone",
|
|
74
|
-
"--depth",
|
|
75
|
-
"1",
|
|
76
|
-
"--single-branch",
|
|
77
|
-
"--branch",
|
|
78
|
-
repo.branch,
|
|
79
|
-
"--progress",
|
|
80
|
-
url,
|
|
81
|
-
refPath,
|
|
82
|
-
]
|
|
83
|
-
: ["git", "clone", "--depth", "1", "--single-branch", "--progress", url, refPath];
|
|
84
|
-
|
|
85
|
-
const proc = Bun.spawn(args, {
|
|
86
|
-
stdout: "pipe",
|
|
87
|
-
stderr: "pipe",
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const { success, output } = await readProcessOutputWithBuffer(proc, {
|
|
91
|
-
maxBufferLines: 5,
|
|
92
|
-
onBufferUpdate: (buffer) => onProgress?.("cloning", buffer),
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (!success) {
|
|
96
|
-
onProgress?.("error", output);
|
|
97
|
-
throw new Error(`Failed to clone reference repo ${repo.owner}/${repo.name}`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
onProgress?.("done", output);
|
|
101
|
-
return refPath;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Refresh a cached reference repo with git pull.
|
|
106
|
-
*/
|
|
107
|
-
export async function refreshReferenceRepo(
|
|
108
|
-
repo: RepoSpec,
|
|
109
|
-
onProgress?: (status: RefreshProgressStatus, outputLines?: string[]) => void,
|
|
110
|
-
): Promise<RefreshResult> {
|
|
111
|
-
const refPath = getReferencePath(repo);
|
|
112
|
-
|
|
113
|
-
// Check if cached repo exists
|
|
114
|
-
const gitDir = join(refPath, ".git");
|
|
115
|
-
const gitDirFile = Bun.file(gitDir);
|
|
116
|
-
if (!(await gitDirFile.exists())) {
|
|
117
|
-
// Try to clone it
|
|
118
|
-
try {
|
|
119
|
-
await ensureReferenceRepo(repo, (status, lines) => {
|
|
120
|
-
if (status === "cloning") onProgress?.("refreshing", lines);
|
|
121
|
-
else if (status === "done") onProgress?.("updated", lines);
|
|
122
|
-
else onProgress?.("error", lines);
|
|
123
|
-
});
|
|
124
|
-
return { repo, status: "updated", reason: "newly cloned" };
|
|
125
|
-
} catch (error) {
|
|
126
|
-
return {
|
|
127
|
-
repo,
|
|
128
|
-
status: "error",
|
|
129
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
onProgress?.("refreshing", [`Pulling ${repo.owner}/${repo.name}...`]);
|
|
135
|
-
|
|
136
|
-
const proc = Bun.spawn(["git", "-C", refPath, "pull", "--ff-only", "--depth", "1"], {
|
|
137
|
-
stdout: "pipe",
|
|
138
|
-
stderr: "pipe",
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
const { success, output, fullOutput } = await readProcessOutputWithBuffer(proc, {
|
|
142
|
-
maxBufferLines: 5,
|
|
143
|
-
onBufferUpdate: (buffer) => onProgress?.("refreshing", buffer),
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
if (!success) {
|
|
147
|
-
const reason = fullOutput.trim() || "git pull failed";
|
|
148
|
-
onProgress?.("error", output.length > 0 ? output : [reason]);
|
|
149
|
-
return { repo, status: "error", reason };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const normalized = fullOutput.toLowerCase();
|
|
153
|
-
const upToDate =
|
|
154
|
-
normalized.includes("already up to date") || normalized.includes("already up-to-date");
|
|
155
|
-
|
|
156
|
-
const status = upToDate ? "up-to-date" : "updated";
|
|
157
|
-
onProgress?.(status, output);
|
|
158
|
-
return { repo, status };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Create a symlink from session directory to the cached reference repo.
|
|
163
|
-
*/
|
|
164
|
-
export async function linkReferenceRepo(repo: RepoSpec, sessionPath: string): Promise<void> {
|
|
165
|
-
const refPath = getReferencePath(repo);
|
|
166
|
-
const linkPath = join(sessionPath, repo.dir);
|
|
167
|
-
|
|
168
|
-
// Check if link already exists and is valid
|
|
169
|
-
if (await isSymlink(linkPath)) {
|
|
170
|
-
try {
|
|
171
|
-
const target = await readlink(linkPath);
|
|
172
|
-
if (target === refPath) {
|
|
173
|
-
return; // Already correctly linked
|
|
174
|
-
}
|
|
175
|
-
// Remove incorrect symlink
|
|
176
|
-
await rm(linkPath);
|
|
177
|
-
} catch {
|
|
178
|
-
// Remove broken symlink
|
|
179
|
-
await rm(linkPath);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
await symlink(refPath, linkPath);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Verify that a symlink is valid and points to the correct reference.
|
|
188
|
-
* Returns true if valid, false if needs repair.
|
|
189
|
-
*/
|
|
190
|
-
export async function verifyReferenceLink(repo: RepoSpec, sessionPath: string): Promise<boolean> {
|
|
191
|
-
const refPath = getReferencePath(repo);
|
|
192
|
-
const linkPath = join(sessionPath, repo.dir);
|
|
193
|
-
|
|
194
|
-
if (!(await isSymlink(linkPath))) {
|
|
195
|
-
return false;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
const target = await readlink(linkPath);
|
|
200
|
-
// Check if target matches expected reference path
|
|
201
|
-
if (target !== refPath) {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
// Check if target actually exists
|
|
205
|
-
const gitDir = Bun.file(join(refPath, ".git"));
|
|
206
|
-
return await gitDir.exists();
|
|
207
|
-
} catch {
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Repair a broken reference link by re-ensuring the cache and re-linking.
|
|
214
|
-
*/
|
|
215
|
-
export async function repairReferenceLink(
|
|
216
|
-
repo: RepoSpec,
|
|
217
|
-
sessionPath: string,
|
|
218
|
-
onProgress?: (status: "cloning" | "done" | "error", outputLines?: string[]) => void,
|
|
219
|
-
): Promise<void> {
|
|
220
|
-
const linkPath = join(sessionPath, repo.dir);
|
|
221
|
-
|
|
222
|
-
// Remove existing link/directory
|
|
223
|
-
try {
|
|
224
|
-
await rm(linkPath, { recursive: true, force: true });
|
|
225
|
-
} catch {
|
|
226
|
-
// Ignore
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Ensure reference is cached
|
|
230
|
-
await ensureReferenceRepo(repo, onProgress);
|
|
231
|
-
|
|
232
|
-
// Create symlink
|
|
233
|
-
await linkReferenceRepo(repo, sessionPath);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Clean up orphaned references that aren't used by any session.
|
|
238
|
-
* Returns the number of references removed.
|
|
239
|
-
*/
|
|
240
|
-
export async function cleanOrphanedReferences(): Promise<number> {
|
|
241
|
-
// This is a placeholder for future implementation
|
|
242
|
-
// Would need to:
|
|
243
|
-
// 1. List all sessions
|
|
244
|
-
// 2. Collect all reference paths in use
|
|
245
|
-
// 3. Walk references dir and remove unused entries
|
|
246
|
-
return 0;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* List all cached references.
|
|
251
|
-
*/
|
|
252
|
-
export async function listCachedReferences(): Promise<
|
|
253
|
-
Array<{ owner: string; name: string; branch: string; path: string }>
|
|
254
|
-
> {
|
|
255
|
-
const refs: Array<{ owner: string; name: string; branch: string; path: string }> = [];
|
|
256
|
-
|
|
257
|
-
try {
|
|
258
|
-
const owners = await readdir(REFERENCES_DIR);
|
|
259
|
-
|
|
260
|
-
for (const owner of owners) {
|
|
261
|
-
const ownerPath = join(REFERENCES_DIR, owner);
|
|
262
|
-
const ownerStat = await lstat(ownerPath);
|
|
263
|
-
if (!ownerStat.isDirectory()) continue;
|
|
264
|
-
|
|
265
|
-
const names = await readdir(ownerPath);
|
|
266
|
-
|
|
267
|
-
for (const name of names) {
|
|
268
|
-
const namePath = join(ownerPath, name);
|
|
269
|
-
const nameStat = await lstat(namePath);
|
|
270
|
-
if (!nameStat.isDirectory()) continue;
|
|
271
|
-
|
|
272
|
-
const branches = await readdir(namePath);
|
|
273
|
-
|
|
274
|
-
for (const branch of branches) {
|
|
275
|
-
const branchPath = join(namePath, branch);
|
|
276
|
-
const branchStat = await lstat(branchPath);
|
|
277
|
-
if (!branchStat.isDirectory()) continue;
|
|
278
|
-
|
|
279
|
-
refs.push({ owner, name, branch, path: branchPath });
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
} catch {
|
|
284
|
-
// References dir doesn't exist yet
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return refs;
|
|
288
|
-
}
|