@monster-orange/den 0.2.7 → 0.3.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.
- package/CHANGELOG.md +1655 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +619 -3
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +63 -0
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +185 -7
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +26 -3
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/environment-manager/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +328 -0
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +24 -5
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +62 -3
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +649 -72
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +298 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +151 -24
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +29 -45
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +590 -45
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +15 -3
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +24 -4
- package/src/ui/server/release-service.ts +25 -3
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +55 -9
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
package/src/ui/cli/clone-plan.ts
CHANGED
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
*
|
|
6
6
|
* On a fresh machine (no workspace set up yet, a blueprint in the config)
|
|
7
7
|
* the plan is what `<cli> workspace seed` and then setup would do, for every
|
|
8
|
-
* blueprint workspace. Otherwise it is the focused
|
|
9
|
-
* ones setup works on
|
|
8
|
+
* blueprint workspace (`-w` narrows it). Otherwise it is the focused
|
|
9
|
+
* workspace's repos, the ones setup works on, or the `-w` / `--all-active`
|
|
10
|
+
* ones; repos only other workspaces hold are named in a hint.
|
|
10
11
|
*/
|
|
11
12
|
import * as fs from "fs";
|
|
12
13
|
import * as path from "path";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
readCatalog,
|
|
16
|
+
resolveRepoCloneSource,
|
|
17
|
+
} from "../../shared/environment-manager/catalog.js";
|
|
18
|
+
import {
|
|
19
|
+
isGitRemote,
|
|
20
|
+
redactRemote,
|
|
21
|
+
} from "../../shared/environment-manager/git-remote.js";
|
|
22
|
+
import { readDenConfig } from "../../shared/den-config.js";
|
|
23
|
+
import type { WorkOrgsConfig } from "../../shared/org-sources.js";
|
|
14
24
|
import {
|
|
15
25
|
blueprintWorkspaces,
|
|
16
26
|
catalogFill,
|
|
@@ -32,6 +42,7 @@ import {
|
|
|
32
42
|
} from "../../shared/environment-manager/workspace-org.js";
|
|
33
43
|
import { orgOf } from "../../shared/manifest/service-links.js";
|
|
34
44
|
import type { ServiceCatalog } from "../../shared/types/index.js";
|
|
45
|
+
import { plural } from "../../shared/plural.js";
|
|
35
46
|
|
|
36
47
|
export type ClonePlanState =
|
|
37
48
|
| "cloned"
|
|
@@ -64,6 +75,39 @@ export interface ClonePlan {
|
|
|
64
75
|
/** True when planned from the blueprint (nothing seeded yet). */
|
|
65
76
|
fromBlueprint: boolean;
|
|
66
77
|
workspaces: ClonePlanWorkspace[];
|
|
78
|
+
/** Repos held only by workspaces outside the plan, per workspace. */
|
|
79
|
+
outside?: { workspace: string; repos: string[] }[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Which workspaces `setup --plan` plans (default: the focused one). */
|
|
83
|
+
export interface PlanSelection {
|
|
84
|
+
/** `-w <ws>`, repeatable. */
|
|
85
|
+
workspaces?: readonly string[];
|
|
86
|
+
/** `--all-active`: every active workspace (plus any `-w` one). */
|
|
87
|
+
allActive?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The branch a checkout has out, read from its HEAD file (no git process):
|
|
92
|
+
* the branch name, `detached <sha7>`, or null when it can't be read.
|
|
93
|
+
*/
|
|
94
|
+
export function checkoutBranch(dir: string): string | null {
|
|
95
|
+
try {
|
|
96
|
+
let gitDir = path.join(dir, ".git");
|
|
97
|
+
if (fs.statSync(gitDir).isFile()) {
|
|
98
|
+
// A worktree or submodule: .git names the real git dir.
|
|
99
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, "utf-8"));
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
gitDir = path.resolve(dir, m[1].trim());
|
|
102
|
+
}
|
|
103
|
+
const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf-8").trim();
|
|
104
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
105
|
+
if (ref) return ref[1];
|
|
106
|
+
if (/^[0-9a-f]{40,64}$/.test(head)) return `detached ${head.slice(0, 7)}`;
|
|
107
|
+
return null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
67
111
|
}
|
|
68
112
|
|
|
69
113
|
const ownerOf = (slug: string): string => slug.slice(0, slug.indexOf("/"));
|
|
@@ -72,22 +116,33 @@ function cloneDir(repo: string, branch: string): string {
|
|
|
72
116
|
return path.join(DEN_SERVICES_DIR, repo, branch);
|
|
73
117
|
}
|
|
74
118
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
119
|
+
/**
|
|
120
|
+
* What setup and up would clone `repo` from (a GitHub slug, or an ORG-1d
|
|
121
|
+
* `remote:`), or null without an org. `owner` gives the slug's owner for a
|
|
122
|
+
* repo with neither `gh` nor `remote`.
|
|
123
|
+
*/
|
|
124
|
+
function sourceFor(
|
|
77
125
|
repo: string,
|
|
78
|
-
workspace: string,
|
|
79
126
|
catalog: ServiceCatalog,
|
|
80
127
|
ctx: OrgContext,
|
|
128
|
+
owner: () => string,
|
|
81
129
|
): string | null {
|
|
82
|
-
const gh = catalog[repo]?.gh?.trim();
|
|
83
|
-
if (gh) return gh;
|
|
84
130
|
try {
|
|
85
|
-
return
|
|
131
|
+
return resolveRepoCloneSource(repo, catalog, owner, ctx.config.orgs);
|
|
86
132
|
} catch {
|
|
87
133
|
return null;
|
|
88
134
|
}
|
|
89
135
|
}
|
|
90
136
|
|
|
137
|
+
function slugFor(
|
|
138
|
+
repo: string,
|
|
139
|
+
workspace: string,
|
|
140
|
+
catalog: ServiceCatalog,
|
|
141
|
+
ctx: OrgContext,
|
|
142
|
+
): string | null {
|
|
143
|
+
return sourceFor(repo, catalog, ctx, () => githubOrgFor(workspace, ctx));
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
function managedRow(
|
|
92
147
|
repo: string,
|
|
93
148
|
branch: string,
|
|
@@ -96,12 +151,13 @@ function managedRow(
|
|
|
96
151
|
): ClonePlanRow {
|
|
97
152
|
const dir = cloneDir(repo, branch);
|
|
98
153
|
const cloned = fs.existsSync(dir);
|
|
154
|
+
const remote = slug !== null && isGitRemote(slug);
|
|
99
155
|
return {
|
|
100
156
|
repo,
|
|
101
157
|
mode: "managed",
|
|
102
158
|
branch,
|
|
103
|
-
owner: slug ? ownerOf(slug)
|
|
104
|
-
url: slug ?
|
|
159
|
+
owner: !slug ? null : remote ? null : ownerOf(slug),
|
|
160
|
+
url: !slug ? null : remote ? redactRemote(slug) : ghCloneUrl(slug),
|
|
105
161
|
state: cloned
|
|
106
162
|
? "cloned"
|
|
107
163
|
: !slug
|
|
@@ -135,7 +191,9 @@ export function workspaceClonePlan(
|
|
|
135
191
|
rows.push({
|
|
136
192
|
repo: repoName,
|
|
137
193
|
mode: "dev-managed",
|
|
138
|
-
branch
|
|
194
|
+
// Den doesn't set a checkout's branch: say what it has out.
|
|
195
|
+
branch:
|
|
196
|
+
(entry.path && checkoutBranch(entry.path)) || "(branch unknown)",
|
|
139
197
|
owner: owner || null,
|
|
140
198
|
url: null,
|
|
141
199
|
state: "checkout",
|
|
@@ -175,28 +233,88 @@ function blueprintClonePlan(ctx: OrgContext): ClonePlanWorkspace[] {
|
|
|
175
233
|
managedRow(
|
|
176
234
|
repo,
|
|
177
235
|
bp.branch,
|
|
178
|
-
ctx.catalog
|
|
236
|
+
sourceFor(repo, ctx.catalog, ctx, () => {
|
|
237
|
+
const slug = fill.get(repo);
|
|
238
|
+
if (!slug) throw new Error(`no org for ${repo}`);
|
|
239
|
+
return ownerOf(slug);
|
|
240
|
+
}),
|
|
179
241
|
true, // seeding writes every blueprint repo into the catalog
|
|
180
242
|
),
|
|
181
243
|
),
|
|
182
244
|
}));
|
|
183
245
|
}
|
|
184
246
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
247
|
+
/**
|
|
248
|
+
* `<cli> setup --plan`: the plan for `names` (every repo when empty) in the
|
|
249
|
+
* selected workspaces. Throws on a workspace that does not exist.
|
|
250
|
+
*/
|
|
251
|
+
export function buildSetupPlan(
|
|
252
|
+
names: readonly string[] = [],
|
|
253
|
+
sel: PlanSelection = {},
|
|
254
|
+
): ClonePlan {
|
|
187
255
|
const ctx = readOrgContext();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
256
|
+
const want = [...new Set(sel.workspaces ?? [])];
|
|
257
|
+
if (isFreshWorkspaceSetup(ctx.file) && blueprintWorkspaces().length > 0) {
|
|
258
|
+
const all = blueprintClonePlan(ctx);
|
|
259
|
+
const unknown = want.filter((n) => !all.some((w) => w.name === n));
|
|
260
|
+
if (unknown.length)
|
|
261
|
+
throw new Error(
|
|
262
|
+
`no blueprint workspace named ${unknown.join(", ")} (the blueprint has ${all.map((w) => w.name).join(", ")})`,
|
|
263
|
+
);
|
|
264
|
+
return {
|
|
265
|
+
fromBlueprint: true,
|
|
266
|
+
workspaces: want.length ? all.filter((w) => want.includes(w.name)) : all,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const focused = getActiveWorkspace()?.name;
|
|
270
|
+
const planned = [
|
|
271
|
+
...new Set(
|
|
272
|
+
sel.allActive
|
|
273
|
+
? [...ctx.file.active, ...want]
|
|
274
|
+
: want.length
|
|
275
|
+
? want
|
|
276
|
+
: focused
|
|
277
|
+
? [focused]
|
|
278
|
+
: [],
|
|
279
|
+
),
|
|
280
|
+
];
|
|
281
|
+
const unknown = planned.filter((n) => !ctx.file.workspaces[n]);
|
|
282
|
+
if (unknown.length)
|
|
283
|
+
throw new Error(
|
|
284
|
+
`no workspace named ${unknown.join(", ")} (workspaces: ${Object.keys(ctx.file.workspaces).join(", ") || "none"})`,
|
|
285
|
+
);
|
|
286
|
+
const workspaces = planned.map((n) =>
|
|
287
|
+
workspaceClonePlan(n, names.length ? names : undefined, ctx),
|
|
288
|
+
);
|
|
192
289
|
return {
|
|
193
290
|
fromBlueprint: false,
|
|
194
|
-
workspaces
|
|
195
|
-
|
|
196
|
-
],
|
|
291
|
+
workspaces,
|
|
292
|
+
outside: outsideRepos(ctx, planned, workspaces),
|
|
197
293
|
};
|
|
198
294
|
}
|
|
199
295
|
|
|
296
|
+
/** Repos that only workspaces outside `planned` hold, per workspace. */
|
|
297
|
+
function outsideRepos(
|
|
298
|
+
ctx: OrgContext,
|
|
299
|
+
planned: readonly string[],
|
|
300
|
+
workspaces: readonly ClonePlanWorkspace[],
|
|
301
|
+
): { workspace: string; repos: string[] }[] {
|
|
302
|
+
const inPlan = new Set(workspaces.flatMap((w) => w.rows.map((r) => r.repo)));
|
|
303
|
+
const out: { workspace: string; repos: string[] }[] = [];
|
|
304
|
+
for (const [name, ws] of Object.entries(ctx.file.workspaces)) {
|
|
305
|
+
if (planned.includes(name)) continue;
|
|
306
|
+
const repos = [
|
|
307
|
+
...new Set(
|
|
308
|
+
Object.keys(ws?.services ?? {}).map(
|
|
309
|
+
(a) => parseAddress(a, ctx.catalog).repoName,
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
].filter((r) => !inPlan.has(r));
|
|
313
|
+
if (repos.length) out.push({ workspace: name, repos });
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
200
318
|
const STATE_TEXT: Record<ClonePlanState, string> = {
|
|
201
319
|
cloned: "cloned",
|
|
202
320
|
"would-clone": "would clone",
|
|
@@ -210,6 +328,7 @@ export function renderClonePlan(
|
|
|
210
328
|
plan: ClonePlan,
|
|
211
329
|
cli: string,
|
|
212
330
|
catalog: ServiceCatalog = readCatalog(),
|
|
331
|
+
orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
|
|
213
332
|
): string[] {
|
|
214
333
|
const out: string[] = [];
|
|
215
334
|
if (plan.fromBlueprint)
|
|
@@ -230,7 +349,7 @@ export function renderClonePlan(
|
|
|
230
349
|
? ` ${r.path}`
|
|
231
350
|
: ""
|
|
232
351
|
: "";
|
|
233
|
-
const owner = r.owner ?? orgOf(r.repo, catalog) ?? "";
|
|
352
|
+
const owner = r.owner ?? orgOf(r.repo, catalog, orgs) ?? "";
|
|
234
353
|
out.push(
|
|
235
354
|
` ${r.repo} ${r.mode} ${r.branch} org: ${owner || "(unknown)"}` +
|
|
236
355
|
`${r.url ? ` ${r.url}` : ""} ${STATE_TEXT[r.state]}${where}`,
|
|
@@ -238,6 +357,18 @@ export function renderClonePlan(
|
|
|
238
357
|
}
|
|
239
358
|
for (const w of ws.warnings) out.push(` ⚠ ${w}`);
|
|
240
359
|
}
|
|
360
|
+
const outside = plan.outside ?? [];
|
|
361
|
+
if (outside.length) {
|
|
362
|
+
const n = new Set(outside.flatMap((o) => o.repos)).size;
|
|
363
|
+
out.push(
|
|
364
|
+
"",
|
|
365
|
+
`Not in this plan: ${plural(n, "repo")} in other workspaces: ` +
|
|
366
|
+
outside
|
|
367
|
+
.map((o) => `${o.workspace} (${o.repos.join(", ")})`)
|
|
368
|
+
.join(", ") +
|
|
369
|
+
`. Plan them with -w <workspace> or --all-active.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
241
372
|
out.push("", "Plan only: nothing was cloned, seeded or written.");
|
|
242
373
|
return out;
|
|
243
374
|
}
|
package/src/ui/cli/create.ts
CHANGED
|
@@ -146,17 +146,26 @@ export function renderDistribution(
|
|
|
146
146
|
...bannerLines(title).map((l) => ` - "${l}"`),
|
|
147
147
|
"",
|
|
148
148
|
"# The GitHub org (or user) that owns the repos Den clones and lists.",
|
|
149
|
-
"
|
|
149
|
+
"# Unset, Den lists no GitHub repos (and never calls gh for them). E.g.",
|
|
150
|
+
"# github_org: example-org",
|
|
150
151
|
"",
|
|
151
|
-
"# Secrets backend:
|
|
152
|
-
"#
|
|
152
|
+
"# Secrets backend: none (this distribution needs no secrets store), or",
|
|
153
|
+
"# env (plain environment variables), doppler or 1password. A backend's",
|
|
154
|
+
"# refs say where Den's own features find their secrets, e.g.",
|
|
155
|
+
`# refs: { sudo: { key: ${prefix}_LOCAL_ADMIN_PWD } }`,
|
|
153
156
|
"secrets:",
|
|
154
|
-
" provider:
|
|
157
|
+
" provider: none",
|
|
155
158
|
"",
|
|
156
|
-
"# This instance's
|
|
159
|
+
"# This instance's edge: <service>.<domain>, one Traefik for its routes.",
|
|
157
160
|
"edge:",
|
|
158
161
|
` org: ${name}`,
|
|
159
162
|
` domain: ${name}.localhost`,
|
|
163
|
+
" # TLS: none serves http only. For https, pick a strategy:",
|
|
164
|
+
" # tls: { strategy: mkcert } # a local CA, per machine",
|
|
165
|
+
" # tls: { strategy: mkcert, ca: ~/path/to/ca } # an existing mkcert CA",
|
|
166
|
+
" # tls: { strategy: files, include: edge/dynamic, mounts: { <host>: <container> } }",
|
|
167
|
+
" # # your own certs, in included Traefik config",
|
|
168
|
+
" tls: none",
|
|
160
169
|
"",
|
|
161
170
|
"environments: [local]",
|
|
162
171
|
"",
|
|
@@ -344,13 +353,21 @@ export function runCreate(argv: string[], cwd: string): number {
|
|
|
344
353
|
denVersion: runningDenVersion(),
|
|
345
354
|
});
|
|
346
355
|
const cli = args.cli ?? args.name;
|
|
347
|
-
|
|
356
|
+
// Relative only when the new directory is under the caller's cwd; a
|
|
357
|
+
// path that climbs out (../../..) is harder to read than the absolute one.
|
|
358
|
+
const rel = path.relative(cwd, args.dir);
|
|
359
|
+
const cdPath =
|
|
360
|
+
rel === ""
|
|
361
|
+
? "."
|
|
362
|
+
: rel.startsWith("..") || path.isAbsolute(rel)
|
|
363
|
+
? args.dir
|
|
364
|
+
: rel;
|
|
348
365
|
console.log(
|
|
349
366
|
`Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
|
|
350
367
|
);
|
|
351
368
|
console.log("");
|
|
352
369
|
console.log("Next:");
|
|
353
|
-
console.log(` cd ${
|
|
370
|
+
console.log(` cd ${cdPath}`);
|
|
354
371
|
console.log(" npm install");
|
|
355
372
|
console.log(` ./bin/${cli} --help`);
|
|
356
373
|
return 0;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doctor`: every strategy kind in the registry (secrets, tls), the
|
|
3
|
+
* strategy this distribution selects for it, whether nothing chose it
|
|
4
|
+
* (implicit), and its detect() result with the reason.
|
|
5
|
+
*
|
|
6
|
+
* Read-only: detect() is a PATH check, so doctor never spawns a backend
|
|
7
|
+
* CLI, never prompts, and never prints a secret value. Exit 1 when a
|
|
8
|
+
* strategy that will be used is unavailable or its config is invalid.
|
|
9
|
+
*/
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { readBrand } from "../../shared/brand.js";
|
|
12
|
+
import { hasEdgeBlock, readEdgeConfig } from "../../shared/edge/config.js";
|
|
13
|
+
import { edgeTlsWarnings, tlsStrategyFor } from "../../shared/edge/tls.js";
|
|
14
|
+
import {
|
|
15
|
+
readSecretsConfig,
|
|
16
|
+
secretsStatus,
|
|
17
|
+
} from "../../shared/secrets/index.js";
|
|
18
|
+
import { strategyKinds } from "../../shared/strategies/registry.js";
|
|
19
|
+
|
|
20
|
+
export interface DoctorRow {
|
|
21
|
+
kind: string;
|
|
22
|
+
/** The selected strategy id ("" when the config could not be read). */
|
|
23
|
+
selected: string;
|
|
24
|
+
implicit: boolean;
|
|
25
|
+
available: boolean;
|
|
26
|
+
reason?: string;
|
|
27
|
+
/** True when it will be used and cannot work: doctor exits 1. */
|
|
28
|
+
problem: boolean;
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Selector = (env: NodeJS.ProcessEnv) => DoctorRow;
|
|
33
|
+
|
|
34
|
+
const SELECTORS: Record<string, Selector> = {
|
|
35
|
+
secrets(env) {
|
|
36
|
+
const st = secretsStatus(readSecretsConfig(), env);
|
|
37
|
+
return {
|
|
38
|
+
kind: "secrets",
|
|
39
|
+
selected: st.backend,
|
|
40
|
+
implicit: st.implicit,
|
|
41
|
+
available: st.detect.available,
|
|
42
|
+
reason: st.detect.reason,
|
|
43
|
+
// The implicit default is used only when a secret is asked for.
|
|
44
|
+
problem: !st.detect.available && !st.implicit,
|
|
45
|
+
warnings: [],
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
tls(env) {
|
|
49
|
+
if (!hasEdgeBlock())
|
|
50
|
+
return {
|
|
51
|
+
kind: "tls",
|
|
52
|
+
selected: "none",
|
|
53
|
+
implicit: false,
|
|
54
|
+
available: true,
|
|
55
|
+
reason: "no edge: block",
|
|
56
|
+
problem: false,
|
|
57
|
+
warnings: [],
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
const cfg = readEdgeConfig();
|
|
61
|
+
const d = tlsStrategyFor(cfg).detect(env);
|
|
62
|
+
return {
|
|
63
|
+
kind: "tls",
|
|
64
|
+
selected: cfg.tls.strategy,
|
|
65
|
+
implicit: cfg.tlsFrom === "implicit",
|
|
66
|
+
available: d.available,
|
|
67
|
+
reason: d.reason,
|
|
68
|
+
problem: !d.available,
|
|
69
|
+
warnings: edgeTlsWarnings(cfg),
|
|
70
|
+
};
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return {
|
|
73
|
+
kind: "tls",
|
|
74
|
+
selected: "",
|
|
75
|
+
implicit: false,
|
|
76
|
+
available: false,
|
|
77
|
+
reason: (err as Error).message,
|
|
78
|
+
problem: true,
|
|
79
|
+
warnings: [],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One row per registered kind (secrets, then tls, then any other kind in
|
|
87
|
+
* registration order). Never spawns, never throws.
|
|
88
|
+
*/
|
|
89
|
+
export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
|
|
90
|
+
const kinds = strategyKinds();
|
|
91
|
+
const ordered = [
|
|
92
|
+
...Object.keys(SELECTORS).filter((k) => kinds.includes(k)),
|
|
93
|
+
...kinds.filter((k) => !(k in SELECTORS)),
|
|
94
|
+
];
|
|
95
|
+
return ordered.map(
|
|
96
|
+
(kind) =>
|
|
97
|
+
SELECTORS[kind]?.(env) ?? {
|
|
98
|
+
kind,
|
|
99
|
+
selected: "",
|
|
100
|
+
implicit: false,
|
|
101
|
+
available: false,
|
|
102
|
+
reason: "no selector for this kind",
|
|
103
|
+
problem: false,
|
|
104
|
+
warnings: [],
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatDoctor(rows: DoctorRow[], title: string): string[] {
|
|
110
|
+
const lines = [chalk.bold(`${title} doctor: strategies`), ""];
|
|
111
|
+
const width = Math.max(...rows.map((r) => r.kind.length));
|
|
112
|
+
for (const r of rows) {
|
|
113
|
+
const sel = r.selected
|
|
114
|
+
? `${r.selected}${r.implicit ? " (implicit)" : ""}`
|
|
115
|
+
: "(invalid config)";
|
|
116
|
+
const state = r.available
|
|
117
|
+
? chalk.green("✓ available")
|
|
118
|
+
: (r.problem ? chalk.red : chalk.yellow)("✗ unavailable");
|
|
119
|
+
lines.push(
|
|
120
|
+
` ${r.kind.padEnd(width)} ${sel.padEnd(20)} ${state}${r.reason ? `: ${r.reason}` : ""}`,
|
|
121
|
+
);
|
|
122
|
+
for (const w of r.warnings) lines.push(chalk.yellow(` ⚠ ${w}`));
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function cmdDoctor(_args: string[]): number {
|
|
128
|
+
const rows = doctorRows();
|
|
129
|
+
for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
|
|
130
|
+
return rows.some((r) => r.problem) ? 1 : 0;
|
|
131
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `edge up | down | status | trust | untrust | routes`
|
|
2
|
+
* `edge up [--take-over] | down | status | trust | untrust | routes`
|
|
3
3
|
*
|
|
4
|
-
* This org's
|
|
4
|
+
* This org's edge: routes at `<name>.<edge.domain>`, TLS as `edge.tls` says
|
|
5
|
+
* (none, mkcert or files). `trust` / `untrust` exist only under mkcert.
|
|
6
|
+
* `routes` lists Den's own routes, then the routers in the included
|
|
7
|
+
* dynamic files, each with its source file, then the stacks' routers
|
|
8
|
+
* (`stack-*.yml`, 0.3.0-dev.32), each marked "stack" with its file.
|
|
5
9
|
*/
|
|
6
10
|
import chalk from "chalk";
|
|
7
11
|
import { readBrand } from "../../shared/brand.js";
|
|
@@ -15,18 +19,37 @@ import {
|
|
|
15
19
|
trustCa,
|
|
16
20
|
untrustCa,
|
|
17
21
|
} from "../../shared/edge/runtime.js";
|
|
18
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
includedRouters,
|
|
24
|
+
stackRouters,
|
|
25
|
+
routerUrls,
|
|
26
|
+
type IncludedRouter,
|
|
27
|
+
} from "../../shared/edge/included.js";
|
|
28
|
+
import { edgeRoutes, edgeStatus } from "../../shared/edge/status.js";
|
|
29
|
+
import { tlsYaml } from "../../shared/edge/tls.js";
|
|
30
|
+
import { rejoinRunningStacks } from "../../shared/stacks/status.js";
|
|
31
|
+
|
|
32
|
+
/** An included router's URLs (the hosts its rule names), else its name. */
|
|
33
|
+
function includedUrl(r: IncludedRouter): string {
|
|
34
|
+
return r.hosts.length ? routerUrls(r).join(", ") : r.name;
|
|
35
|
+
}
|
|
19
36
|
|
|
20
37
|
export async function cmdEdge(args: string[]): Promise<void> {
|
|
21
38
|
const sub = args[0] ?? "status";
|
|
22
39
|
const cfg = readEdgeConfig();
|
|
23
40
|
const n = edgeNames(cfg.org);
|
|
24
41
|
const published = cfg.ports
|
|
25
|
-
?
|
|
42
|
+
? cfg.tls.strategy === "none"
|
|
43
|
+
? `host :${cfg.ports.http} (http only)`
|
|
44
|
+
: `host :${cfg.ports.http} / :${cfg.ports.https}`
|
|
26
45
|
: `not published (reach it on Docker network ${n.network})`;
|
|
27
46
|
switch (sub) {
|
|
28
47
|
case "up": {
|
|
29
|
-
|
|
48
|
+
// --take-over: the only way to replace an edge another state dir
|
|
49
|
+
// owns (0.3.0-dev.40); never implied.
|
|
50
|
+
const { certs } = await edgeUp(cfg, {
|
|
51
|
+
takeOver: args.includes("--take-over"),
|
|
52
|
+
});
|
|
30
53
|
console.log(
|
|
31
54
|
chalk.green(
|
|
32
55
|
`✓ ${n.container} up — ${edgeDomains(cfg)
|
|
@@ -37,7 +60,7 @@ export async function cmdEdge(args: string[]): Promise<void> {
|
|
|
37
60
|
console.log(` ports ${published}`);
|
|
38
61
|
if (certs.issued)
|
|
39
62
|
console.log(` cert issued for ${certs.names.join(", ")}`);
|
|
40
|
-
if (caTrusted(cfg) === false)
|
|
63
|
+
if (cfg.tls.strategy === "mkcert" && caTrusted(cfg) === false)
|
|
41
64
|
console.log(
|
|
42
65
|
chalk.yellow(
|
|
43
66
|
" CA not trusted yet — run `edge trust` (asks for your password once)",
|
|
@@ -45,6 +68,8 @@ export async function cmdEdge(args: string[]): Promise<void> {
|
|
|
45
68
|
);
|
|
46
69
|
for (const name of ["edge", ...Object.keys(cfg.routes)])
|
|
47
70
|
console.log(` ${routeUrl(name, cfg)}`);
|
|
71
|
+
// Running stacks' routes stay up: the edge rejoins their networks.
|
|
72
|
+
rejoinRunningStacks();
|
|
48
73
|
return;
|
|
49
74
|
}
|
|
50
75
|
case "down":
|
|
@@ -57,10 +82,25 @@ export async function cmdEdge(args: string[]): Promise<void> {
|
|
|
57
82
|
case "untrust":
|
|
58
83
|
process.exitCode = untrustCa(cfg);
|
|
59
84
|
return;
|
|
60
|
-
case "routes":
|
|
61
|
-
for (const r of
|
|
85
|
+
case "routes": {
|
|
86
|
+
for (const r of edgeRoutes(cfg))
|
|
62
87
|
console.log(`${r.url.padEnd(52)} → ${r.upstream}`);
|
|
88
|
+
// Routers in the included dynamic files: Traefik serves them too.
|
|
89
|
+
const inc = includedRouters(cfg);
|
|
90
|
+
for (const r of inc.routers)
|
|
91
|
+
console.log(
|
|
92
|
+
`${includedUrl(r).padEnd(52)} → ${r.upstream || "?"} (${r.file})`,
|
|
93
|
+
);
|
|
94
|
+
// Routers the stacks wrote (stack up): neither Den's nor included.
|
|
95
|
+
const stk = stackRouters(cfg);
|
|
96
|
+
for (const r of stk.routers)
|
|
97
|
+
console.log(
|
|
98
|
+
`${includedUrl(r).padEnd(52)} → ${r.upstream || "?"} (stack: ${r.file})`,
|
|
99
|
+
);
|
|
100
|
+
for (const e of [...inc.errors, ...stk.errors])
|
|
101
|
+
console.log(chalk.yellow(`${e.file}: not read (${e.error})`));
|
|
63
102
|
return;
|
|
103
|
+
}
|
|
64
104
|
case "status": {
|
|
65
105
|
const st = edgeStatus(cfg);
|
|
66
106
|
console.log(chalk.bold(`${readBrand().name} edge — ${st.org}`));
|
|
@@ -68,14 +108,20 @@ export async function cmdEdge(args: string[]): Promise<void> {
|
|
|
68
108
|
console.log(` ports ${published}`);
|
|
69
109
|
console.log(` domains ${st.domains.join(", ")} (+ every subdomain)`);
|
|
70
110
|
console.log(
|
|
71
|
-
`
|
|
111
|
+
` tls ${tlsYaml(cfg.tls).replace(/^tls: /, "")}${cfg.tlsFrom === "implicit" ? " (implicit: edge.tls is not set)" : ""}`,
|
|
72
112
|
);
|
|
113
|
+
if (cfg.tls.strategy !== "none")
|
|
114
|
+
console.log(
|
|
115
|
+
// Fronted (edge.publish: false): the proxy in front owns
|
|
116
|
+
// browser trust (BOOT-1), so this machine's trust is moot.
|
|
117
|
+
` CA ${caDir(cfg)} — ${!st.ports ? "not needed: fronted" : st.caTrusted === null ? "unknown" : st.caTrusted ? "trusted" : "NOT trusted"}`,
|
|
118
|
+
);
|
|
73
119
|
if (st.certIssued) console.log(` cert ${st.certNames.join(", ")}`);
|
|
74
120
|
return;
|
|
75
121
|
}
|
|
76
122
|
default:
|
|
77
123
|
throw new Error(
|
|
78
|
-
`Unknown edge command "${sub}". Use: up | down | status | trust | untrust | routes`,
|
|
124
|
+
`Unknown edge command "${sub}". Use: up [--take-over] | down | status | trust | untrust | routes`,
|
|
79
125
|
);
|
|
80
126
|
}
|
|
81
127
|
}
|