@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/docs/distributions.md +51 -0
  3. package/package.json +1 -1
  4. package/scripts/setup-secrets.ts +11 -0
  5. package/service/install.sh +4 -2
  6. package/src/shared/edge/config.ts +16 -0
  7. package/src/shared/environment-manager/health.ts +36 -1
  8. package/src/shared/environment-manager/opctl.ts +83 -1
  9. package/src/shared/environment-manager/service-runner.ts +205 -8
  10. package/src/shared/git/origin-url.ts +77 -0
  11. package/src/shared/manifest/read-manifest.ts +39 -15
  12. package/src/shared/manifest/schema.ts +15 -0
  13. package/src/shared/paths.ts +42 -0
  14. package/src/shared/product.ts +7 -1
  15. package/src/shared/profile.ts +8 -0
  16. package/src/shared/read-cache.ts +77 -0
  17. package/src/shared/secrets/doppler.ts +10 -5
  18. package/src/shared/secrets/index.ts +1 -0
  19. package/src/shared/secrets/keys.ts +2 -0
  20. package/src/shared/secrets/secret-env.ts +149 -0
  21. package/src/shared/secrets/types.ts +10 -0
  22. package/src/shared/stacks/config.ts +6 -0
  23. package/src/shared/stacks/runtime.ts +51 -3
  24. package/src/shared/stacks/status.ts +109 -1
  25. package/src/shared/stacks/types.ts +8 -0
  26. package/src/shared/types/index.ts +15 -0
  27. package/src/ui/cli/index.ts +151 -10
  28. package/src/ui/cli/mcp-connect.ts +77 -15
  29. package/src/ui/cli/stack-command.ts +47 -5
  30. package/src/ui/cli/workspace-notes.ts +43 -0
  31. package/src/ui/mcp/connect.ts +3 -1
  32. package/src/ui/server/graph-order.ts +22 -0
  33. package/src/ui/server/index.ts +101 -183
  34. package/src/ui/server/lifecycle-exec.ts +23 -0
  35. package/src/ui/server/services-list.ts +210 -0
  36. package/src/ui/server/single-flight.ts +53 -0
  37. 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
- const wsFile = wsOverride ? readWorkspacesFile() : null;
444
- const ws =
445
- wsOverride && wsFile?.workspaces[wsOverride]
446
- ? { config: wsFile.workspaces[wsOverride] }
447
- : getActiveWorkspace();
448
- const catalog = readCatalog();
449
- const existingAddresses = new Set(Object.keys(ws?.config.services ?? {}));
450
-
451
- // Build a set of gh slugs already represented in the workspace, so that
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