letmecook 0.0.14 → 0.0.16

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,12 +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
30
+ let isConfirming = false; // Flag for confirmation mode (showing checkboxes)
31
+ let confirmOptionIndex = 0; // 0 = reference, 1 = confirm button
31
32
 
32
33
  // Repository input
33
34
  const repoLabel = new TextRenderable(renderer, {
@@ -77,21 +78,21 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
77
78
  });
78
79
  content.add(detailsLabel);
79
80
 
80
- const detailsReadOnly = new TextRenderable(renderer, {
81
- id: "details-readonly",
81
+ const detailsReference = new TextRenderable(renderer, {
82
+ id: "details-reference",
82
83
  content: "",
83
84
  fg: "#94a3b8",
84
85
  marginTop: 0,
85
86
  });
86
- content.add(detailsReadOnly);
87
+ content.add(detailsReference);
87
88
 
88
- const detailsLatest = new TextRenderable(renderer, {
89
- id: "details-latest",
89
+ const confirmButton = new TextRenderable(renderer, {
90
+ id: "confirm-button",
90
91
  content: "",
91
- fg: "#94a3b8",
92
- marginTop: 0,
92
+ fg: "#10b981",
93
+ marginTop: 1,
93
94
  });
94
- content.add(detailsLatest);
95
+ content.add(confirmButton);
95
96
 
96
97
  repoInput.onPaste = (event) => {
97
98
  const text = event.text.replace(/[\r\n]+/g, "");
@@ -102,8 +103,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
102
103
  const repo = validateRepo(currentInput.trim());
103
104
  currentValidRepo = repo;
104
105
  if (repo) {
105
- currentReadOnly = false;
106
- currentLatest = false;
106
+ currentReference = false;
107
107
  }
108
108
  } else {
109
109
  statusText.content = "";
@@ -151,9 +151,8 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
151
151
  } else {
152
152
  reposList.content = repos
153
153
  .map((repo, i) => {
154
- const roMarker = repo.readOnly ? " [RO]" : "";
155
- const latestMarker = repo.latest ? " [Latest]" : "";
156
- return ` ${i + 1}. ${repo.spec}${roMarker}${latestMarker}`;
154
+ const refMarker = repo.reference ? " [Ref]" : "";
155
+ return ` ${i + 1}. ${repo.spec}${refMarker}`;
157
156
  })
158
157
  .join("\n");
159
158
  reposList.fg = "#94a3b8";
@@ -161,17 +160,28 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
161
160
  }
162
161
 
163
162
  function updateDetails() {
164
- if (currentValidRepo) {
165
- detailsLabel.content = "\nRepository details:";
166
- detailsLabel.fg = "#e2e8f0";
167
- detailsReadOnly.content = ` Read-only: ${currentReadOnly ? "Yes [r]" : "No [r]"}`;
168
- detailsReadOnly.fg = currentReadOnly ? "#f59e0b" : "#94a3b8";
169
- detailsLatest.content = ` Latest: ${currentLatest ? "Yes [l]" : "No [l]"}`;
170
- detailsLatest.fg = currentLatest ? "#22d3ee" : "#94a3b8";
163
+ if (isConfirming && currentValidRepo) {
164
+ // Confirmation mode with interactive checkboxes
165
+ detailsLabel.content = `\nConfigure options for: ${currentInput.trim()}`;
166
+ detailsLabel.fg = "#38bdf8";
167
+
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";
172
+
173
+ const confirmSelected = confirmOptionIndex === 1;
174
+ confirmButton.content = ` ${confirmSelected ? "▶" : " "} [Add repository]`;
175
+ confirmButton.fg = confirmSelected ? "#10b981" : "#64748b";
176
+ } else if (currentValidRepo) {
177
+ detailsLabel.content = "\nPress Enter to configure options";
178
+ detailsLabel.fg = "#64748b";
179
+ detailsReference.content = "";
180
+ confirmButton.content = "";
171
181
  } else {
172
182
  detailsLabel.content = "";
173
- detailsReadOnly.content = "";
174
- detailsLatest.content = "";
183
+ detailsReference.content = "";
184
+ confirmButton.content = "";
175
185
  }
176
186
  }
177
187
 
@@ -240,8 +250,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
240
250
  const repo = validateRepo(selectedMatch.trim());
241
251
  currentValidRepo = repo;
242
252
  if (repo) {
243
- currentReadOnly = false;
244
- currentLatest = false;
253
+ currentReference = false;
245
254
  }
246
255
 
247
256
  updateMatches(); // Refresh display with new selection (matches stay the same, just highlight changes)
@@ -262,15 +271,13 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
262
271
  }
263
272
 
264
273
  const repoToAdd = { ...currentValidRepo };
265
- repoToAdd.readOnly = currentReadOnly;
266
- repoToAdd.latest = currentLatest;
274
+ repoToAdd.reference = currentReference;
267
275
  repos.push(repoToAdd);
268
276
 
269
277
  currentInput = "";
270
278
  repoInput.value = "";
271
279
  currentValidRepo = null;
272
- currentReadOnly = false;
273
- currentLatest = false;
280
+ currentReference = false;
274
281
  updateReposList();
275
282
  lastQuery = "";
276
283
  selectedMatchIndex = -1;
@@ -280,37 +287,109 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
280
287
  statusText.content = "";
281
288
  }
282
289
 
290
+ function enterConfirmMode() {
291
+ isConfirming = true;
292
+ confirmOptionIndex = 1; // Start on confirm button for quick add
293
+ repoInput.blur();
294
+ updateDetails();
295
+ updateFooter();
296
+ }
297
+
298
+ function exitConfirmMode() {
299
+ isConfirming = false;
300
+ confirmOptionIndex = 0;
301
+ repoInput.focus();
302
+ updateDetails();
303
+ updateFooter();
304
+ }
305
+
306
+ function toggleCurrentOption() {
307
+ if (confirmOptionIndex === 0) {
308
+ // Toggle reference
309
+ currentReference = !currentReference;
310
+ } else if (confirmOptionIndex === 1) {
311
+ // Confirm button - add the repo
312
+ addCurrentRepo();
313
+ exitConfirmMode();
314
+ }
315
+ updateDetails();
316
+ }
317
+
318
+ function updateFooter() {
319
+ if (isConfirming) {
320
+ showFooter(renderer, content, {
321
+ navigate: true,
322
+ select: false,
323
+ back: true,
324
+ custom: ["r Reference", "space Toggle", "enter Add"],
325
+ });
326
+ } else {
327
+ showFooter(renderer, content, {
328
+ navigate: true,
329
+ select: true,
330
+ back: true,
331
+ custom: repos.length > 0 ? ["enter (empty) Continue"] : [],
332
+ });
333
+ }
334
+ }
335
+
283
336
  const handleKeypress = (key: KeyEvent) => {
337
+ // Escape behavior differs based on mode
284
338
  if (isEscape(key)) {
339
+ if (isConfirming) {
340
+ // Exit confirmation mode, go back to input
341
+ exitConfirmMode();
342
+ return;
343
+ }
285
344
  cleanup();
286
345
  resolve({ repos, cancelled: true });
287
346
  return;
288
347
  }
289
348
 
290
- // Toggle read-only
291
- if (key.name === "r" && currentValidRepo) {
292
- currentReadOnly = !currentReadOnly;
293
- if (!currentReadOnly) {
294
- currentLatest = false;
349
+ // Confirmation mode handling
350
+ if (isConfirming) {
351
+ // Toggle reference with 'r' hotkey
352
+ if (key.name === "r") {
353
+ currentReference = !currentReference;
354
+ updateDetails();
355
+ return;
356
+ }
357
+
358
+ // Space to toggle current option
359
+ if (key.name === "space") {
360
+ toggleCurrentOption();
361
+ return;
362
+ }
363
+
364
+ // Enter to confirm/add
365
+ if (isEnter(key)) {
366
+ addCurrentRepo();
367
+ exitConfirmMode();
368
+ return;
369
+ }
370
+
371
+ // Arrow keys to navigate options
372
+ if (isArrowUp(key)) {
373
+ confirmOptionIndex = Math.max(0, confirmOptionIndex - 1);
374
+ updateDetails();
375
+ return;
295
376
  }
296
- updateDetails();
297
- return;
298
- }
299
377
 
300
- // Toggle latest
301
- if (key.name === "l" && currentValidRepo) {
302
- currentLatest = !currentLatest;
303
- if (currentLatest) {
304
- currentReadOnly = true;
378
+ if (isArrowDown(key)) {
379
+ confirmOptionIndex = Math.min(1, confirmOptionIndex + 1);
380
+ updateDetails();
381
+ return;
305
382
  }
306
- updateDetails();
383
+
307
384
  return;
308
385
  }
309
386
 
310
- // Enter to add repo or continue
387
+ // Normal input mode handling
388
+
389
+ // Enter to enter confirmation mode or continue
311
390
  if (isEnter(key)) {
312
391
  if (currentInput.trim() && currentValidRepo) {
313
- addCurrentRepo();
392
+ enterConfirmMode();
314
393
  } else if (!currentInput.trim() && repos.length > 0) {
315
394
  // Empty input + repos added = continue
316
395
  cleanup();
@@ -367,8 +446,7 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
367
446
  const repo = validateRepo(value.trim());
368
447
  currentValidRepo = repo;
369
448
  if (repo) {
370
- currentReadOnly = false;
371
- currentLatest = false;
449
+ currentReference = false;
372
450
  }
373
451
  } else {
374
452
  statusText.content = "";
@@ -385,16 +463,9 @@ export async function showAddReposPrompt(renderer: CliRenderer): Promise<AddRepo
385
463
  clearLayout(renderer);
386
464
  };
387
465
 
388
- // Show footer with context-aware actions
389
- showFooter(renderer, content, {
390
- navigate: true,
391
- select: true,
392
- back: true,
393
- custom: currentValidRepo ? ["r Toggle RO", "l Toggle Latest"] : ["↑↓ Navigate matches"],
394
- });
395
-
396
466
  renderer.keyInput.on("keypress", handleKeypress);
397
467
  updateDetails();
398
468
  updateReposList();
469
+ updateFooter();
399
470
  });
400
471
  }