@monster-orange/den 0.2.8 → 0.4.0

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 (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +12 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * A workspace's stack namespace: the suffix its stacks' Compose projects run
3
+ * under (`<stack>-<namespace>`), and so the names of their containers,
4
+ * volumes and networks.
5
+ *
6
+ * By default it is the workspace's own name (`default`: `main`, see
7
+ * stackWorkspace). A workspace can set `stack_namespace` to opt out: several
8
+ * workspaces may then share one namespace, and a stack keeps its data when
9
+ * its repo moves between workspaces. First match wins:
10
+ *
11
+ * 1. `stack_namespace:` on the workspace in workspaces.yml (per machine)
12
+ * 2. `stack_namespace:` on the same-named blueprint workspace
13
+ * 3. stackWorkspace(name)
14
+ *
15
+ * Every stack code path turns a den workspace into its suffix through
16
+ * resolveStackWorkspaces (status.ts), which calls this; DEN_STACK_WORKSPACE
17
+ * still wins there, for one process.
18
+ */
19
+ import {
20
+ checkStackNamespace,
21
+ readBlueprintStackNamespaces,
22
+ readDenConfig,
23
+ type DenConfig,
24
+ } from "../den-config.js";
25
+ import { readWorkspacesFile } from "../environment-manager/workspaces.js";
26
+ import { cachedRead } from "../read-cache.js";
27
+ import type { WorkspacesFile } from "../types/index.js";
28
+ import { stackWorkspace } from "./render.js";
29
+
30
+ /** Which step gave the namespace. */
31
+ export type StackNamespaceSource = "workspace" | "blueprint" | "default";
32
+
33
+ export interface StackNamespace {
34
+ workspace: string;
35
+ /** The Compose project suffix. */
36
+ namespace: string;
37
+ source: StackNamespaceSource;
38
+ /** What it would be with no `stack_namespace` anywhere. */
39
+ defaultNamespace: string;
40
+ /** workspaces.yml overriding a different blueprint value, as a sentence. */
41
+ warnings: string[];
42
+ }
43
+
44
+ export interface StackNamespaceContext {
45
+ file: Pick<WorkspacesFile, "workspaces">;
46
+ /** Blueprint `stack_namespace` by workspace name. */
47
+ blueprint: Record<string, string>;
48
+ }
49
+
50
+ /**
51
+ * workspaces.yml and the blueprint's namespaces, as they are now. A
52
+ * workspaces file that cannot be read sets no namespace (its other readers
53
+ * report it), and so does an unreadable org config; a bad
54
+ * `stack_namespace` value still throws.
55
+ */
56
+ export function readStackNamespaceContext(): StackNamespaceContext {
57
+ return cachedRead("stack-namespace-context", () => {
58
+ let file: StackNamespaceContext["file"] = { workspaces: {} };
59
+ try {
60
+ file = readWorkspacesFile() ?? file;
61
+ } catch {
62
+ /* unreadable: no workspace sets one */
63
+ }
64
+ let config: DenConfig | null = null;
65
+ try {
66
+ config = readDenConfig();
67
+ } catch {
68
+ /* unreadable: no blueprint sets one */
69
+ }
70
+ return {
71
+ file,
72
+ blueprint: config ? readBlueprintStackNamespaces(config) : {},
73
+ };
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Resolve one workspace's stack namespace. A malformed `stack_namespace`
79
+ * (workspaces.yml or blueprint) throws, naming the key: falling back to
80
+ * another name would run the stacks against other volumes.
81
+ */
82
+ export function resolveStackNamespace(
83
+ name: string,
84
+ ctx: StackNamespaceContext = readStackNamespaceContext(),
85
+ ): StackNamespace {
86
+ const defaultNamespace = stackWorkspace(name);
87
+ const own = checkStackNamespace(
88
+ `workspaces.${name}.stack_namespace (config/local/workspaces.yml)`,
89
+ ctx.file.workspaces?.[name]?.stack_namespace,
90
+ );
91
+ const bp = ctx.blueprint[name];
92
+ const warnings: string[] = [];
93
+ if (own) {
94
+ if (bp && bp !== own)
95
+ warnings.push(
96
+ `workspace ${name}: stack_namespace ${own} in workspaces.yml overrides the blueprint's stack_namespace ${bp}`,
97
+ );
98
+ return {
99
+ workspace: name,
100
+ namespace: own,
101
+ source: "workspace",
102
+ defaultNamespace,
103
+ warnings,
104
+ };
105
+ }
106
+ if (bp)
107
+ return {
108
+ workspace: name,
109
+ namespace: bp,
110
+ source: "blueprint",
111
+ defaultNamespace,
112
+ warnings,
113
+ };
114
+ return {
115
+ workspace: name,
116
+ namespace: defaultNamespace,
117
+ source: "default",
118
+ defaultNamespace,
119
+ warnings,
120
+ };
121
+ }
122
+
123
+ /** One workspace's namespace as the lists show it: resolved, or the error. */
124
+ export interface StackNamespaceRow {
125
+ workspace: string;
126
+ /** The resolved namespace; null when its config is invalid. */
127
+ stack_namespace: string | null;
128
+ source: StackNamespaceSource | null;
129
+ /** The suffix with no `stack_namespace` anywhere. */
130
+ default: string;
131
+ /** True when the namespace is not the default suffix. */
132
+ differs: boolean;
133
+ warnings: string[];
134
+ error?: string;
135
+ }
136
+
137
+ /**
138
+ * Every named workspace's stack namespace, for `workspace list` and
139
+ * `/api/workspaces`. A bad value is that row's error, never the list's.
140
+ */
141
+ export function stackNamespaceRows(
142
+ names: string[],
143
+ ctx?: StackNamespaceContext,
144
+ ): StackNamespaceRow[] {
145
+ let c: StackNamespaceContext | null = ctx ?? null;
146
+ let ctxError: string | null = null;
147
+ if (!c)
148
+ try {
149
+ c = readStackNamespaceContext();
150
+ } catch (e) {
151
+ ctxError = (e as Error).message;
152
+ }
153
+ return names.map((workspace) => {
154
+ const fallback = stackWorkspace(workspace);
155
+ try {
156
+ if (!c) throw new Error(ctxError ?? "unreadable");
157
+ const ns = resolveStackNamespace(workspace, c);
158
+ return {
159
+ workspace,
160
+ stack_namespace: ns.namespace,
161
+ source: ns.source,
162
+ default: ns.defaultNamespace,
163
+ differs: ns.namespace !== ns.defaultNamespace,
164
+ warnings: ns.warnings,
165
+ };
166
+ } catch (e) {
167
+ return {
168
+ workspace,
169
+ stack_namespace: null,
170
+ source: null,
171
+ default: fallback,
172
+ differs: false,
173
+ warnings: [],
174
+ error: (e as Error).message,
175
+ };
176
+ }
177
+ });
178
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Who holds a stack's Compose project: the refcount a shared stack
3
+ * namespace needs (namespace.ts).
4
+ *
5
+ * Without `stack_namespace` each workspace's projects are its own
6
+ * (`<stack>-<workspace>`), so one workspace holds each. Two workspaces with
7
+ * the same namespace that bring up the same stack share one project, and
8
+ * one of them stopping it must not stop it for the other. This mirrors the
9
+ * owner refcount of Den's own services (a daemon's `workspaces[]`,
10
+ * releaseWorkspaceDaemons):
11
+ *
12
+ * - `stack up` from workspace W records W as an owner, with the checkout
13
+ * (repo path and branch) the project runs from;
14
+ * - a later `up` from another workspace on the same checkout joins; from a
15
+ * different checkout or branch it is refused (`identity_mismatch`), never
16
+ * a silent swap of containers;
17
+ * - `stack down` from W stops the project only when no other live owner
18
+ * remains; otherwise it detaches W and leaves the project running.
19
+ *
20
+ * A live owner is a workspace that is still active, still resolves to this
21
+ * namespace and still holds the stack: re-derived on every decision, so a
22
+ * deactivated or moved workspace never wedges a project. A record whose
23
+ * project has verifiably stopped counts no one.
24
+ *
25
+ * The record is `owners.json` in the project's own state directory, next
26
+ * to its generated.env and override.yml. DEN_STACK_WORKSPACE (a
27
+ * per-command override) bypasses it: such a command neither records nor
28
+ * counts owners, as before.
29
+ */
30
+ import { spawnSync } from "child_process";
31
+ import * as fs from "fs";
32
+ import * as path from "path";
33
+ import { getReposRoot } from "../den-config.js";
34
+ import { readCatalog } from "../environment-manager/catalog.js";
35
+ import {
36
+ getActiveWorkspaces,
37
+ getWorkspaceByName,
38
+ listWorkspaceServices,
39
+ } from "../environment-manager/workspaces.js";
40
+ import { ServiceConflictError } from "../environment-manager/service-runner.js";
41
+ import { readManifest } from "../manifest/read-manifest.js";
42
+ import { manifestStackName, readStacks } from "./config.js";
43
+ import { isPrimary } from "./manifest-commands.js";
44
+ import { resolveStackNamespace } from "./namespace.js";
45
+ import { projectName } from "./render.js";
46
+ import { stackDir, type StackContext } from "./runtime.js";
47
+ import { loadStackContext } from "./status.js";
48
+ import type { StackConfig } from "./types.js";
49
+
50
+ /** A checkout: what a shared project must run from for every owner. */
51
+ export interface StackCheckout {
52
+ repoPath: string;
53
+ branch: string | null;
54
+ }
55
+
56
+ interface OwnersRecord extends StackCheckout {
57
+ project: string;
58
+ workspaces: string[];
59
+ }
60
+
61
+ /** Lookups the refcount needs; injectable for tests. */
62
+ export interface StackOwnerDeps {
63
+ active?: () => string[];
64
+ /** A workspace's stack namespace; null when it cannot be resolved. */
65
+ namespaceOf?: (workspace: string) => string | null;
66
+ /** Whether a workspace still holds the stack. */
67
+ holds?: (workspace: string, stack: string) => boolean;
68
+ /** Whether the project has running containers; null: docker can't tell. */
69
+ running?: (project: string) => boolean | null;
70
+ }
71
+
72
+ /** Whether a context takes part in the refcount (not under the override). */
73
+ export function stackRefcounted(
74
+ ctx: StackContext,
75
+ ): ctx is StackContext & { denWorkspace: string } {
76
+ return !!ctx.denWorkspace && ctx.namespaceSource !== "env";
77
+ }
78
+
79
+ function ownersFile(ctx: StackContext, s: StackConfig): string {
80
+ return path.join(stackDir(ctx, s), "owners.json");
81
+ }
82
+
83
+ function readRecord(ctx: StackContext, s: StackConfig): OwnersRecord | null {
84
+ try {
85
+ const raw = JSON.parse(
86
+ fs.readFileSync(ownersFile(ctx, s), "utf-8"),
87
+ ) as Partial<OwnersRecord>;
88
+ if (typeof raw.repoPath !== "string" || !Array.isArray(raw.workspaces))
89
+ return null;
90
+ return {
91
+ project: projectName(s.name, ctx.workspace),
92
+ repoPath: raw.repoPath,
93
+ branch: typeof raw.branch === "string" ? raw.branch : null,
94
+ workspaces: raw.workspaces.filter(
95
+ (w): w is string => typeof w === "string",
96
+ ),
97
+ };
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function writeRecord(
104
+ ctx: StackContext,
105
+ s: StackConfig,
106
+ rec: OwnersRecord,
107
+ ): void {
108
+ const file = ownersFile(ctx, s);
109
+ fs.mkdirSync(path.dirname(file), { recursive: true });
110
+ fs.writeFileSync(file, JSON.stringify(rec, null, 2) + "\n");
111
+ }
112
+
113
+ /** Drop the record once the project is down. */
114
+ export function forgetStackOwners(ctx: StackContext, s: StackConfig): void {
115
+ try {
116
+ fs.unlinkSync(ownersFile(ctx, s));
117
+ } catch {
118
+ /* none recorded */
119
+ }
120
+ }
121
+
122
+ /** The checkout `s` runs from in `workspace`: its repo path and branch. */
123
+ export function stackCheckout(
124
+ workspace: string,
125
+ s: StackConfig,
126
+ ): StackCheckout {
127
+ const repoPath = s.repoPath ?? path.join(getReposRoot(), s.repo);
128
+ let branch: string | null = null;
129
+ try {
130
+ const ws = getWorkspaceByName(workspace);
131
+ if (ws)
132
+ branch =
133
+ listWorkspaceServices(ws.config, readCatalog(), ws.name).find(
134
+ (svc) => svc.repoPath === repoPath,
135
+ )?.branch ?? null;
136
+ } catch {
137
+ /* no branch to tell */
138
+ }
139
+ return { repoPath, branch };
140
+ }
141
+
142
+ const same = (a: StackCheckout, b: StackCheckout): boolean =>
143
+ a.repoPath === b.repoPath && (a.branch ?? null) === (b.branch ?? null);
144
+
145
+ function composeRunning(project: string): boolean | null {
146
+ const r = spawnSync(
147
+ "docker",
148
+ ["ps", "-q", "--filter", `label=com.docker.compose.project=${project}`],
149
+ { encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] },
150
+ );
151
+ if (r.error || r.status !== 0) return null;
152
+ return (r.stdout ?? "").trim().length > 0;
153
+ }
154
+
155
+ const defaultDeps: Required<StackOwnerDeps> = {
156
+ active: getActiveWorkspaces,
157
+ namespaceOf: (w) => {
158
+ try {
159
+ return resolveStackNamespace(w).namespace;
160
+ } catch {
161
+ return null;
162
+ }
163
+ },
164
+ holds: (w, stack) => {
165
+ try {
166
+ if (!getWorkspaceByName(w)) return false;
167
+ return stack in readStacks(undefined, { workspace: w });
168
+ } catch {
169
+ return true; // can't tell: never stop it from under them
170
+ }
171
+ },
172
+ running: composeRunning,
173
+ };
174
+
175
+ /** The record and the live owners other than `workspace`. */
176
+ function coOwners(
177
+ ctx: StackContext,
178
+ s: StackConfig,
179
+ workspace: string,
180
+ deps: StackOwnerDeps,
181
+ ): { record: OwnersRecord | null; others: string[] } {
182
+ const d = { ...defaultDeps, ...deps };
183
+ const record = readRecord(ctx, s);
184
+ if (!record) return { record, others: [] };
185
+ const active = new Set(d.active());
186
+ const others = record.workspaces.filter(
187
+ (w) =>
188
+ w !== workspace &&
189
+ active.has(w) &&
190
+ d.namespaceOf(w) === ctx.workspace &&
191
+ d.holds(w, s.name),
192
+ );
193
+ // A project that has verifiably stopped is held by no one.
194
+ if (others.length && d.running(record.project) === false)
195
+ return { record, others: [] };
196
+ return { record, others };
197
+ }
198
+
199
+ /**
200
+ * Check that `workspace` may bring `s` up: no other live owner runs its
201
+ * project from a different checkout or branch. Returns those other owners
202
+ * (sharing it); throws ServiceConflictError (`identity_mismatch`) when one
203
+ * runs it from elsewhere.
204
+ */
205
+ export function assertStackClaim(
206
+ ctx: StackContext,
207
+ s: StackConfig,
208
+ workspace: string,
209
+ deps: StackOwnerDeps = {},
210
+ ): string[] {
211
+ const { record, others } = coOwners(ctx, s, workspace, deps);
212
+ if (!record || others.length === 0) return [];
213
+ const requested = stackCheckout(workspace, s);
214
+ if (same(record, requested)) return others;
215
+ const project = projectName(s.name, ctx.workspace);
216
+ const n = (b: string | null) => b ?? "n/a";
217
+ throw new ServiceConflictError({
218
+ reason: "identity_mismatch",
219
+ address: s.name,
220
+ owners: others,
221
+ running: { repoPath: record.repoPath, branch: record.branch },
222
+ requested,
223
+ message:
224
+ `stack ${s.name}: Compose project ${project} (stack namespace "${ctx.workspace}") ` +
225
+ `is already running for workspace(s) [${others.join(", ")}] at ${record.repoPath} ` +
226
+ `(branch: ${n(record.branch)}), which differs from ${requested.repoPath} ` +
227
+ `(branch: ${n(requested.branch)}) for workspace "${workspace}". ` +
228
+ `Not swapping its containers: stop it there first, or give "${workspace}" its own stack_namespace`,
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Record `workspace` as an owner of `s`'s project after it is up. Joins the
234
+ * record on the same checkout; a different checkout (reached only once no
235
+ * live owner remains, see assertStackClaim) starts a new one.
236
+ */
237
+ export function recordStackOwner(
238
+ ctx: StackContext,
239
+ s: StackConfig,
240
+ workspace: string,
241
+ ): void {
242
+ const checkout = stackCheckout(workspace, s);
243
+ const prev = readRecord(ctx, s);
244
+ const kept = prev && same(prev, checkout) ? prev.workspaces : [];
245
+ writeRecord(ctx, s, {
246
+ project: projectName(s.name, ctx.workspace),
247
+ ...checkout,
248
+ workspaces: [...new Set([...kept, workspace])],
249
+ });
250
+ }
251
+
252
+ /**
253
+ * `workspace` lets go of `s`'s project. `stop: false` with `remaining`
254
+ * when other live owners still hold it (they stay recorded, re-derived);
255
+ * `stop: true` when it was the last (call forgetStackOwners once down).
256
+ */
257
+ export function releaseStackOwner(
258
+ ctx: StackContext,
259
+ s: StackConfig,
260
+ workspace: string,
261
+ deps: StackOwnerDeps = {},
262
+ ): { stop: boolean; remaining: string[] } {
263
+ const { record, others } = coOwners(ctx, s, workspace, deps);
264
+ if (!record || others.length === 0) return { stop: true, remaining: [] };
265
+ writeRecord(ctx, s, { ...record, workspaces: others });
266
+ return { stop: false, remaining: others };
267
+ }
268
+
269
+ /**
270
+ * Record `workspace` as an owner of a compose stack it is about to skip
271
+ * because it already answers its health check (claimHealthyService).
272
+ * `attached` when it now shares the project with another workspace; null
273
+ * when it holds it alone (recorded all the same), when `resolved` is not a
274
+ * stack's primary service, or under the override;
275
+ * `conflict` when the running project is another checkout's (the caller
276
+ * then runs the real `up`, which refuses with the identity mismatch).
277
+ */
278
+ export function claimHealthyStack(
279
+ resolved: { repoPath: string; repoName: string; subsystem: string },
280
+ workspace: string,
281
+ deps: StackOwnerDeps = {},
282
+ ): "attached" | "conflict" | null {
283
+ try {
284
+ const m = readManifest(resolved.repoPath);
285
+ if (!m?.compose || !isPrimary(m, resolved.subsystem)) return null;
286
+ const ctx = loadStackContext(workspace);
287
+ if (!stackRefcounted(ctx)) return null;
288
+ const s = ctx.stacks[manifestStackName(m, resolved.repoName)];
289
+ if (!s) return null;
290
+ let others: string[];
291
+ try {
292
+ others = assertStackClaim(ctx, s, workspace, deps);
293
+ } catch (e) {
294
+ if (e instanceof ServiceConflictError) return "conflict";
295
+ throw e;
296
+ }
297
+ recordStackOwner(ctx, s, workspace);
298
+ // Held by this workspace alone: recorded, and said as before.
299
+ return others.length ? "attached" : null;
300
+ } catch {
301
+ return null; // no edge, an unreadable manifest: not a stack we can tell
302
+ }
303
+ }
@@ -3,7 +3,12 @@
3
3
  * override, the link networks and the edge routes. No I/O.
4
4
  */
5
5
  import * as yaml from "yaml";
6
- import { edgeDomains, routeHost } from "../edge/render.js";
6
+ import {
7
+ edgeDomains,
8
+ routeEntryPoint,
9
+ routeHost,
10
+ routerTls,
11
+ } from "../edge/render.js";
7
12
  import type { EdgeConfig } from "../edge/types.js";
8
13
  import type { ResolvedProject, StackConfig } from "./types.js";
9
14
 
@@ -220,12 +225,12 @@ export function renderStackRoutes(
220
225
  const { service, port } = parseTarget(target);
221
226
  const key = `stack-${stack.name}-${route}`;
222
227
  routers[key] = {
223
- entryPoints: ["websecure"],
228
+ entryPoints: [routeEntryPoint(edge)],
224
229
  rule: edgeDomains(edge)
225
230
  .map((d) => `Host(\`${routeHost(`${route}.${stack.name}`, d)}\`)`)
226
231
  .join(" || "),
227
232
  service: key,
228
- tls: {},
233
+ ...routerTls(edge),
229
234
  };
230
235
  services[key] = {
231
236
  loadBalancer: {
@@ -60,10 +60,16 @@ function must(
60
60
  export interface StackContext {
61
61
  edge: EdgeConfig;
62
62
  stacks: Record<string, StackConfig>;
63
+ /** The Compose project suffix (the stack namespace). */
63
64
  workspace: string;
65
+ /** The den workspace the stacks were read for (loadStackContext). */
66
+ denWorkspace?: string;
67
+ /** Where the suffix came from; `env`: DEN_STACK_WORKSPACE. */
68
+ namespaceSource?: "workspace" | "blueprint" | "default" | "env";
64
69
  }
65
70
 
66
- function stackDir(ctx: StackContext, s: StackConfig): string {
71
+ /** The project's local state: generated.env, override.yml, owners.json. */
72
+ export function stackDir(ctx: StackContext, s: StackConfig): string {
67
73
  return path.join(
68
74
  ctx.edge.stateDir,
69
75
  "stacks",
@@ -193,6 +199,94 @@ function connect(network: string, container: string): void {
193
199
  throw new Error(`docker network connect ${network} ${container}: ${r.err}`);
194
200
  }
195
201
 
202
+ /**
203
+ * Connect the edge to each of `nets` it is not on (`on`: the networks it
204
+ * is on already; unknown, every one is connected, "already connected"
205
+ * being fine). Returns the networks it joined. stack up and the rejoin
206
+ * after edge up share it.
207
+ */
208
+ function joinEdge(
209
+ edge: EdgeConfig,
210
+ nets: string[],
211
+ on: ReadonlySet<string> = new Set(),
212
+ ): string[] {
213
+ const container = edgeNames(edge.org).container;
214
+ const joined: string[] = [];
215
+ for (const n of nets) {
216
+ if (on.has(n)) continue;
217
+ connect(n, container);
218
+ joined.push(n);
219
+ }
220
+ return joined;
221
+ }
222
+
223
+ /** The dynamic file a running stack's routes live in, under this edge's state dir. */
224
+ function stackRoutesFile(edge: EdgeConfig, project: string): string {
225
+ return path.join(edge.stateDir, "dynamic", `stack-${project}.yml`);
226
+ }
227
+
228
+ /**
229
+ * After edge up (re)creates or starts the edge, put it back on every
230
+ * running stack's routed networks (0.3.0-dev.40): a recreated container
231
+ * starts on its own network only, and each stack's routes 502 until that
232
+ * stack's next `stack up`. A stack counts when it has its routes file in
233
+ * this edge's dynamic dir and its compose project has a container up;
234
+ * stacks that are down, and projects with no routes file here (another
235
+ * state dir's, or none of Den's), are not touched. Idempotent: a network
236
+ * the edge is on already is left alone. One line per network joined;
237
+ * a stack that cannot be resolved gets a line and is skipped.
238
+ */
239
+ export function rejoinEdgeNetworks(
240
+ contexts: StackContext[],
241
+ onLine: (line: string) => void = console.log,
242
+ ): string[] {
243
+ if (!contexts.length) return [];
244
+ const edge = contexts[0].edge;
245
+ const on = new Set(
246
+ run("docker", [
247
+ "inspect",
248
+ "-f",
249
+ "{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
250
+ edgeNames(edge.org).container,
251
+ ]).out.split(/\s+/),
252
+ );
253
+ const joined: string[] = [];
254
+ const seen = new Set<string>();
255
+ for (const ctx of contexts) {
256
+ // Only this edge's state dir.
257
+ if (ctx.edge.stateDir !== edge.stateDir) continue;
258
+ for (const s of Object.values(ctx.stacks)) {
259
+ const project = projectName(s.name, ctx.workspace);
260
+ if (seen.has(project)) continue;
261
+ seen.add(project);
262
+ if (!fs.existsSync(stackRoutesFile(edge, project))) continue;
263
+ const up = run("docker", [
264
+ "ps",
265
+ "-q",
266
+ "--filter",
267
+ `label=com.docker.compose.project=${project}`,
268
+ ]);
269
+ if (up.code !== 0 || !up.out) continue;
270
+ try {
271
+ for (const n of joinEdge(
272
+ edge,
273
+ routedNetworks(s, resolve(ctx, s)),
274
+ on,
275
+ )) {
276
+ on.add(n);
277
+ joined.push(n);
278
+ onLine(` rejoined ${n} (${project})`);
279
+ }
280
+ } catch (err) {
281
+ onLine(
282
+ ` ${project}: not rejoined (${(err as Error).message.split("\n")[0]})`,
283
+ );
284
+ }
285
+ }
286
+ }
287
+ return joined;
288
+ }
289
+
196
290
  function running(container: string): boolean {
197
291
  return (
198
292
  run("docker", ["inspect", "-f", "{{.State.Running}}", container]).out ===
@@ -257,29 +351,19 @@ export function stackUp(
257
351
  for (const svc of providerServices(c)[s.name] ?? [])
258
352
  connect(net, containerName(projectName(s.name, ctx.workspace), svc));
259
353
  }
260
- const edge = edgeNames(ctx.edge.org).container;
261
354
  const nets = routedNetworks(s, resolved);
262
- for (const n of nets) connect(n, edge);
355
+ joinEdge(ctx.edge, nets);
263
356
  fs.writeFileSync(
264
- path.join(
265
- ctx.edge.stateDir,
266
- "dynamic",
267
- `stack-${projectName(s.name, ctx.workspace)}.yml`,
268
- ),
357
+ stackRoutesFile(ctx.edge, projectName(s.name, ctx.workspace)),
269
358
  renderStackRoutes(ctx.edge, s, resolved),
270
359
  );
271
360
  return { project: resolved.name, networks: nets };
272
361
  }
273
362
 
274
363
  export function stackDown(ctx: StackContext, s: StackConfig): void {
275
- fs.rmSync(
276
- path.join(
277
- ctx.edge.stateDir,
278
- "dynamic",
279
- `stack-${projectName(s.name, ctx.workspace)}.yml`,
280
- ),
281
- { force: true },
282
- );
364
+ fs.rmSync(stackRoutesFile(ctx.edge, projectName(s.name, ctx.workspace)), {
365
+ force: true,
366
+ });
283
367
  const edge = edgeNames(ctx.edge.org).container;
284
368
  const dir = repoDir(s);
285
369
  const override = path.join(stackDir(ctx, s), "override.yml");