@monster-orange/den 0.2.3 → 0.2.5

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/docs/distributions.md +64 -16
  3. package/package.json +1 -1
  4. package/service/install.sh +4 -2
  5. package/src/shared/constants.ts +7 -0
  6. package/src/shared/den-config.ts +130 -12
  7. package/src/shared/edge/config.ts +16 -0
  8. package/src/shared/engine.ts +18 -0
  9. package/src/shared/environment-manager/default-workspace.ts +182 -87
  10. package/src/shared/environment-manager/health.ts +229 -30
  11. package/src/shared/environment-manager/opctl.ts +83 -1
  12. package/src/shared/environment-manager/service-runner.ts +49 -4
  13. package/src/shared/git/origin-url.ts +77 -0
  14. package/src/shared/manifest/read-manifest.ts +39 -15
  15. package/src/shared/paths.ts +42 -0
  16. package/src/shared/product.ts +7 -1
  17. package/src/shared/profile.ts +8 -0
  18. package/src/shared/read-cache.ts +77 -0
  19. package/src/shared/stacks/compose-health.ts +150 -0
  20. package/src/shared/stacks/runtime.ts +71 -24
  21. package/src/shared/stacks/status.ts +112 -3
  22. package/src/ui/cli/health-display.ts +1 -0
  23. package/src/ui/cli/index.ts +181 -31
  24. package/src/ui/cli/mcp-connect.ts +77 -15
  25. package/src/ui/cli/stack-command.ts +40 -12
  26. package/src/ui/cli/web-command.ts +25 -7
  27. package/src/ui/cli/workspace-notes.ts +43 -0
  28. package/src/ui/server/graph-order.ts +22 -0
  29. package/src/ui/server/index.ts +174 -197
  30. package/src/ui/server/public/changelog-view.js +16 -0
  31. package/src/ui/server/public/header.js +121 -12
  32. package/src/ui/server/services-list.ts +210 -0
  33. package/src/ui/server/single-flight.ts +53 -0
  34. package/src/ui/server/ui-assets.ts +1 -0
  35. package/src/ui/server/workspace-routes.ts +247 -181
@@ -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";
@@ -89,6 +90,7 @@ import {
89
90
  } from "../../shared/environment-manager/catalog.js";
90
91
  import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
91
92
  import { getDistRoot } from "../../shared/paths.js";
93
+ import { recordRemovedFromWorkspace } from "../../shared/environment-manager/default-workspace.js";
92
94
  import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
