@monster-orange/den 0.2.6 → 0.2.8
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 +225 -0
- package/bin/den +9 -0
- package/docs/distributions.md +205 -6
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +84 -5
- package/src/shared/environment-manager/catalog.ts +61 -3
- package/src/shared/environment-manager/default-workspace.ts +53 -14
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/health.ts +51 -8
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +69 -26
- package/src/shared/environment-manager/workspace-org.ts +279 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +19 -2
- package/src/ui/cli/clone-plan.ts +374 -0
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +346 -48
- package/src/ui/cli/org-command.ts +172 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +94 -9
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +96 -5
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/readonly-routes.ts +12 -3
- package/src/ui/server/release-service.ts +40 -5
- package/src/ui/server/workspace-routes.ts +13 -6
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A distribution config that fails validation (ORG-1d's `orgs:` checks, for
|
|
3
|
+
* now). It is the user's to fix, not a crash: the CLI prints it and exits 1
|
|
4
|
+
* before doing anything, and the web server answers it as
|
|
5
|
+
* `{ ok: false, error, code: "config_invalid" }` with status 422.
|
|
6
|
+
*
|
|
7
|
+
* The message names config keys (`orgs.initech.repos`), never values a
|
|
8
|
+
* secret could hide in.
|
|
9
|
+
*/
|
|
10
|
+
export class ConfigError extends Error {
|
|
11
|
+
readonly code = "config_invalid";
|
|
12
|
+
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ConfigError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isConfigError(e: unknown): e is ConfigError {
|
|
20
|
+
return e instanceof ConfigError;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The web server's status for a ConfigError: the request was fine, the config is not. */
|
|
24
|
+
export const CONFIG_ERROR_STATUS = 422;
|
|
25
|
+
|
|
26
|
+
/** The JSON body for a ConfigError (the `{ok, error, code}` frame R26 extends). */
|
|
27
|
+
export function configErrorBody(e: ConfigError): {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
code: "config_invalid";
|
|
31
|
+
} {
|
|
32
|
+
return { ok: false, error: e.message, code: e.code };
|
|
33
|
+
}
|
package/src/shared/den-config.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { Environment, ReleaseSourceConfig } from "./types/index.js";
|
|
|
16
16
|
import { getDistRoot } from "./paths.js";
|
|
17
17
|
import type { SecretsConfig } from "./secrets/index.js";
|
|
18
18
|
import type { OrgConfig } from "./org.js";
|
|
19
|
+
import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
|
|
20
|
+
import { ConfigError, isConfigError } from "./config-error.js";
|
|
19
21
|
import type { TestAccountsConfig } from "./auth/test-accounts.js";
|
|
20
22
|
|
|
21
23
|
import { product } from "./product.js";
|
|
@@ -194,11 +196,13 @@ export interface DenConfig {
|
|
|
194
196
|
/** GitHub org slug used to list org repos in the web UI (e.g. "Globex-Corp"). */
|
|
195
197
|
github_org?: string;
|
|
196
198
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
199
|
+
* Work orgs, keyed by org id: a display name and an optional colour. An
|
|
200
|
+
* entry with neither `sources` nor `repos` is the GitHub owner of its own
|
|
201
|
+
* name (ORG-1a). ORG-1d: `sources: [{host, owner|group}]` binds the org to
|
|
202
|
+
* git-host owners, and `repos: [...]` lists repos outright. See
|
|
203
|
+
* src/shared/org-sources.ts.
|
|
200
204
|
*/
|
|
201
|
-
orgs?:
|
|
205
|
+
orgs?: WorkOrgsConfig;
|
|
202
206
|
/** Derived-dependency-graph tuning (aliases + edge overrides). */
|
|
203
207
|
graph?: GraphConfig;
|
|
204
208
|
/**
|
|
@@ -412,6 +416,41 @@ export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
|
|
|
412
416
|
return org;
|
|
413
417
|
}
|
|
414
418
|
|
|
419
|
+
let checked: { key: string; error: ConfigError | null } | null = null;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Throw the config's validation error (a ConfigError naming its keys), if it
|
|
423
|
+
* has one, without reading anything else. The CLI runs this before any
|
|
424
|
+
* command that touches orgs, the catalog, clones or releases, and the web
|
|
425
|
+
* server before every config-reading route, so a config that fails
|
|
426
|
+
* validation stops them all, not only those that happen to read `orgs:`.
|
|
427
|
+
* A missing file, or one that does not parse, is left to readDenConfig.
|
|
428
|
+
* Memoised on the file's mtime and size: the server checks per request.
|
|
429
|
+
*/
|
|
430
|
+
export function assertDenConfigValid(): void {
|
|
431
|
+
let key: string;
|
|
432
|
+
try {
|
|
433
|
+
const st = fs.statSync(DEN_CONFIG_PATH);
|
|
434
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
435
|
+
} catch {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (checked?.key !== key) {
|
|
439
|
+
let error: ConfigError | null = null;
|
|
440
|
+
try {
|
|
441
|
+
const parsed = yaml.parse(fs.readFileSync(DEN_CONFIG_PATH, "utf-8")) as {
|
|
442
|
+
orgs?: unknown;
|
|
443
|
+
} | null;
|
|
444
|
+
validateOrgSources(parsed?.orgs, product().configFile);
|
|
445
|
+
} catch (e) {
|
|
446
|
+
if (!isConfigError(e)) return;
|
|
447
|
+
error = e;
|
|
448
|
+
}
|
|
449
|
+
checked = { key, error };
|
|
450
|
+
}
|
|
451
|
+
if (checked.error) throw new ConfigError(checked.error.message);
|
|
452
|
+
}
|
|
453
|
+
|
|
415
454
|
/**
|
|
416
455
|
* Read the effective den config, merging the shared deployment config with
|
|
417
456
|
* per-engineer overrides from config/local/user.yml.
|
|
@@ -422,6 +461,9 @@ export function readDenConfig(): DenConfig {
|
|
|
422
461
|
try {
|
|
423
462
|
const raw = fs.readFileSync(DEN_CONFIG_PATH, "utf-8");
|
|
424
463
|
const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
|
|
464
|
+
// ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
|
|
465
|
+
// value throws naming its key; configs without them are not touched.
|
|
466
|
+
validateOrgSources(parsed?.orgs, product().configFile);
|
|
425
467
|
// `environments` may be malformed (scalar/object) in a hand-edited file —
|
|
426
468
|
// fall back to the default set rather than throwing on .filter below.
|
|
427
469
|
const envs = Array.isArray(parsed?.environments)
|
|
@@ -636,6 +678,8 @@ export interface ResolvedWorkspaceBlueprint {
|
|
|
636
678
|
branch: string;
|
|
637
679
|
autostartOff: string[];
|
|
638
680
|
focus: boolean;
|
|
681
|
+
/** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
|
|
682
|
+
org?: string;
|
|
639
683
|
}
|
|
640
684
|
|
|
641
685
|
/** Which blueprint shape the distribution uses, and its workspaces. */
|
|
@@ -644,7 +688,7 @@ export interface WorkspaceBlueprints {
|
|
|
644
688
|
workspaces: ResolvedWorkspaceBlueprint[];
|
|
645
689
|
}
|
|
646
690
|
|
|
647
|
-
const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off"];
|
|
691
|
+
const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off", "org"];
|
|
648
692
|
|
|
649
693
|
function blueprintStrings(where: string, key: string, v: unknown): string[] {
|
|
650
694
|
if (v === undefined) return [];
|
|
@@ -674,12 +718,15 @@ function resolveBlueprint(
|
|
|
674
718
|
throw new Error(`${where}.branch must be a branch name`);
|
|
675
719
|
if (bp.focus !== undefined && typeof bp.focus !== "boolean")
|
|
676
720
|
throw new Error(`${where}.focus must be true or false`);
|
|
721
|
+
if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
|
|
722
|
+
throw new Error(`${where}.org must be an org id (a key of orgs:)`);
|
|
677
723
|
return {
|
|
678
724
|
name,
|
|
679
725
|
repos: blueprintStrings(where, "repos", bp.repos),
|
|
680
726
|
branch,
|
|
681
727
|
autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
|
|
682
728
|
focus: bp.focus === true,
|
|
729
|
+
...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
|
|
683
730
|
};
|
|
684
731
|
}
|
|
685
732
|
|
|
@@ -864,6 +911,38 @@ export function readReleaseConfig(): ReleaseConfig {
|
|
|
864
911
|
return cfg;
|
|
865
912
|
}
|
|
866
913
|
|
|
914
|
+
/**
|
|
915
|
+
* `release.org` as configured (den.yml, then user.yml), without the
|
|
916
|
+
* `github_org` fallback readReleaseConfig fills in. An explicit value is the
|
|
917
|
+
* release owner of every repo (REL-1); unset, each repo's own catalog owner is.
|
|
918
|
+
*/
|
|
919
|
+
export function readExplicitReleaseOrg(): string | undefined {
|
|
920
|
+
const den = mergeReleaseLayer(
|
|
921
|
+
DEFAULT_RELEASE_CONFIG,
|
|
922
|
+
readDenConfig().release,
|
|
923
|
+
);
|
|
924
|
+
const org = mergeReleaseLayer(den, readUserConfig().release).org;
|
|
925
|
+
return typeof org === "string" && org.trim() ? org.trim() : undefined;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The GitHub owner a repo is released from (REL-1): an explicit
|
|
930
|
+
* `release.org` wins, then the repo's non-empty catalog owner, then
|
|
931
|
+
* `github_org`. "" when none is known.
|
|
932
|
+
*/
|
|
933
|
+
export function releaseOwnerFor(opts: {
|
|
934
|
+
explicitOrg?: string;
|
|
935
|
+
catalogOwner?: string;
|
|
936
|
+
githubOrg?: string;
|
|
937
|
+
}): string {
|
|
938
|
+
return (
|
|
939
|
+
opts.explicitOrg?.trim() ||
|
|
940
|
+
opts.catalogOwner?.trim() ||
|
|
941
|
+
opts.githubOrg?.trim() ||
|
|
942
|
+
""
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
867
946
|
/**
|
|
868
947
|
* Persist a personal release override to config/local/user.yml (gitignored).
|
|
869
948
|
* Used by the Release tab's wave editor — team defaults stay in den.yml.
|
|
@@ -14,7 +14,13 @@ import * as path from "path";
|
|
|
14
14
|
import * as yaml from "yaml";
|
|
15
15
|
import type { ServiceCatalog, ServiceCatalogEntry } from "../types/index.js";
|
|
16
16
|
import { getDistRoot } from "../paths.js";
|
|
17
|
-
import { requireGithubOrg } from "../den-config.js";
|
|
17
|
+
import { readDenConfig, requireGithubOrg } from "../den-config.js";
|
|
18
|
+
import {
|
|
19
|
+
parseRemote,
|
|
20
|
+
repoSource,
|
|
21
|
+
type WorkOrgsConfig,
|
|
22
|
+
} from "../org-sources.js";
|
|
23
|
+
import { checkedRemote, isGitRemote } from "./git-remote.js";
|
|
18
24
|
|
|
19
25
|
/** Fallback GitHub org when `config/den.yml` doesn't set `github_org`. */
|
|
20
26
|
|
|
@@ -55,7 +61,8 @@ export function upsertCatalogEntry(
|
|
|
55
61
|
/**
|
|
56
62
|
* GitHub slug to clone `repoName` from.
|
|
57
63
|
*
|
|
58
|
-
* The catalog's `gh` when set, else the conventional `<
|
|
64
|
+
* The catalog's `gh` when set, else the conventional `<org>/<repo>`, where
|
|
65
|
+
* `<org>` is the workspace's resolved org (`github_org` when none is given) —
|
|
59
66
|
* the same rule `seedDefaultWorkspace` uses. Entries written by the
|
|
60
67
|
* dev-managed add flow carry `gh: ""`, so a repo can be IN the catalog and
|
|
61
68
|
* still have no slug; deriving it (rather than refusing to clone) is what lets
|
|
@@ -64,10 +71,61 @@ export function upsertCatalogEntry(
|
|
|
64
71
|
export function resolveRepoGhSlug(
|
|
65
72
|
repoName: string,
|
|
66
73
|
catalog: ServiceCatalog = readCatalog(),
|
|
74
|
+
/**
|
|
75
|
+
* The owner for a repo with no `gh`: the workspace's resolved org
|
|
76
|
+
* (`() => githubOrgFor(ws)`, ORG-1a). Called only when needed, so a repo
|
|
77
|
+
* with a slug never needs an org. Defaults to `github_org`.
|
|
78
|
+
*/
|
|
79
|
+
owner: () => string = () => requireGithubOrg(),
|
|
67
80
|
): string {
|
|
68
81
|
const gh = catalog[repoName]?.gh?.trim();
|
|
69
82
|
if (gh) return gh;
|
|
70
|
-
return `${
|
|
83
|
+
return `${owner()}/${repoName}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What `repoName` clones from (ORG-1d): a GitHub slug or a git URL (see
|
|
88
|
+
* git-remote.ts). The repo's source decides: a GitHub source gives its slug
|
|
89
|
+
* (`gh`, else `<owner>/<repo>`). A repo with no source and a `remote:` gives
|
|
90
|
+
* that remote, through plain git. Anything else is the legacy GitHub slug
|
|
91
|
+
* (resolveRepoGhSlug), so a catalog without `remote:` clones as before.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveRepoCloneSource(
|
|
94
|
+
repoName: string,
|
|
95
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
96
|
+
owner: () => string = () => requireGithubOrg(),
|
|
97
|
+
orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
|
|
98
|
+
): string {
|
|
99
|
+
const entry = catalog[repoName];
|
|
100
|
+
const gh = entry?.gh?.trim();
|
|
101
|
+
const remote = entry?.remote?.trim();
|
|
102
|
+
const src = repoSource(repoName, catalog, orgs ?? {});
|
|
103
|
+
if (src?.host === "github") {
|
|
104
|
+
if (gh) return gh;
|
|
105
|
+
const fromRemote = remote ? parseRemote(remote) : null;
|
|
106
|
+
return fromRemote?.host === "github"
|
|
107
|
+
? `${fromRemote.owner}/${repoName}`
|
|
108
|
+
: `${src.owner ?? ""}/${repoName}`;
|
|
109
|
+
}
|
|
110
|
+
if (remote) return checkedRemote(repoName, remote);
|
|
111
|
+
if (src)
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${repoName}: its org's source is on ${src.host}, which Den clones only ` +
|
|
114
|
+
`from a remote: — set ${repoName}.remote in the service catalog`,
|
|
115
|
+
);
|
|
116
|
+
return resolveRepoGhSlug(repoName, catalog, owner);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether a clone source may be written back as the catalog's `gh` (the
|
|
121
|
+
* empty-slug repair): only a GitHub slug, and only for an entry with no
|
|
122
|
+
* `gh` and no `remote` of its own.
|
|
123
|
+
*/
|
|
124
|
+
export function needsGhRepair(
|
|
125
|
+
entry: ServiceCatalogEntry | undefined,
|
|
126
|
+
source: string,
|
|
127
|
+
): boolean {
|
|
128
|
+
return !isGitRemote(source) && !entry?.gh?.trim() && !entry?.remote?.trim();
|
|
71
129
|
}
|
|
72
130
|
|
|
73
131
|
export function removeCatalogEntry(repoName: string): void {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* all managed. On a fresh clone (no local workspace) den offers — once —
|
|
6
6
|
* to seed the `default` workspace from it so `den graph` / `den up` show the
|
|
7
7
|
* full system graph immediately; `den init` (run in the den root) re-seeds.
|
|
8
|
-
* Catalog clone URLs derive as `<
|
|
8
|
+
* Catalog clone URLs derive as `<org>/<repo>`, with the workspace's resolved org
|
|
9
|
+
* (workspace-org.ts; `github_org` unless the blueprint or catalog says otherwise). Switch any repo to a local
|
|
9
10
|
* checkout with `den use-local <repo>`.
|
|
10
11
|
*/
|
|
11
12
|
import {
|
|
@@ -16,9 +17,15 @@ import {
|
|
|
16
17
|
import { readCatalog, writeCatalog } from "./catalog.js";
|
|
17
18
|
import {
|
|
18
19
|
readWorkspaceBlueprints,
|
|
19
|
-
requireGithubOrg,
|
|
20
20
|
type ResolvedWorkspaceBlueprint,
|
|
21
21
|
} from "../den-config.js";
|
|
22
|
+
import {
|
|
23
|
+
githubOrgFor,
|
|
24
|
+
readOrgContext,
|
|
25
|
+
resolveWorkspaceOrg,
|
|
26
|
+
type OrgContext,
|
|
27
|
+
} from "./workspace-org.js";
|
|
28
|
+
import { orgHasGithub } from "../org-sources.js";
|
|
22
29
|
import type {
|
|
23
30
|
ServiceCatalog,
|
|
24
31
|
WorkspaceConfig,
|
|
@@ -47,10 +54,6 @@ export function defaultWorkspaceRepos(): string[] {
|
|
|
47
54
|
return singleBlueprint()?.repos ?? [];
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
function org(): string {
|
|
51
|
-
return requireGithubOrg();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
57
|
function blueprintServices(
|
|
55
58
|
bp: ResolvedWorkspaceBlueprint,
|
|
56
59
|
repos: string[] = bp.repos,
|
|
@@ -70,14 +73,47 @@ function blueprintServices(
|
|
|
70
73
|
return services;
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The clone slug seeding would give each repo the catalog has no `gh` for:
|
|
78
|
+
* `<org>/<repo>` with its workspace's resolved org. A repo in several
|
|
79
|
+
* workspaces takes the first one's. Reads only; throws when a repo needs an
|
|
80
|
+
* org and none resolves (no org anywhere and no `github_org`).
|
|
81
|
+
*/
|
|
82
|
+
export function catalogFill(
|
|
83
|
+
workspaces: { name: string; repos: string[] }[],
|
|
84
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
85
|
+
ctx: OrgContext = readOrgContext(),
|
|
86
|
+
): Map<string, string> {
|
|
87
|
+
const fill = new Map<string, string>();
|
|
88
|
+
for (const ws of workspaces) {
|
|
89
|
+
// ORG-1d: an org with no GitHub source has no slug to give; its repos
|
|
90
|
+
// clone from their catalog `remote:`.
|
|
91
|
+
const org = resolveWorkspaceOrg(ws.name, ctx).org;
|
|
92
|
+
if (org && !orgHasGithub(org, ctx.config.orgs)) continue;
|
|
93
|
+
for (const repo of ws.repos) {
|
|
94
|
+
if (catalog[repo]?.gh || catalog[repo]?.remote || fill.has(repo))
|
|
95
|
+
continue;
|
|
96
|
+
fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return fill;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Give each blueprint repo with no clone URL `<org>/<repo>`, where `<org>` is
|
|
104
|
+
* its workspace's resolved org (ORG-1a: the blueprint's `org:`, else the
|
|
105
|
+
* catalog owners, else `github_org`), so a client workspace never clones
|
|
106
|
+
* from the default org on a fresh machine. The org is resolved only for a
|
|
107
|
+
* repo that needs one, and before anything is written: an unresolvable org
|
|
108
|
+
* throws with the catalog untouched.
|
|
109
|
+
*/
|
|
73
110
|
function fillCatalog(
|
|
74
|
-
repos: string[],
|
|
111
|
+
workspaces: { name: string; repos: string[] }[],
|
|
75
112
|
catalog: ServiceCatalog = readCatalog(),
|
|
76
113
|
): void {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
114
|
+
const fill = catalogFill(workspaces, catalog);
|
|
115
|
+
if (fill.size === 0) return;
|
|
116
|
+
for (const [repo, gh] of fill) catalog[repo] = { gh };
|
|
81
117
|
writeCatalog(catalog);
|
|
82
118
|
}
|
|
83
119
|
|
|
@@ -178,6 +214,9 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
178
214
|
if (!bp) return [];
|
|
179
215
|
const repos = bp.repos;
|
|
180
216
|
|
|
217
|
+
// The catalog first: it is what can fail (no github_org), and a failure
|
|
218
|
+
// must leave the workspaces as they were, not reset and uncloneable.
|
|
219
|
+
fillCatalog([{ name: "default", repos }]);
|
|
181
220
|
const file = readWorkspacesFile();
|
|
182
221
|
file.workspaces.default = {
|
|
183
222
|
...(file.workspaces.default ?? {}),
|
|
@@ -189,7 +228,6 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
189
228
|
file.focus = "default";
|
|
190
229
|
}
|
|
191
230
|
writeWorkspacesFile(file);
|
|
192
|
-
fillCatalog(repos);
|
|
193
231
|
return repos;
|
|
194
232
|
}
|
|
195
233
|
|
|
@@ -202,6 +240,7 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
202
240
|
export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
203
241
|
const bps = blueprintWorkspaces();
|
|
204
242
|
if (bps.length === 0) return [];
|
|
243
|
+
fillCatalog(bps);
|
|
205
244
|
const file = readWorkspacesFile();
|
|
206
245
|
for (const bp of bps) {
|
|
207
246
|
const prev: WorkspaceConfig | undefined = file.workspaces[bp.name];
|
|
@@ -223,7 +262,6 @@ export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
|
223
262
|
file.active = [...names, ...kept.filter((n) => !names.includes(n))];
|
|
224
263
|
file.focus = (bps.find((b) => b.focus) ?? bps[0]).name;
|
|
225
264
|
writeWorkspacesFile(file);
|
|
226
|
-
fillCatalog([...new Set(bps.flatMap((b) => b.repos))]);
|
|
227
265
|
return bps.map((b) => ({ name: b.name, repos: [...b.repos] }));
|
|
228
266
|
}
|
|
229
267
|
|
|
@@ -277,7 +315,8 @@ export function topUpDefaultWorkspace(): string[] {
|
|
|
277
315
|
|
|
278
316
|
def.seeded_repos = [...repos];
|
|
279
317
|
writeWorkspacesFile(file);
|
|
280
|
-
if (missing.length > 0)
|
|
318
|
+
if (missing.length > 0)
|
|
319
|
+
fillCatalog([{ name: "default", repos: missing }], catalog);
|
|
281
320
|
return missing;
|
|
282
321
|
}
|
|
283
322
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a managed clone is made from (ORG-1d): a GitHub slug (`owner/repo`,
|
|
3
|
+
* cloned from github.com with the user's gh as credential helper) or a git
|
|
4
|
+
* URL from the catalog's `remote:` (cloned through plain git and the user's
|
|
5
|
+
* own credential helper, never Den's or gh's: SEC-0).
|
|
6
|
+
*
|
|
7
|
+
* Clone commands run through a shell, so a remote must pass REMOTE_RE: a URL
|
|
8
|
+
* with a known transport, scp-style `user@host:path`, or an absolute path,
|
|
9
|
+
* and nothing a shell or git would read as a command (`ext::`, quotes, `$`,
|
|
10
|
+
* spaces, a leading `-`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A GitHub slug, as the clone guards accept it. */
|
|
14
|
+
export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
15
|
+
|
|
16
|
+
const REMOTE_RE =
|
|
17
|
+
/^(?:(?:https?|ssh|git|file):\/\/[A-Za-z0-9._~@:%+/-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~+/-]+|\/[A-Za-z0-9._~@+/-]+)$/;
|
|
18
|
+
|
|
19
|
+
/** True when `spec` is a git URL rather than a GitHub slug. */
|
|
20
|
+
export function isGitRemote(spec: string): boolean {
|
|
21
|
+
return !SAFE_SLUG_RE.test(spec);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when `remote` is a git URL Den will hand to git. */
|
|
25
|
+
export function isSafeRemote(remote: string): boolean {
|
|
26
|
+
return REMOTE_RE.test(remote) && !remote.includes("..");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `remote` for display: any user info (`https://user:token@host/…`) masked.
|
|
31
|
+
* Remotes can carry a token, so one is never printed as written.
|
|
32
|
+
*/
|
|
33
|
+
export function redactRemote(remote: string): string {
|
|
34
|
+
return remote.replace(/^([a-z+]+:\/\/)[^@/]+@/i, "$1***@");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The checked remote for `repo`, or a throw naming its catalog key (and not
|
|
39
|
+
* its value, which may hold a token).
|
|
40
|
+
*/
|
|
41
|
+
export function checkedRemote(repo: string, remote: string): string {
|
|
42
|
+
const r = remote.trim();
|
|
43
|
+
if (!isSafeRemote(r))
|
|
44
|
+
throw new Error(
|
|
45
|
+
`service catalog: ${repo}.remote is not a git URL Den can clone ` +
|
|
46
|
+
`(https://, ssh://, git://, file://, user@host:path or an absolute path)`,
|
|
47
|
+
);
|
|
48
|
+
return r;
|
|
49
|
+
}
|
|
@@ -230,6 +230,28 @@ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
|
|
|
230
230
|
return _trust;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The body a reverse proxy sends for a host it has no route for: Traefik's
|
|
235
|
+
* default 404 (Go's http.NotFound). Behind a shared edge a stopped service
|
|
236
|
+
* answers with this instead of refusing the connection, so a 4xx-means-up
|
|
237
|
+
* rule would show it healthy. A service's own 404 carries its own body.
|
|
238
|
+
*/
|
|
239
|
+
const EDGE_NO_ROUTE_BODY = "404 page not found";
|
|
240
|
+
|
|
241
|
+
/** Up: 2xx, or a 4xx from the service itself (auth/route proves it answers). */
|
|
242
|
+
function statusOk(status: number, body404?: string): boolean {
|
|
243
|
+
if (status === 404 && body404?.trim() === EDGE_NO_ROUTE_BODY) return false;
|
|
244
|
+
return status < 300 || (status >= 400 && status < 500);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The PingResult for `status`, naming an edge 404 as "no route". */
|
|
248
|
+
function statusResult(status: number, body404?: string): PingResult {
|
|
249
|
+
const ok = statusOk(status, body404);
|
|
250
|
+
return ok || status !== 404
|
|
251
|
+
? { ok, status }
|
|
252
|
+
: { ok, status, error: "no route (edge 404)" };
|
|
253
|
+
}
|
|
254
|
+
|
|
233
255
|
/**
|
|
234
256
|
* HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
|
|
235
257
|
* hostname for SNI and certificate verification; rewriting the URL to the IP,
|
|
@@ -276,11 +298,23 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
|
|
|
276
298
|
},
|
|
277
299
|
(res) => {
|
|
278
300
|
const status = res.statusCode ?? 0;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
ok:
|
|
282
|
-
|
|
301
|
+
if (status !== 404) {
|
|
302
|
+
res.resume(); // discard the body
|
|
303
|
+
done({ ok: statusOk(status), status });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// A 404: read a little of the body to tell the edge's own "no
|
|
307
|
+
// route" from the service's 404.
|
|
308
|
+
let body = "";
|
|
309
|
+
res.setEncoding("utf8");
|
|
310
|
+
res.on("data", (c: string) => {
|
|
311
|
+
if (body.length < 64) body += c;
|
|
312
|
+
if (body.length >= 64) res.destroy();
|
|
283
313
|
});
|
|
314
|
+
const finish = () => done(statusResult(status, body));
|
|
315
|
+
res.on("end", finish);
|
|
316
|
+
res.on("close", finish);
|
|
317
|
+
res.on("error", finish);
|
|
284
318
|
},
|
|
285
319
|
);
|
|
286
320
|
const timer = setTimeout(() => {
|
|
@@ -334,11 +368,20 @@ async function pingOnce(
|
|
|
334
368
|
// (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
|
|
335
369
|
// on a different port — the previous `< 500` check showed those as green.
|
|
336
370
|
// 5xx = unhealthy (service errored).
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
// sockets in the long-lived web server
|
|
371
|
+
// A 404 may be the edge's own "no route" (see EDGE_NO_ROUTE_BODY): read
|
|
372
|
+
// a little of it. Otherwise discard the body: only the status matters,
|
|
373
|
+
// and unconsumed bodies pin sockets in the long-lived web server.
|
|
374
|
+
if (res.status === 404) {
|
|
375
|
+
let body = "";
|
|
376
|
+
try {
|
|
377
|
+
body = (await res.text()).slice(0, 64);
|
|
378
|
+
} catch {
|
|
379
|
+
/* unreadable body: judge by the status alone */
|
|
380
|
+
}
|
|
381
|
+
return statusResult(res.status, body);
|
|
382
|
+
}
|
|
340
383
|
void res.body?.cancel().catch(() => {});
|
|
341
|
-
return { ok, status: res.status };
|
|
384
|
+
return { ok: statusOk(res.status), status: res.status };
|
|
342
385
|
} catch (e: unknown) {
|
|
343
386
|
clearTimeout(timer);
|
|
344
387
|
const err = e as Error;
|
|
@@ -69,7 +69,7 @@ import type {
|
|
|
69
69
|
WorkspaceConfig,
|
|
70
70
|
} from "../types/index.js";
|
|
71
71
|
import type { ChildProcess } from "child_process";
|
|
72
|
-
import { getStateDir } from "../paths.js";
|
|
72
|
+
import { getDistRoot, getStateDir } from "../paths.js";
|
|
73
73
|
|
|
74
74
|
import { product } from "../product.js";
|
|
75
75
|
// Re-export so existing callers don't need to change their import path.
|
|
@@ -101,6 +101,33 @@ export interface DaemonRecord {
|
|
|
101
101
|
* command. Optional for backward-compat with records written before this.
|
|
102
102
|
*/
|
|
103
103
|
containers?: string[];
|
|
104
|
+
/**
|
|
105
|
+
* The distribution root (realpath) whose server or CLI registered this
|
|
106
|
+
* daemon. Every root of a product shares one daemons file, so startup
|
|
107
|
+
* pruning skips records stamped by another root. Missing on records
|
|
108
|
+
* written before 0.2.7.
|
|
109
|
+
*/
|
|
110
|
+
root?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** This process's distribution root, as stamped on its daemon records. */
|
|
114
|
+
function daemonRoot(): string | null {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync(getDistRoot());
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whose a daemon record is: this root's, another root's (all roots of a
|
|
124
|
+
* product share one daemons file), or unstamped (written before 0.2.7).
|
|
125
|
+
*/
|
|
126
|
+
export function daemonRootOf(
|
|
127
|
+
d: Pick<DaemonRecord, "root">,
|
|
128
|
+
): "here" | "other" | "unstamped" {
|
|
129
|
+
if (!d.root) return "unstamped";
|
|
130
|
+
return d.root === daemonRoot() ? "here" : "other";
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
export type ServiceConflictReason = "identity_mismatch" | "orphan_containers";
|
|
@@ -263,8 +290,10 @@ export function registerDaemon(record: DaemonRecord): void {
|
|
|
263
290
|
? (existing?.workspaces ?? [])
|
|
264
291
|
: [];
|
|
265
292
|
const workspaces = [...new Set([...inherited, ...(record.workspaces ?? [])])];
|
|
293
|
+
const root = daemonRoot() ?? record.root;
|
|
266
294
|
_daemonRegistry.set(record.address, {
|
|
267
295
|
...record,
|
|
296
|
+
...(root ? { root } : {}),
|
|
268
297
|
workspaces,
|
|
269
298
|
branch: normalizeBranch(record.branch),
|
|
270
299
|
});
|
|
@@ -296,9 +325,17 @@ export function updateDaemonContainers(
|
|
|
296
325
|
saveDaemonsFile(getDaemonsFile());
|
|
297
326
|
}
|
|
298
327
|
|
|
328
|
+
/**
|
|
329
|
+
* This root's daemons (and unstamped legacy ones). Every root of a product
|
|
330
|
+
* shares one daemons file; another root's records stay in it untouched but
|
|
331
|
+
* are not listed, so this server never shows, stops, releases or sweeps
|
|
332
|
+
* them. Lookups by address (findDaemonByAddress) still see every record.
|
|
333
|
+
*/
|
|
299
334
|
export function listDaemons(): DaemonRecord[] {
|
|
300
335
|
loadDaemonsFromFile();
|
|
301
|
-
return Array.from(_daemonRegistry.values())
|
|
336
|
+
return Array.from(_daemonRegistry.values()).filter(
|
|
337
|
+
(d) => daemonRootOf(d) !== "other",
|
|
338
|
+
);
|
|
302
339
|
}
|
|
303
340
|
|
|
304
341
|
export function findDaemonByAddress(address: string): DaemonRecord | undefined {
|
|
@@ -646,8 +683,11 @@ export function isDaemonVerifiablyDead(
|
|
|
646
683
|
* clear stale entries from previous sessions. Otherwise they reopen as tabs in
|
|
647
684
|
* the web UI and clutter the shutdown prompt.
|
|
648
685
|
*
|
|
649
|
-
* A daemon
|
|
650
|
-
*
|
|
686
|
+
* A daemon stamped by another root (all roots of a product share the file)
|
|
687
|
+
* is never touched. Otherwise it is dropped when:
|
|
688
|
+
* - it was stamped by this root and its address no longer resolves to a
|
|
689
|
+
* workspace/catalog service (an unstamped legacy record might belong to
|
|
690
|
+
* another root, so this alone never drops one), OR
|
|
651
691
|
* - it is opctl-based (has captured containers or a discoverable dev-op) and
|
|
652
692
|
* NONE of its candidate containers are currently running.
|
|
653
693
|
*
|
|
@@ -687,7 +727,13 @@ function pruneAgainst(
|
|
|
687
727
|
const pruned: string[] = [];
|
|
688
728
|
for (const d of daemons) {
|
|
689
729
|
try {
|
|
690
|
-
|
|
730
|
+
const root = daemonRootOf(d);
|
|
731
|
+
// Another root's daemon: its services resolve in its workspaces, not
|
|
732
|
+
// ours, and its liveness is its own server's call.
|
|
733
|
+
if (root === "other") continue;
|
|
734
|
+
// An unresolvable address is only proof of staleness for a record
|
|
735
|
+
// this root wrote; an unstamped one may belong to another root.
|
|
736
|
+
if (root === "here" && !canResolveDaemonAddress(d)) {
|
|
691
737
|
unregisterDaemon(d.address);
|
|
692
738
|
pruned.push(d.address);
|
|
693
739
|
continue;
|