@monster-orange/den 0.2.3 → 0.2.4

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.
@@ -12,7 +12,8 @@ import { localCommandsFor } from "../../shared/stacks/manifest-commands.js";
12
12
  import * as http from "http";
13
13
  import * as fs from "fs";
14
14
  import * as path from "path";
15
- import { execSync } from "child_process";
15
+ import { execFile, execSync } from "child_process";
16
+ import { singleFlight } from "./single-flight.js";
16
17
  import { readBody, sendJson, send400 } from "./http-helpers.js";
17
18
  import type { ResolvedService } from "../../shared/types/index.js";
18
19
  import type { EditorManager } from "./editor-manager.js";
@@ -106,6 +107,229 @@ import { product } from "../../shared/product.js";
106
107
  // cwd-started-elsewhere server would resolve and persist garbage paths.
107
108
  const ROOT = getDistRoot();
108
109
 
110
+ /** `git <args>` in `cwd`, trimmed stdout; null on any failure. Never blocks. */
111
+ function gitOut(cwd: string, args: string[]): Promise<string | null> {
112
+ return new Promise((resolve) => {
113
+ execFile(
114
+ "git",
115
+ args,
116
+ { cwd, encoding: "utf-8", timeout: GIT_REMOTE_TIMEOUT_MS },
117
+ (err, stdout) => resolve(err ? null : stdout.trim()),
118
+ );
119
+ });
120
+ }
121
+
122
+ /**
123
+ * The repos Local Dev's "add" picker offers: local checkouts under
124
+ * repos_root with a manifest, managed clones, and catalog-only entries.
125
+ */
126
+ async function buildAvailableRepos(wsOverride: string | null) {
127
+ const reposRoot = getReposRoot();
128
+ const wsFile = wsOverride ? readWorkspacesFile() : null;
129
+ const ws =
130
+ wsOverride && wsFile?.workspaces[wsOverride]
131
+ ? { config: wsFile.workspaces[wsOverride] }
132
+ : getActiveWorkspace();
133
+ const catalog = readCatalog();
134
+ const existingAddresses = new Set(Object.keys(ws?.config.services ?? {}));
135
+
136
+ // Build a set of gh slugs already represented in the workspace, so that
137
+ // alternate local paths for the same repo (e.g. TestWorkspace/shop) are
138
+ // correctly flagged as already-added.
139
+ const existingGhSlugs = new Set<string>();
140
+ for (const addr of existingAddresses) {
141
+ const { repoName } = parseAddress(addr, catalog);
142
+ const gh = catalog[repoName]?.gh;
143
+ if (gh) existingGhSlugs.add(gh.toLowerCase());
144
+ }
145
+
146
+ // Git remote origin for a local path, as "owner/repo" (null on failure).
147
+ // Async and in parallel: one execSync per repo here used to hold the
148
+ // event loop for the whole walk.
149
+ const getRemoteGh = async (absPath: string): Promise<string | null> => {
150
+ const remote = await gitOut(absPath, ["remote", "get-url", "origin"]);
151
+ // Normalise SSH and HTTPS GitHub URLs → "owner/repo"
152
+ const m =
153
+ remote?.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ?? null;
154
+ return m ? m[1].toLowerCase() : null;
155
+ };
156
+ /** Lookups still in flight; each fills in its entry when it lands. */
157
+ const pending: Promise<void>[] = [];
158
+ const isAdded = (relPath: string, gh: string | null): boolean =>
159
+ existingAddresses.has(relPath) ||
160
+ (!!gh && existingGhSlugs.has(gh.toLowerCase()));
161
+
162
+ type RepoEntry = {
163
+ name: string;
164
+ relPath: string;
165
+ absPath: string | null;
166
+ hasManifest: boolean;
167
+ alreadyAdded: boolean;
168
+ source: "local" | "catalog" | "managed";
169
+ gh: string | null;
170
+ currentBranch?: string | null;
171
+ };
172
+ const entries: RepoEntry[] = [];
173
+ const seenRelPaths = new Set<string>();
174
+
175
+ // Walk repos_root for checkouts with their own manifest. A git checkout is
176
+ // never descended into, manifest or not: the walk used to recurse into
177
+ // every directory without the manifest file, so a distribution whose
178
+ // repos use held manifests (manifests/<repo>.yml) had each repo's whole
179
+ // tree walked to depth 4 — seconds of readdirSync on every call. All of it
180
+ // is async now: the event loop serves other requests meanwhile.
181
+ const exists = (p: string): Promise<boolean> =>
182
+ fs.promises.access(p).then(
183
+ () => true,
184
+ () => false,
185
+ );
186
+ const walkDir = async (
187
+ dir: string,
188
+ relPrefix: string,
189
+ depth: number,
190
+ ): Promise<void> => {
191
+ if (depth > 4) return; // safety limit
192
+ let dirents: fs.Dirent[];
193
+ try {
194
+ dirents = await fs.promises.readdir(dir, { withFileTypes: true });
195
+ } catch {
196
+ return;
197
+ }
198
+ const children = dirents.filter(
199
+ (d) =>
200
+ (d.isDirectory() || d.isSymbolicLink()) &&
201
+ !d.name.startsWith(".") && // .git, .github, .DS_Store, …
202
+ d.name !== "node_modules" &&
203
+ path.join(dir, d.name) !== ROOT, // den itself
204
+ );
205
+ // One directory's children are checked together; the descent stays
206
+ // depth-first in readdir order, so the list keeps its old order.
207
+ const checked = await Promise.all(
208
+ children.map(async (d) => {
209
+ const absPath = path.join(dir, d.name);
210
+ const [hasManifest, isCheckout] = await Promise.all([
211
+ exists(path.join(absPath, `${product().manifestYml}`)),
212
+ exists(path.join(absPath, ".git")),
213
+ ]);
214
+ return { d, absPath, hasManifest, isCheckout };
215
+ }),
216
+ );
217
+ for (const { d, absPath, hasManifest, isCheckout } of checked) {
218
+ const relPath = relPrefix ? `${relPrefix}/${d.name}` : d.name;
219
+ if (hasManifest) {
220
+ const known = catalog[relPath]?.gh ?? null;
221
+ // Already added if the exact address is in the workspace, OR if
222
+ // a repo with the same GitHub remote is already present.
223
+ const entry: RepoEntry = {
224
+ name: d.name,
225
+ relPath,
226
+ absPath,
227
+ hasManifest: true,
228
+ alreadyAdded: isAdded(relPath, known),
229
+ source: "local",
230
+ gh: known,
231
+ };
232
+ entries.push(entry);
233
+ if (!known)
234
+ pending.push(
235
+ getRemoteGh(absPath).then((gh) => {
236
+ entry.gh = gh;
237
+ entry.alreadyAdded = isAdded(relPath, gh);
238
+ }),
239
+ );
240
+ seenRelPaths.add(relPath);
241
+ } else if (!isCheckout) {
242
+ // Not a repo: look for repos in its subdirectories.
243
+ await walkDir(absPath, relPath, depth + 1);
244
+ }
245
+ }
246
+ };
247
+ await walkDir(reposRoot, "", 0);
248
+
249
+ // Managed clones live at .den-services/<repo>/<branch>/
250
+ // Walk two levels: repo dir → branch dir (each branch dir is a full clone).
251
+ // Multiple workspaces pointing to the same repo+branch share the same clone.
252
+ const readDirs = async (dir: string): Promise<string[]> => {
253
+ try {
254
+ return (await fs.promises.readdir(dir, { withFileTypes: true }))
255
+ .filter((d) => d.isDirectory())
256
+ .map((d) => d.name);
257
+ } catch {
258
+ return []; // the managed-clones dir does not exist yet
259
+ }
260
+ };
261
+ for (const repoName of await readDirs(DEN_SERVICES_DIR)) {
262
+ const repoDirPath = path.join(DEN_SERVICES_DIR, repoName);
263
+ const clones = await Promise.all(
264
+ (await readDirs(repoDirPath)).map(async (branchName) => {
265
+ const absPath = path.join(repoDirPath, branchName);
266
+ const [isGit, hasManifest] = await Promise.all([
267
+ exists(path.join(absPath, ".git")),
268
+ exists(path.join(absPath, `${product().manifestYml}`)),
269
+ ]);
270
+ return { branchName, absPath, isGit, hasManifest };
271
+ }),
272
+ );
273
+ for (const { branchName, absPath, isGit, hasManifest } of clones) {
274
+ // Only treat as a branch clone if it's actually a git repo
275
+ if (!isGit) continue;
276
+ const key = `${repoName}::${branchName}`; // unique per repo+branch
277
+ if (seenRelPaths.has(key)) continue;
278
+ const known = catalog[repoName]?.gh ?? null;
279
+ const entry: RepoEntry = {
280
+ name: repoName,
281
+ relPath: repoName, // catalog key used as workspace address
282
+ absPath,
283
+ hasManifest,
284
+ alreadyAdded: isAdded(repoName, known),
285
+ source: "managed",
286
+ gh: known,
287
+ currentBranch: branchName,
288
+ };
289
+ entries.push(entry);
290
+ pending.push(
291
+ Promise.all([
292
+ known ? known : getRemoteGh(absPath),
293
+ gitOut(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
294
+ ]).then(([gh, branch]) => {
295
+ entry.gh = gh;
296
+ entry.alreadyAdded = isAdded(repoName, gh);
297
+ entry.currentBranch = branch ?? branchName;
298
+ }),
299
+ );
300
+ seenRelPaths.add(key);
301
+ seenRelPaths.add(repoName); // prevent catalog fallback from duplicating
302
+ }
303
+ }
304
+
305
+ // Catalog-only entries — repos den knows about but not found locally.
306
+ for (const [relPath, entry] of Object.entries(catalog)) {
307
+ if (seenRelPaths.has(relPath)) continue;
308
+ const name = relPath.includes("/")
309
+ ? relPath.slice(relPath.lastIndexOf("/") + 1)
310
+ : relPath;
311
+ const alreadyAdded =
312
+ existingAddresses.has(relPath) ||
313
+ (!!entry.gh && existingGhSlugs.has(entry.gh.toLowerCase()));
314
+ entries.push({
315
+ name,
316
+ relPath,
317
+ absPath: null,
318
+ hasManifest: false,
319
+ alreadyAdded,
320
+ source: "catalog",
321
+ gh: entry.gh,
322
+ });
323
+ }
324
+
325
+ await Promise.all(pending);
326
+ return entries;
327
+ }
328
+
329
+ type AvailableRepos = Awaited<ReturnType<typeof buildAvailableRepos>>;
330
+ /** Shared by concurrent requests, reused briefly; cleared by any mutation. */
331
+ const availableReposCache = singleFlight<AvailableRepos>(2_000);
332
+
109
333
  /**
110
334
  * Handle all /api/workspaces/* and /api/workspace/* routes.
111
335
  *
@@ -120,6 +344,12 @@ export async function handleWorkspaceRoutes(
120
344
  editors: EditorManager,
121
345
  github: GitHubService,
122
346
  ): Promise<void> {
347
+ // A mutation may change what available-repos would list.
348
+ if (
349
+ (req.method !== "GET" && req.method !== "HEAD") ||
350
+ /\/(exec|sync)$/.test(pathname)
351
+ )
352
+ availableReposCache.clear();
123
353
  // ── Workspace cleanup helpers ─────────────────────────────────────────────
124
354
  /**
125
355
  * Compute which clones can be deleted when removing `wsName`.
@@ -439,187 +669,16 @@ export async function handleWorkspaceRoutes(
439
669
  // name — leaf directory name (display label)
440
670
  // absPath — absolute filesystem path (stored as linkedPath for dev-managed repos)
441
671
  if (pathname === "/api/workspaces/available-repos" && req.method === "GET") {
442
- const reposRoot = getReposRoot();
443
672
  const wsOverride = url.searchParams.get("workspace");
444
- const wsFile = wsOverride ? readWorkspacesFile() : null;
445
- const ws =
446
- wsOverride && wsFile?.workspaces[wsOverride]
447
- ? { config: wsFile.workspaces[wsOverride] }
448
- : getActiveWorkspace();
449
- const catalog = readCatalog();
450
- const existingAddresses = new Set(Object.keys(ws?.config.services ?? {}));
451
-
452
- // Build a set of gh slugs already represented in the workspace, so that
453
- // alternate local paths for the same repo (e.g. TestWorkspace/shop) are
454
- // correctly flagged as already-added.
455
- const existingGhSlugs = new Set<string>();
456
- for (const addr of existingAddresses) {
457
- const { repoName } = parseAddress(addr, catalog);
458
- const gh = catalog[repoName]?.gh;
459
- if (gh) existingGhSlugs.add(gh.toLowerCase());
460
- }
461
-
462
- // Get git remote origin for a local path (fast, silent on failure).
463
- const getRemoteGh = (absPath: string): string | null => {
464
- try {
465
- const remote = execSync("git remote get-url origin", {
466
- cwd: absPath,
467
- encoding: "utf-8",
468
- stdio: ["ignore", "pipe", "ignore"],
469
- timeout: GIT_REMOTE_TIMEOUT_MS,
470
- }).trim();
471
- // Normalise SSH and HTTPS GitHub URLs → "owner/repo"
472
- const m =
473
- remote.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ?? null;
474
- return m ? m[1].toLowerCase() : null;
475
- } catch {
476
- return null;
477
- }
478
- };
479
-
480
- type RepoEntry = {
481
- name: string;
482
- relPath: string;
483
- absPath: string | null;
484
- hasManifest: boolean;
485
- alreadyAdded: boolean;
486
- source: "local" | "catalog" | "managed";
487
- gh: string | null;
488
- currentBranch?: string | null;
489
- };
490
- const entries: RepoEntry[] = [];
491
- const seenRelPaths = new Set<string>();
492
-
493
- // Recursively walk repos_root, stopping at directories that have .den.yml.
494
- // We don't recurse into repo directories themselves.
495
- const walkDir = (dir: string, relPrefix: string, depth: number): void => {
496
- if (depth > 4) return; // safety limit
497
- let dirents: fs.Dirent[];
498
- try {
499
- dirents = fs.readdirSync(dir, { withFileTypes: true });
500
- } catch {
501
- return;
502
- }
503
-
504
- for (const d of dirents) {
505
- if (!d.isDirectory() && !d.isSymbolicLink()) continue;
506
- if (d.name.startsWith(".")) continue; // skip .git, .github, .DS_Store, etc.
507
- const absPath = path.join(dir, d.name);
508
- if (absPath === ROOT) continue; // skip den itself
509
- const relPath = relPrefix ? `${relPrefix}/${d.name}` : d.name;
510
- const hasManifest = fs.existsSync(
511
- path.join(absPath, `${product().manifestYml}`),
512
- );
513
- if (hasManifest) {
514
- const gh = catalog[relPath]?.gh ?? getRemoteGh(absPath);
515
- // Already added if the exact address is in the workspace, OR if
516
- // a repo with the same GitHub remote is already present.
517
- const alreadyAdded =
518
- existingAddresses.has(relPath) ||
519
- (!!gh && existingGhSlugs.has(gh.toLowerCase()));
520
- entries.push({
521
- name: d.name,
522
- relPath,
523
- absPath,
524
- hasManifest: true,
525
- alreadyAdded,
526
- source: "local",
527
- gh: gh ?? null,
528
- });
529
- seenRelPaths.add(relPath);
530
- // Don't recurse into repo directories.
531
- } else {
532
- // Not a repo — recurse to find repos in subdirectories.
533
- walkDir(absPath, relPath, depth + 1);
534
- }
535
- }
536
- };
537
- walkDir(reposRoot, "", 0);
538
-
539
- // Managed clones live at .den-services/<repo>/<branch>/
540
- // Walk two levels: repo dir → branch dir (each branch dir is a full clone).
541
- // Multiple workspaces pointing to the same repo+branch share the same clone.
542
- const getCurrentBranch = (absPath: string): string | null => {
543
- try {
544
- return execSync("git rev-parse --abbrev-ref HEAD", {
545
- cwd: absPath,
546
- encoding: "utf-8",
547
- stdio: ["ignore", "pipe", "ignore"],
548
- timeout: GIT_REMOTE_TIMEOUT_MS,
549
- }).trim();
550
- } catch {
551
- return null;
552
- }
553
- };
554
- try {
555
- const repoDirs = fs.readdirSync(DEN_SERVICES_DIR, {
556
- withFileTypes: true,
557
- });
558
- for (const repoDir of repoDirs) {
559
- if (!repoDir.isDirectory()) continue;
560
- const repoName = repoDir.name;
561
- const repoDirPath = path.join(DEN_SERVICES_DIR, repoName);
562
- let branchDirs: fs.Dirent[];
563
- try {
564
- branchDirs = fs.readdirSync(repoDirPath, { withFileTypes: true });
565
- } catch {
566
- continue;
567
- }
568
- for (const branchDir of branchDirs) {
569
- if (!branchDir.isDirectory()) continue;
570
- const branchName = branchDir.name;
571
- const absPath = path.join(repoDirPath, branchName);
572
- // Only treat as a branch clone if it's actually a git repo
573
- if (!fs.existsSync(path.join(absPath, ".git"))) continue;
574
- const key = `${repoName}::${branchName}`; // unique per repo+branch
575
- if (seenRelPaths.has(key)) continue;
576
- const hasManifest = fs.existsSync(
577
- path.join(absPath, `${product().manifestYml}`),
578
- );
579
- const gh = catalog[repoName]?.gh ?? getRemoteGh(absPath);
580
- const alreadyAdded =
581
- existingAddresses.has(repoName) ||
582
- (!!gh && existingGhSlugs.has(gh.toLowerCase()));
583
- const currentBranch = getCurrentBranch(absPath) ?? branchName;
584
- entries.push({
585
- name: repoName,
586
- relPath: repoName, // catalog key used as workspace address
587
- absPath,
588
- hasManifest,
589
- alreadyAdded,
590
- source: "managed",
591
- gh: gh ?? null,
592
- currentBranch,
593
- });
594
- seenRelPaths.add(key);
595
- seenRelPaths.add(repoName); // prevent catalog fallback from duplicating
596
- }
597
- }
598
- } catch {
599
- /* the managed-clones dir does not exist yet */
600
- }
601
-
602
- // Catalog-only entries — repos den knows about but not found locally.
603
- for (const [relPath, entry] of Object.entries(catalog)) {
604
- if (seenRelPaths.has(relPath)) continue;
605
- const name = relPath.includes("/")
606
- ? relPath.slice(relPath.lastIndexOf("/") + 1)
607
- : relPath;
608
- const alreadyAdded =
609
- existingAddresses.has(relPath) ||
610
- (!!entry.gh && existingGhSlugs.has(entry.gh.toLowerCase()));
611
- entries.push({
612
- name,
613
- relPath,
614
- absPath: null,
615
- hasManifest: false,
616
- alreadyAdded,
617
- source: "catalog",
618
- gh: entry.gh,
619
- });
620
- }
621
-
622
- return sendJson(res, entries);
673
+ // Local Dev calls this on load: concurrent loads share one walk.
674
+ return sendJson(
675
+ res,
676
+ await availableReposCache.get(
677
+ wsOverride ?? "",
678
+ () => buildAvailableRepos(wsOverride),
679
+ url.searchParams.get("refresh") === "1",
680
+ ),
681
+ );
623
682
  }
624
683
 
625
684
  // ── API: per-system auto-start toggle ─────────────────────────────────────