93
95
  import {
94
96
  devManagedPaths,
@@ -106,6 +108,229 @@ import { product } from "../../shared/product.js";
106
108
  // cwd-started-elsewhere server would resolve and persist garbage paths.
107
109
  const ROOT = getDistRoot();
108
110
 
111
+ /** `git <args>` in `cwd`, trimmed stdout; null on any failure. Never blocks. */
112
+ function gitOut(cwd: string, args: string[]): Promise<string | null> {
113
+ return new Promise((resolve) => {
114
+ execFile(
115
+ "git",
116
+ args,
117
+ { cwd, encoding: "utf-8", timeout: GIT_REMOTE_TIMEOUT_MS },
118
+ (err, stdout) => resolve(err ? null : stdout.trim()),
119
+ );
120
+ });
121
+ }
122
+
123
+ /**
124
+ * The repos Local Dev's "add" picker offers: local checkouts under
125
+ * repos_root with a manifest, managed clones, and catalog-only entries.
126
+ */
127
+ async function buildAvailableRepos(wsOverride: string | null) {
128
+ const reposRoot = getReposRoot();
129
+ const wsFile = wsOverride ? readWorkspacesFile() : null;
130
+ const ws =
131
+ wsOverride && wsFile?.workspaces[wsOverride]
132
+ ? { config: wsFile.workspaces[wsOverride] }
133
+ : getActiveWorkspace();
134
+ const catalog = readCatalog();
135
+ const existingAddresses = new Set(Object.keys(ws?.config.services ?? {}));
136
+
137
+ // Build a set of gh slugs already represented in the workspace, so that
138
+ // alternate local paths for the same repo (e.g. TestWorkspace/shop) are
139
+ // correctly flagged as already-added.
140
+ const existingGhSlugs = new Set<string>();
141
+ for (const addr of existingAddresses) {
142
+ const { repoName } = parseAddress(addr, catalog);
143
+ const gh = catalog[repoName]?.gh;
144
+ if (gh) existingGhSlugs.add(gh.toLowerCase());
145
+ }
146
+
147
+ // Git remote origin for a local path, as "owner/repo" (null on failure).
148
+ // Async and in parallel: one execSync per repo here used to hold the
149
+ // event loop for the whole walk.
150
+ const getRemoteGh = async (absPath: string): Promise<string | null> => {
151
+ const remote = await gitOut(absPath, ["remote", "get-url", "origin"]);
152
+ // Normalise SSH and HTTPS GitHub URLs → "owner/repo"
153
+ const m =
154
+ remote?.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ?? null;
155
+ return m ? m[1].toLowerCase() : null;
156
+ };
157
+ /** Lookups still in flight; each fills in its entry when it lands. */
158
+ const pending: Promise<void>[] = [];
159
+ const isAdded = (relPath: string, gh: string | null): boolean =>
160
+ existingAddresses.has(relPath) ||
161
+ (!!gh && existingGhSlugs.has(gh.toLowerCase()));
162
+
163
+ type RepoEntry = {
164
+ name: string;
165
+ relPath: string;
166
+ absPath: string | null;
167
+ hasManifest: boolean;
168
+ alreadyAdded: boolean;
169
+ source: "local" | "catalog" | "managed";
170
+ gh: string | null;
171
+ currentBranch?: string | null;
172
+ };
173
+ const entries: RepoEntry[] = [];
174
+ const seenRelPaths = new Set<string>();
175
+
176
+ // Walk repos_root for checkouts with their own manifest. A git checkout is
177
+ // never descended into, manifest or not: the walk used to recurse into
178
+ // every directory without the manifest file, so a distribution whose
179
+ // repos use held manifests (manifests/<repo>.yml) had each repo's whole
180
+ // tree walked to depth 4 — seconds of readdirSync on every call. All of it
181
+ // is async now: the event loop serves other requests meanwhile.
182
+ const exists = (p: string): Promise<boolean> =>
183
+ fs.promises.access(p).then(
184
+ () => true,
185
+ () => false,
186
+ );
187
+ const walkDir = async (
188
+ dir: string,
189
+ relPrefix: string,
190
+ depth: number,
191
+ ): Promise<void> => {
192
+ if (depth > 4) return; // safety limit
193
+ let dirents: fs.Dirent[];
194
+ try {
195
+ dirents = await fs.promises.readdir(dir, { withFileTypes: true });
196
+ } catch {
197
+ return;
198
+ }
199
+ const children = dirents.filter(
200
+ (d) =>
201
+ (d.isDirectory() || d.isSymbolicLink()) &&
202
+ !d.name.startsWith(".") && // .git, .github, .DS_Store, …
203
+ d.name !== "node_modules" &&
204
+ path.join(dir, d.name) !== ROOT, // den itself
205
+ );
206
+ // One directory's children are checked together; the descent stays
207
+ // depth-first in readdir order, so the list keeps its old order.
208
+ const checked = await Promise.all(
209
+ children.map(async (d) => {
210
+ const absPath = path.join(dir, d.name);
211
+ const [hasManifest, isCheckout] = await Promise.all([
212
+ exists(path.join(absPath, `${product().manifestYml}`)),
213
+ exists(path.join(absPath, ".git")),
214
+ ]);
215
+ return { d, absPath, hasManifest, isCheckout };
216
+ }),
217
+ );
218
+ for (const { d, absPath, hasManifest, isCheckout } of checked) {
219
+ const relPath = relPrefix ? `${relPrefix}/${d.name}` : d.name;
220
+ if (hasManifest) {
221
+ const known = catalog[relPath]?.gh ?? null;
222
+ // Already added if the exact address is in the workspace, OR if
223
+ // a repo with the same GitHub remote is already present.
224
+ const entry: RepoEntry = {
225
+ name: d.name,
226
+ relPath,
227
+ absPath,
228
+ hasManifest: true,
229
+ alreadyAdded: isAdded(relPath, known),
230
+ source: "local",
231
+ gh: known,
232
+ };
233
+ entries.push(entry);
234
+ if (!known)
235
+ pending.push(
236
+ getRemoteGh(absPath).then((gh) => {
237
+ entry.gh = gh;
238
+ entry.alreadyAdded = isAdded(relPath, gh);
239
+ }),
240
+ );
241
+ seenRelPaths.add(relPath);
242
+ } else if (!isCheckout) {
243
+ // Not a repo: look for repos in its subdirectories.
244
+ await walkDir(absPath, relPath, depth + 1);
245
+ }
246
+ }
247
+ };
248
+ await walkDir(reposRoot, "", 0);
249
+
250
+ // Managed clones live at .den-services/<repo>/<branch>/
251
+ // Walk two levels: repo dir → branch dir (each branch dir is a full clone).
252
+ // Multiple workspaces pointing to the same repo+branch share the same clone.
253
+ const readDirs = async (dir: string): Promise<string[]> => {
254
+ try {
255
+ return (await fs.promises.readdir(dir, { withFileTypes: true }))
256
+ .filter((d) => d.isDirectory())
257
+ .map((d) => d.name);
258
+ } catch {
259
+ return []; // the managed-clones dir does not exist yet
260
+ }
261
+ };
262
+ for (const repoName of await readDirs(DEN_SERVICES_DIR)) {
263
+ const repoDirPath = path.join(DEN_SERVICES_DIR, repoName);
264
+ const clones = await Promise.all(
265
+ (await readDirs(repoDirPath)).map(async (branchName) => {
266
+ const absPath = path.join(repoDirPath, branchName);
267
+ const [isGit, hasManifest] = await Promise.all([
268
+ exists(path.join(absPath, ".git")),
269
+ exists(path.join(absPath, `${product().manifestYml}`)),
270
+ ]);
271
+ return { branchName, absPath, isGit, hasManifest };
272
+ }),
273
+ );
274
+ for (const { branchName, absPath, isGit, hasManifest } of clones) {
275
+ // Only treat as a branch clone if it's actually a git repo
276
+ if (!isGit) continue;
277
+ const key = `${repoName}::${branchName}`; // unique per repo+branch
278
+ if (seenRelPaths.has(key)) continue;
279
+ const known = catalog[repoName]?.gh ?? null;
280
+ const entry: RepoEntry = {
281
+ name: repoName,
282
+ relPath: repoName, // catalog key used as workspace address
283
+ absPath,
284
+ hasManifest,
285
+ alreadyAdded: isAdded(repoName, known),
286
+ source: "managed",
287
+ gh: known,
288
+ currentBranch: branchName,
289
+ };
290
+ entries.push(entry);
291
+ pending.push(
292
+ Promise.all([
293
+ known ? known : getRemoteGh(absPath),
294
+ gitOut(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
295
+ ]).then(([gh, branch]) => {
296
+ entry.gh = gh;
297
+ entry.alreadyAdded = isAdded(repoName, gh);
298
+ entry.currentBranch = branch ?? branchName;
299
+ }),
300
+ );
301
+ seenRelPaths.add(key);
302
+ seenRelPaths.add(repoName); // prevent catalog fallback from duplicating
303
+ }
304
+ }
305
+
306
+ // Catalog-only entries — repos den knows about but not found locally.
307
+ for (const [relPath, entry] of Object.entries(catalog)) {
308
+ if (seenRelPaths.has(relPath)) continue;
309
+ const name = relPath.includes("/")
310
+ ? relPath.slice(relPath.lastIndexOf("/") + 1)
311
+ : relPath;
312
+ const alreadyAdded =
313
+ existingAddresses.has(relPath) ||
314
+ (!!entry.gh && existingGhSlugs.has(entry.gh.toLowerCase()));
315
+ entries.push({
316
+ name,
317
+ relPath,
318
+ absPath: null,
319
+ hasManifest: false,
320
+ alreadyAdded,
321
+ source: "catalog",
322
+ gh: entry.gh,
323
+ });
324
+ }
325
+
326
+ await Promise.all(pending);
327
+ return entries;
328
+ }
329
+
330
+ type AvailableRepos = Awaited<ReturnType<typeof buildAvailableRepos>>;
331
+ /** Shared by concurrent requests, reused briefly; cleared by any mutation. */
332
+ const availableReposCache = singleFlight<AvailableRepos>(2_000);
333
+
109
334
  /**
110
335
  * Handle all /api/workspaces/* and /api/workspace/* routes.
111
336
  *
@@ -120,6 +345,12 @@ export async function handleWorkspaceRoutes(
120
345
  editors: EditorManager,
121
346
  github: GitHubService,
122
347
  ): Promise<void> {
348
+ // A mutation may change what available-repos would list.
349
+ if (
350
+ (req.method !== "GET" && req.method !== "HEAD") ||
351
+ /\/(exec|sync)$/.test(pathname)
352
+ )
353
+ availableReposCache.clear();
123
354
  // ── Workspace cleanup helpers ─────────────────────────────────────────────
124
355
  /**
125
356
  * Compute which clones can be deleted when removing `wsName`.
@@ -439,187 +670,16 @@ export async function handleWorkspaceRoutes(
439
670
  // name — leaf directory name (display label)
440
671
  // absPath — absolute filesystem path (stored as linkedPath for dev-managed repos)
441
672
  if (pathname === "/api/workspaces/available-repos" && req.method === "GET") {
442
- const reposRoot = getReposRoot();
443
673
  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);
674
+ // Local Dev calls this on load: concurrent loads share one walk.
675
+ return sendJson(
676
+ res,
677
+ await availableReposCache.get(
678
+ wsOverride ?? "",
679
+ () => buildAvailableRepos(wsOverride),
680
+ url.searchParams.get("refresh") === "1",
681
+ ),
682
+ );
623
683
  }
624
684
 
625
685
  // ── API: per-system auto-start toggle ─────────────────────────────────────
@@ -935,6 +995,12 @@ export async function handleWorkspaceRoutes(
935
995
  // Delete exact match, or all subsystem entries for the repo
936
996
  // (handles both bare-key workspaces like "shop" and
937
997
  // explicit-subsystem workspaces like "shop/api", "shop/db").
998
+ // Removal is deliberate: top-up must never bring the repo back.
999
+ recordRemovedFromWorkspace(
1000
+ file,
1001
+ wsName,
1002
+ parseAddress(address, catalog).repoName,
1003
+ );
938
1004
  if (services[address] !== undefined) {
939
1005
  delete services[address];
940
1006
  } else {