letmecook 0.0.15 → 0.0.17

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.
@@ -0,0 +1,288 @@
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
+ }
package/src/tui-mode.ts CHANGED
@@ -9,9 +9,11 @@ import { showMainMenu } from "./ui/main-menu";
9
9
  import { showSessionDetails } from "./ui/session-details";
10
10
  import { showSessionSettings } from "./ui/session-settings";
11
11
  import { showDeleteConfirm } from "./ui/confirm-delete";
12
+ import { showQuitWarning } from "./ui/background-warning";
12
13
  import type { Session } from "./types";
13
14
  import { createNewSession, resumeSession } from "./flows";
14
15
  import { listSessions, deleteSession, updateLastAccessed, updateSessionSettings } from "./sessions";
16
+ import { getRunningProcesses, killAllProcesses } from "./process-registry";
15
17
 
16
18
  export async function handleTUIMode(): Promise<void> {
17
19
  let renderer = await createRenderer();
@@ -39,10 +41,21 @@ export async function handleTUIMode(): Promise<void> {
39
41
  break;
40
42
  }
41
43
 
42
- case "quit":
44
+ case "quit": {
45
+ const runningProcesses = await getRunningProcesses();
46
+ if (runningProcesses.length > 0) {
47
+ const choice = await showQuitWarning(renderer, runningProcesses);
48
+ if (choice === "cancel") {
49
+ break; // Stay in main loop
50
+ }
51
+ if (choice === "kill") {
52
+ await killAllProcesses();
53
+ }
54
+ }
43
55
  destroyRenderer();
44
56
  console.log("\nGoodbye!");
45
57
  return;
58
+ }
46
59
  }
47
60
  }
