@monster-orange/den 0.2.2 → 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.
- package/CHANGELOG.md +151 -0
- package/docs/distributions.md +51 -0
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- package/service/install.sh +4 -2
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/environment-manager/health.ts +36 -1
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +205 -8
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/manifest/schema.ts +15 -0
- 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/secrets/doppler.ts +10 -5
- package/src/shared/secrets/index.ts +1 -0
- package/src/shared/secrets/keys.ts +2 -0
- package/src/shared/secrets/secret-env.ts +149 -0
- package/src/shared/secrets/types.ts +10 -0
- package/src/shared/stacks/config.ts +6 -0
- package/src/shared/stacks/runtime.ts +51 -3
- package/src/shared/stacks/status.ts +109 -1
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/index.ts +151 -10
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +47 -5
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +101 -183
- package/src/ui/server/lifecycle-exec.ts +23 -0
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/workspace-routes.ts +272 -181
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short-lived, keyed cache whose builds are single-flight: callers that ask
|
|
3
|
+
* for the same key while a build is running share it instead of starting their
|
|
4
|
+
* own, and a settled result is reused for `ttlMs`. A failed build is never
|
|
5
|
+
* cached, so the next caller retries.
|
|
6
|
+
*
|
|
7
|
+
* For server routes the UI polls from every open tab (/api/services,
|
|
8
|
+
* /api/health): N tabs cost one build, not N.
|
|
9
|
+
*/
|
|
10
|
+
export interface SingleFlight<T> {
|
|
11
|
+
/** The cached or in-flight value for `key`; `fresh` forces a new build. */
|
|
12
|
+
get(key: string, build: () => Promise<T>, fresh?: boolean): Promise<T>;
|
|
13
|
+
/** Drop every entry (after a change the cached value may not reflect). */
|
|
14
|
+
clear(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function singleFlight<T>(
|
|
18
|
+
ttlMs: number,
|
|
19
|
+
now: () => number = Date.now,
|
|
20
|
+
): SingleFlight<T> {
|
|
21
|
+
const entries = new Map<
|
|
22
|
+
string,
|
|
23
|
+
{ promise: Promise<T>; settledAt: number | null }
|
|
24
|
+
>();
|
|
25
|
+
return {
|
|
26
|
+
get(key, build, fresh = false) {
|
|
27
|
+
const hit = entries.get(key);
|
|
28
|
+
if (
|
|
29
|
+
!fresh &&
|
|
30
|
+
hit &&
|
|
31
|
+
(hit.settledAt === null || now() - hit.settledAt < ttlMs)
|
|
32
|
+
)
|
|
33
|
+
return hit.promise;
|
|
34
|
+
const entry: { promise: Promise<T>; settledAt: number | null } = {
|
|
35
|
+
promise: Promise.resolve().then(build),
|
|
36
|
+
settledAt: null,
|
|
37
|
+
};
|
|
38
|
+
entries.set(key, entry);
|
|
39
|
+
entry.promise.then(
|
|
40
|
+
() => {
|
|
41
|
+
entry.settledAt = now();
|
|
42
|
+
},
|
|
43
|
+
() => {
|
|
44
|
+
if (entries.get(key) === entry) entries.delete(key);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
return entry.promise;
|
|
48
|
+
},
|
|
49
|
+
clear() {
|
|
50
|
+
entries.clear();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -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";
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
checkServiceHealth,
|
|
45
46
|
claimHealthyService,
|
|
46
47
|
runServiceCommand,
|
|
48
|
+
prepareServiceSecretEnv,
|
|
47
49
|
resolveService,
|
|
48
50
|
hasRunningOpctlContainers,
|
|
49
51
|
releaseWorkspaceDaemons,
|
|
@@ -105,6 +107,229 @@ import { product } from "../../shared/product.js";
|
|
|
105
107
|
// cwd-started-elsewhere server would resolve and persist garbage paths.
|
|
106
108
|
const ROOT = getDistRoot();
|
|
107
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
|
+
|
|
108
333
|
/**
|
|
109
334
|
* Handle all /api/workspaces/* and /api/workspace/* routes.
|
|
110
335
|
*
|
|
@@ -119,6 +344,12 @@ export async function handleWorkspaceRoutes(
|
|
|
119
344
|
editors: EditorManager,
|
|
120
345
|
github: GitHubService,
|
|
121
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();
|
|
122
353
|
// ── Workspace cleanup helpers ─────────────────────────────────────────────
|
|
123
354
|
/**
|
|
124
355
|
* Compute which clones can be deleted when removing `wsName`.
|
|
@@ -438,187 +669,16 @@ export async function handleWorkspaceRoutes(
|
|
|
438
669
|
// name — leaf directory name (display label)
|
|
439
670
|
// absPath — absolute filesystem path (stored as linkedPath for dev-managed repos)
|
|
440
671
|
if (pathname === "/api/workspaces/available-repos" && req.method === "GET") {
|
|
441
|
-
const reposRoot = getReposRoot();
|
|
442
672
|
const wsOverride = url.searchParams.get("workspace");
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// alternate local paths for the same repo (e.g. TestWorkspace/shop) are
|
|
453
|
-
// correctly flagged as already-added.
|
|
454
|
-
const existingGhSlugs = new Set<string>();
|
|
455
|
-
for (const addr of existingAddresses) {
|
|
456
|
-
const { repoName } = parseAddress(addr, catalog);
|
|
457
|
-
const gh = catalog[repoName]?.gh;
|
|
458
|
-
if (gh) existingGhSlugs.add(gh.toLowerCase());
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Get git remote origin for a local path (fast, silent on failure).
|
|
462
|
-
const getRemoteGh = (absPath: string): string | null => {
|
|
463
|
-
try {
|
|
464
|
-
const remote = execSync("git remote get-url origin", {
|
|
465
|
-
cwd: absPath,
|
|
466
|
-
encoding: "utf-8",
|
|
467
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
468
|
-
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
469
|
-
}).trim();
|
|
470
|
-
// Normalise SSH and HTTPS GitHub URLs → "owner/repo"
|
|
471
|
-
const m =
|
|
472
|
-
remote.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ?? null;
|
|
473
|
-
return m ? m[1].toLowerCase() : null;
|
|
474
|
-
} catch {
|
|
475
|
-
return null;
|
|
476
|
-
}
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
type RepoEntry = {
|
|
480
|
-
name: string;
|
|
481
|
-
relPath: string;
|
|
482
|
-
absPath: string | null;
|
|
483
|
-
hasManifest: boolean;
|
|
484
|
-
alreadyAdded: boolean;
|
|
485
|
-
source: "local" | "catalog" | "managed";
|
|
486
|
-
gh: string | null;
|
|
487
|
-
currentBranch?: string | null;
|
|
488
|
-
};
|
|
489
|
-
const entries: RepoEntry[] = [];
|
|
490
|
-
const seenRelPaths = new Set<string>();
|
|
491
|
-
|
|
492
|
-
// Recursively walk repos_root, stopping at directories that have .den.yml.
|
|
493
|
-
// We don't recurse into repo directories themselves.
|
|
494
|
-
const walkDir = (dir: string, relPrefix: string, depth: number): void => {
|
|
495
|
-
if (depth > 4) return; // safety limit
|
|
496
|
-
let dirents: fs.Dirent[];
|
|
497
|
-
try {
|
|
498
|
-
dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
499
|
-
} catch {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
for (const d of dirents) {
|
|
504
|
-
if (!d.isDirectory() && !d.isSymbolicLink()) continue;
|
|
505
|
-
if (d.name.startsWith(".")) continue; // skip .git, .github, .DS_Store, etc.
|
|
506
|
-
const absPath = path.join(dir, d.name);
|
|
507
|
-
if (absPath === ROOT) continue; // skip den itself
|
|
508
|
-
const relPath = relPrefix ? `${relPrefix}/${d.name}` : d.name;
|
|
509
|
-
const hasManifest = fs.existsSync(
|
|
510
|
-
path.join(absPath, `${product().manifestYml}`),
|
|
511
|
-
);
|
|
512
|
-
if (hasManifest) {
|
|
513
|
-
const gh = catalog[relPath]?.gh ?? getRemoteGh(absPath);
|
|
514
|
-
// Already added if the exact address is in the workspace, OR if
|
|
515
|
-
// a repo with the same GitHub remote is already present.
|
|
516
|
-
const alreadyAdded =
|
|
517
|
-
existingAddresses.has(relPath) ||
|
|
518
|
-
(!!gh && existingGhSlugs.has(gh.toLowerCase()));
|
|
519
|
-
entries.push({
|
|
520
|
-
name: d.name,
|
|
521
|
-
relPath,
|
|
522
|
-
absPath,
|
|
523
|
-
hasManifest: true,
|
|
524
|
-
alreadyAdded,
|
|
525
|
-
source: "local",
|
|
526
|
-
gh: gh ?? null,
|
|
527
|
-
});
|
|
528
|
-
seenRelPaths.add(relPath);
|
|
529
|
-
// Don't recurse into repo directories.
|
|
530
|
-
} else {
|
|
531
|
-
// Not a repo — recurse to find repos in subdirectories.
|
|
532
|
-
walkDir(absPath, relPath, depth + 1);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
};
|
|
536
|
-
walkDir(reposRoot, "", 0);
|
|
537
|
-
|
|
538
|
-
// Managed clones live at .den-services/<repo>/<branch>/
|
|
539
|
-
// Walk two levels: repo dir → branch dir (each branch dir is a full clone).
|
|
540
|
-
// Multiple workspaces pointing to the same repo+branch share the same clone.
|
|
541
|
-
const getCurrentBranch = (absPath: string): string | null => {
|
|
542
|
-
try {
|
|
543
|
-
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
544
|
-
cwd: absPath,
|
|
545
|
-
encoding: "utf-8",
|
|
546
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
547
|
-
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
548
|
-
}).trim();
|
|
549
|
-
} catch {
|
|
550
|
-
return null;
|
|
551
|
-
}
|
|
552
|
-
};
|
|
553
|
-
try {
|
|
554
|
-
const repoDirs = fs.readdirSync(DEN_SERVICES_DIR, {
|
|
555
|
-
withFileTypes: true,
|
|
556
|
-
});
|
|
557
|
-
for (const repoDir of repoDirs) {
|
|
558
|
-
if (!repoDir.isDirectory()) continue;
|
|
559
|
-
const repoName = repoDir.name;
|
|
560
|
-
const repoDirPath = path.join(DEN_SERVICES_DIR, repoName);
|
|
561
|
-
let branchDirs: fs.Dirent[];
|
|
562
|
-
try {
|
|
563
|
-
branchDirs = fs.readdirSync(repoDirPath, { withFileTypes: true });
|
|
564
|
-
} catch {
|
|
565
|
-
continue;
|
|
566
|
-
}
|
|
567
|
-
for (const branchDir of branchDirs) {
|
|
568
|
-
if (!branchDir.isDirectory()) continue;
|
|
569
|
-
const branchName = branchDir.name;
|
|
570
|
-
const absPath = path.join(repoDirPath, branchName);
|
|
571
|
-
// Only treat as a branch clone if it's actually a git repo
|
|
572
|
-
if (!fs.existsSync(path.join(absPath, ".git"))) continue;
|
|
573
|
-
const key = `${repoName}::${branchName}`; // unique per repo+branch
|
|
574
|
-
if (seenRelPaths.has(key)) continue;
|
|
575
|
-
const hasManifest = fs.existsSync(
|
|
576
|
-
path.join(absPath, `${product().manifestYml}`),
|
|
577
|
-
);
|
|
578
|
-
const gh = catalog[repoName]?.gh ?? getRemoteGh(absPath);
|
|
579
|
-
const alreadyAdded =
|
|
580
|
-
existingAddresses.has(repoName) ||
|
|
581
|
-
(!!gh && existingGhSlugs.has(gh.toLowerCase()));
|
|
582
|
-
const currentBranch = getCurrentBranch(absPath) ?? branchName;
|
|
583
|
-
entries.push({
|
|
584
|
-
name: repoName,
|
|
585
|
-
relPath: repoName, // catalog key used as workspace address
|
|
586
|
-
absPath,
|
|
587
|
-
hasManifest,
|
|
588
|
-
alreadyAdded,
|
|
589
|
-
source: "managed",
|
|
590
|
-
gh: gh ?? null,
|
|
591
|
-
currentBranch,
|
|
592
|
-
});
|
|
593
|
-
seenRelPaths.add(key);
|
|
594
|
-
seenRelPaths.add(repoName); // prevent catalog fallback from duplicating
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
} catch {
|
|
598
|
-
/* the managed-clones dir does not exist yet */
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Catalog-only entries — repos den knows about but not found locally.
|
|
602
|
-
for (const [relPath, entry] of Object.entries(catalog)) {
|
|
603
|
-
if (seenRelPaths.has(relPath)) continue;
|
|
604
|
-
const name = relPath.includes("/")
|
|
605
|
-
? relPath.slice(relPath.lastIndexOf("/") + 1)
|
|
606
|
-
: relPath;
|
|
607
|
-
const alreadyAdded =
|
|
608
|
-
existingAddresses.has(relPath) ||
|
|
609
|
-
(!!entry.gh && existingGhSlugs.has(entry.gh.toLowerCase()));
|
|
610
|
-
entries.push({
|
|
611
|
-
name,
|
|
612
|
-
relPath,
|
|
613
|
-
absPath: null,
|
|
614
|
-
hasManifest: false,
|
|
615
|
-
alreadyAdded,
|
|
616
|
-
source: "catalog",
|
|
617
|
-
gh: entry.gh,
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
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
|
+
);
|
|
622
682
|
}
|
|
623
683
|
|
|
624
684
|
// ── API: per-system auto-start toggle ─────────────────────────────────────
|
|
@@ -1411,6 +1471,10 @@ export async function handleWorkspaceRoutes(
|
|
|
1411
1471
|
const startTime = Date.now();
|
|
1412
1472
|
let overallExit = 0;
|
|
1413
1473
|
let ranCount = 0;
|
|
1474
|
+
// R16: each started service's resolved secret_env, by position in
|
|
1475
|
+
// `ordered`. Resolved before a restart's down, so a missing key leaves
|
|
1476
|
+
// the service running; the values only go into the runner's spawn env.
|
|
1477
|
+
const preparedSecrets = new Map<number, Record<string, string>>();
|
|
1414
1478
|
|
|
1415
1479
|
// Run services sequentially in dependency order
|
|
1416
1480
|
const runNext = (i: number): void => {
|
|
@@ -1508,6 +1572,32 @@ export async function handleWorkspaceRoutes(
|
|
|
1508
1572
|
}
|
|
1509
1573
|
}
|
|
1510
1574
|
|
|
1575
|
+
if ((cmd === "up" || cmd === "restart") && !preparedSecrets.has(i)) {
|
|
1576
|
+
void (async () => {
|
|
1577
|
+
// Phase 2 of a restart is a true `up`, so resolve for that.
|
|
1578
|
+
const r = await prepareServiceSecretEnv(address, "up", ws.name);
|
|
1579
|
+
if (cancelled) return;
|
|
1580
|
+
if (!r.ok) {
|
|
1581
|
+
ranCount++;
|
|
1582
|
+
overallExit = 1;
|
|
1583
|
+
emit({ type: "label", text: address });
|
|
1584
|
+
emit({ type: "error", text: r.message });
|
|
1585
|
+
emit({
|
|
1586
|
+
type: "svc-done",
|
|
1587
|
+
address,
|
|
1588
|
+
exitCode: 1,
|
|
1589
|
+
repoName: svcRepoName,
|
|
1590
|
+
subsystem: svcSubsystem,
|
|
1591
|
+
});
|
|
1592
|
+
runNext(i + 1);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
preparedSecrets.set(i, r.secretEnv);
|
|
1596
|
+
runNext(i);
|
|
1597
|
+
})();
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1511
1601
|
ranCount++;
|
|
1512
1602
|
|
|
1513
1603
|
emit({ type: "label", text: address });
|
|
@@ -1540,6 +1630,7 @@ export async function handleWorkspaceRoutes(
|
|
|
1540
1630
|
// the UI just hides the command they came here to watch.
|
|
1541
1631
|
runtime: "daemon",
|
|
1542
1632
|
env: getSudoEnv(),
|
|
1633
|
+
secretEnv: preparedSecrets.get(i),
|
|
1543
1634
|
onLine: (line) => emit({ type: "line", text: line }),
|
|
1544
1635
|
onClose: (exitCode) => {
|
|
1545
1636
|
// Release outer-scope refs so the disconnect handler no longer
|