@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.
- package/CHANGELOG.md +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- 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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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 {
|