48
61
  } catch (error) {
package/src/types.ts CHANGED
@@ -9,10 +9,8 @@ export interface RepoSpec {
9
9
  branch?: string;
10
10
  /** Directory name in the session workspace */
11
11
  dir: string;
12
- /** Whether this repo is read-only (reference only, no modifications allowed) */
13
- readOnly?: boolean;
14
- /** Whether this repo should be refreshed to latest before resuming */
15
- latest?: boolean;
12
+ /** Whether this resource is a reference (symlinked from shared cache, read-only, auto-refreshed) */
13
+ reference?: boolean;
16
14
  }
17
15
 
18
16
  export interface SessionManifest {
@@ -22,14 +22,13 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
22
22
 
23
23
  const repos: RepoSpec[] = [];
24
24
  let currentInput = "";
25
- let currentReadOnly = false;
26
- let currentLatest = false;
25
+ let currentReference = false;
27
26
  let currentValidRepo: RepoSpec | null = null;
28
27
  let selectedMatchIndex = -1; // -1 means no match selected, user is typing freely
29
28
  let lastQuery = ""; // Track the query that generated current matches
30
29
  let isNavigating = false; // Flag to prevent input handler from resetting when navigating
31
30
  let isConfirming = false; // Flag for confirmation mode (showing checkboxes)
32
- let confirmOptionIndex = 0; // 0 = read-only, 1 = latest, 2 = confirm button
31
+ let confirmOptionIndex = 0; // 0 = reference, 1 = confirm button
33
32
 
34
33
  // Repository input
35
34
  const repoLabel = new TextRenderable(renderer, {
@@ -79,21 +78,13 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
79
78
  });
80
79
  content.add(detailsLabel);
81
80
 
82
- const detailsReadOnly = new TextRenderable(renderer, {
83
- id: "details-readonly",
81
+ const detailsReference = new TextRenderable(renderer, {
82
+ id: "details-reference",
84
83
  content: "",
85
84
  fg: "#94a3b8",
86
85
  marginTop: 0,
87
86
  });
88
- content.add(detailsReadOnly);
89
-
90
- const detailsLatest = new TextRenderable(renderer, {
91
- id: "details-latest",
92
- content: "",
93
- fg: "#94a3b8",
94
- marginTop: 0,
95
- });
96
- content.add(detailsLatest);
87
+ content.add(detailsReference);
97
88
 
98
89
  const confirmButton = new TextRenderable(renderer, {
99
90
  id: "confirm-button",
@@ -112,8 +103,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
112
103
  const repo = validateRepo(currentInput.trim());
113
104
  currentValidRepo = repo;
114
105
  if (repo) {
115
- currentReadOnly = false;
116
- currentLatest = false;
106
+ currentReference = false;
117
107
  }
118
108
  } else {
119
109
  statusText.content = "";
@@ -161,9 +151,8 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
161
151
  } else {
162
152
  reposList.content = repos
163
153
  .map((repo, i) => {
164
- const roMarker = repo.readOnly ? " [RO]" : "";
165
- const latestMarker = repo.latest ? " [Latest]" : "";
166
- return ` ${i + 1}. ${repo.spec}${roMarker}${latestMarker}`;
154
+ const refMarker = repo.reference ? " [Ref]" : "";
155
+ return ` ${i + 1}. ${repo.spec}${refMarker}`;
167
156
  })
168
157
  .join("\n");
169
158
  reposList.fg = "#94a3b8";
@@ -176,29 +165,22 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
176
165
  detailsLabel.content = `\nConfigure options for: ${currentInput.trim()}`;
177
166
  detailsLabel.fg = "#38bdf8";
178
167
 
179
- const roCheckbox = currentReadOnly ? "[✓]" : "[ ]";
180
- const roSelected = confirmOptionIndex === 0;
181
- detailsReadOnly.content = ` ${roSelected ? "▶" : " "} ${roCheckbox} Read-only [r]`;
182
- detailsReadOnly.fg = roSelected ? "#f8fafc" : currentReadOnly ? "#f59e0b" : "#94a3b8";
183
-
184
- const latestCheckbox = currentLatest ? "[✓]" : "[ ]";
185
- const latestSelected = confirmOptionIndex === 1;
186
- detailsLatest.content = ` ${latestSelected ? "▶" : " "} ${latestCheckbox} Latest [l]`;
187
- detailsLatest.fg = latestSelected ? "#f8fafc" : currentLatest ? "#22d3ee" : "#94a3b8";
168
+ const refCheckbox = currentReference ? "[✓]" : "[ ]";
169
+ const refSelected = confirmOptionIndex === 0;
170
+ detailsReference.content = ` ${refSelected ? "▶" : " "} ${refCheckbox} Reference [r] (read-only, shared cache, auto-refresh)`;
171
+ detailsReference.fg = refSelected ? "#f8fafc" : currentReference ? "#22d3ee" : "#94a3b8";
188
172
 
189
- const confirmSelected = confirmOptionIndex === 2;
173
+ const confirmSelected = confirmOptionIndex === 1;
190
174
  confirmButton.content = ` ${confirmSelected ? "▶" : " "} [Add repository]`;
191
175
  confirmButton.fg = confirmSelected ? "#10b981" : "#64748b";
192
176
  } else if (currentValidRepo) {
193
177
  detailsLabel.content = "\nPress Enter to configure options";
194
178
  detailsLabel.fg = "#64748b";
195
- detailsReadOnly.content = "";
196
- detailsLatest.content = "";
179
+ detailsReference.content = "";
197
180
  confirmButton.content = "";
198
181
  } else {
199
182
  detailsLabel.content = "";
200
- detailsReadOnly.content = "";
201
- detailsLatest.content = "";
183
+ detailsReference.content = "";
202
184
  confirmButton.content = "";
203
185
  }
204
186
  }
@@ -268,8 +250,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
268
250
  const repo = validateRepo(selectedMatch.trim());
269
251
  currentValidRepo = repo;
270
252
  if (repo) {
271
- currentReadOnly = false;
272
- currentLatest = false;
253
+ currentReference = false;
273
254
  }
274
255
 
275
256
  updateMatches(); // Refresh display with new selection (matches stay the same, just highlight changes)
@@ -290,15 +271,13 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
290
271
  }
291
272
 
292
273
  const repoToAdd = { ...currentValidRepo };
293
- repoToAdd.readOnly = currentReadOnly;
294
- repoToAdd.latest = currentLatest;
274
+ repoToAdd.reference = currentReference;
295
275
  repos.push(repoToAdd);
296
276
 
297
277
  currentInput = "";
298
278
  repoInput.value = "";
299
279
  currentValidRepo = null;
300
- currentReadOnly = false;
301
- currentLatest = false;
280
+ currentReference = false;
302
281
  updateReposList();
303
282
  lastQuery = "";
304
283
  selectedMatchIndex = -1;
@@ -310,7 +289,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
310
289
 
311
290
  function enterConfirmMode() {
312
291
  isConfirming = true;
313
- confirmOptionIndex = 2; // Start on confirm button for quick add
292
+ confirmOptionIndex = 1; // Start on confirm button for quick add
314
293
  repoInput.blur();
315
294
  updateDetails();
316
295
  updateFooter();
@@ -326,18 +305,9 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
326
305
 
327
306
  function toggleCurrentOption() {
328
307
  if (confirmOptionIndex === 0) {
329
- // Toggle read-only
330
- currentReadOnly = !currentReadOnly;
331
- if (!currentReadOnly) {
332
- currentLatest = false;
333
- }
308
+ // Toggle reference
309
+ currentReference = !currentReference;
334
310
  } else if (confirmOptionIndex === 1) {
335
- // Toggle latest
336
- currentLatest = !currentLatest;
337
- if (currentLatest) {
338
- currentReadOnly = true;
339
- }
340
- } else if (confirmOptionIndex === 2) {
341
311
  // Confirm button - add the repo
342
312
  addCurrentRepo();
343
313
  exitConfirmMode();
@@ -351,7 +321,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
351
321
  navigate: true,
352
322
  select: false,
353
323
  back: true,
354
- custom: ["r Read-only", "l Latest", "space Toggle", "enter Add"],
324
+ custom: ["r Reference", "space Toggle", "enter Add"],
355
325
  });
356
326
  } else {
357
327
  showFooter(renderer, content, {
@@ -378,22 +348,9 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
378
348
 
379
349
  // Confirmation mode handling
380
350
  if (isConfirming) {
381
- // Toggle read-only with 'r' hotkey
351
+ // Toggle reference with 'r' hotkey
382
352
  if (key.name === "r") {
383
- currentReadOnly = !currentReadOnly;
384
- if (!currentReadOnly) {
385
- currentLatest = false;
386
- }
387
- updateDetails();
388
- return;
389
- }
390
-
391
- // Toggle latest with 'l' hotkey
392
- if (key.name === "l") {
393
- currentLatest = !currentLatest;
394
- if (currentLatest) {
395
- currentReadOnly = true;
396
- }
353
+ currentReference = !currentReference;
397
354
  updateDetails();
398
355
  return;
399
356
  }
@@ -419,7 +376,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
419
376
  }
420
377
 
421
378
  if (isArrowDown(key)) {
422
- confirmOptionIndex = Math.min(2, confirmOptionIndex + 1);
379
+ confirmOptionIndex = Math.min(1, confirmOptionIndex + 1);
423
380
  updateDetails();
424
381
  return;
425
382
  }
@@ -489,8 +446,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
489
446
  const repo = validateRepo(value.trim());
490
447
  currentValidRepo = repo;
491
448
  if (repo) {
492
- currentReadOnly = false;
493
- currentLatest = false;
449
+ currentReference = false;
494
450
  }
495
451
  } else {
496
452
  statusText.content = "";