@monster-orange/den 0.4.14 → 0.4.15
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 +127 -0
- package/README.md +7 -7
- package/docs/distributions.md +144 -8
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +33 -4
- package/src/shared/agents/den-claude.ts +13 -3
- package/src/shared/agents/records.ts +18 -0
- package/src/shared/agents/runtime.ts +6 -1
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +12 -0
- package/src/shared/environment-manager/workspaces.ts +20 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +75 -22
- package/src/ui/cli/agents-command.ts +4 -1
- package/src/ui/cli/doctor-command.ts +29 -3
- package/src/ui/cli/doctor-onboard.ts +26 -0
- package/src/ui/cli/flag-parser.ts +9 -1
- package/src/ui/cli/index.ts +29 -3
- package/src/ui/cli/onboard-command.ts +180 -0
- package/src/ui/cli/onboard-reads.ts +613 -0
- package/src/ui/cli/repo-command.ts +156 -10
- package/src/ui/cli/web-command.ts +92 -1
- package/src/ui/mcp/api.ts +14 -2
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +368 -1
- package/src/ui/server/agent-routes.ts +2 -5
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +12 -4
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +286 -16
- package/src/ui/server/lifecycle-exec.ts +70 -0
- package/src/ui/server/onboard-routes.ts +132 -0
- package/src/ui/server/pair-routes.ts +7 -4
- package/src/ui/server/public/agents-page.js +91 -4
- package/src/ui/server/public/error-page.js +27 -3
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +29 -0
- package/src/ui/server/public/i18n/ja.js +31 -0
- package/src/ui/server/public/index.html +1 -1
- package/src/ui/server/public/local-dev.html +5 -4
- package/src/ui/server/public/persona-ui.css +21 -0
- package/src/ui/server/public/release.html +0 -1
- package/src/ui/server/public/settings-registry.js +3 -1
- package/src/ui/server/public/settings.html +9 -1
- package/src/ui/server/public/shell.js +313 -0
- package/src/ui/server/request-guard.ts +2 -0
- package/src/ui/server/route-classes.ts +28 -1
- package/src/ui/server/session-routes.ts +11 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- package/themes/den/den-theme.json +6 -6
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ON-3's read actions, as data: what the MCP tools `onboard_status`,
|
|
3
|
+
* `repos_detect` and `manifest_draft` answer, what the web server's
|
|
4
|
+
* /api/onboard/* routes serve, and what their CLI twins print
|
|
5
|
+
* (`onboard status`, `repo detect`, `repo add --dry-run`). One copy, so a
|
|
6
|
+
* person, the browser and an agent get the same answer.
|
|
7
|
+
*
|
|
8
|
+
* All three only read. Nothing here writes a file, runs a repo's tools, or
|
|
9
|
+
* follows a symlink while scanning.
|
|
10
|
+
*
|
|
11
|
+
* **The path rule** (checkOnboardPath), for `repos_detect` and
|
|
12
|
+
* `manifest_draft`: a path is required, absolute (or `~/…`), resolved with
|
|
13
|
+
* its symlinks, and must be a folder inside the person's home folder and
|
|
14
|
+
* outside Den's own state folder. Anything else is refused with the reason.
|
|
15
|
+
*
|
|
16
|
+
* **The scan** (reposDetect): the folder itself when it is a git repo (it
|
|
17
|
+
* has `.git`), else its subfolders one and two levels down. A repo's own
|
|
18
|
+
* subfolders are not scanned. Skipped: symlinks (never followed), dot-folders,
|
|
19
|
+
* node_modules and vendor. At most `maxRepos` repos (default 50) and
|
|
20
|
+
* MAX_ENTRIES folder entries are read; past either, `truncated` is true.
|
|
21
|
+
*
|
|
22
|
+
* **Suggested workspaces:** the repos in one parent folder go in one
|
|
23
|
+
* workspace named after that folder. A repo directly in the scanned folder
|
|
24
|
+
* gets the scanned folder's name; `<folder>/<group>/<repo>` gets `<group>`.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from "fs";
|
|
27
|
+
import * as os from "os";
|
|
28
|
+
import * as path from "path";
|
|
29
|
+
import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
30
|
+
import {
|
|
31
|
+
heldManifestPath,
|
|
32
|
+
repoManifestPath,
|
|
33
|
+
} from "../../shared/manifest/read-manifest.js";
|
|
34
|
+
import {
|
|
35
|
+
MCP_CLIENTS,
|
|
36
|
+
RUNTIME_CLIENTS,
|
|
37
|
+
tomlHasEntry,
|
|
38
|
+
type ConnectEnv,
|
|
39
|
+
type ServerSpec,
|
|
40
|
+
} from "../mcp/connect.js";
|
|
41
|
+
import {
|
|
42
|
+
onboardingChecks,
|
|
43
|
+
type OnboardCheck,
|
|
44
|
+
type OnboardDeps,
|
|
45
|
+
} from "./doctor-onboard.js";
|
|
46
|
+
import { detectRunCommands, type DetectedCommands } from "./repo-detect.js";
|
|
47
|
+
import {
|
|
48
|
+
repoAdd,
|
|
49
|
+
type ManifestValidator,
|
|
50
|
+
type RepoAddResult,
|
|
51
|
+
} from "./repo-command.js";
|
|
52
|
+
|
|
53
|
+
// ── The path rule ───────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export interface PathRules {
|
|
56
|
+
/** The person's home folder. */
|
|
57
|
+
home: string;
|
|
58
|
+
/** Den's own state folder (getStateDir()): never scanned. */
|
|
59
|
+
stateDir: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type PathCheck =
|
|
63
|
+
| { ok: true; dir: string }
|
|
64
|
+
| { ok: false; error: string };
|
|
65
|
+
|
|
66
|
+
/** `child` is `parent` or inside it. */
|
|
67
|
+
function within(child: string, parent: string): boolean {
|
|
68
|
+
const rel = path.relative(parent, child);
|
|
69
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function real(p: string): string {
|
|
73
|
+
try {
|
|
74
|
+
return fs.realpathSync(p);
|
|
75
|
+
} catch {
|
|
76
|
+
return path.resolve(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The folder `p` names, when the path rule allows it (see the module comment). */
|
|
81
|
+
export function checkOnboardPath(
|
|
82
|
+
p: string | undefined,
|
|
83
|
+
rules: PathRules,
|
|
84
|
+
): PathCheck {
|
|
85
|
+
const given = (p ?? "").trim();
|
|
86
|
+
if (!given)
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: "give a folder: path is required (for example ~/code)",
|
|
90
|
+
};
|
|
91
|
+
const expanded =
|
|
92
|
+
given === "~"
|
|
93
|
+
? rules.home
|
|
94
|
+
: given.startsWith("~/")
|
|
95
|
+
? path.join(rules.home, given.slice(2))
|
|
96
|
+
: given;
|
|
97
|
+
if (!path.isAbsolute(expanded))
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `give an absolute path (or one starting with ~/), not "${given}"`,
|
|
101
|
+
};
|
|
102
|
+
let dir: string;
|
|
103
|
+
try {
|
|
104
|
+
dir = fs.realpathSync(expanded);
|
|
105
|
+
} catch {
|
|
106
|
+
return { ok: false, error: `there is no folder at ${expanded}` };
|
|
107
|
+
}
|
|
108
|
+
if (!fs.statSync(dir).isDirectory())
|
|
109
|
+
return { ok: false, error: `${expanded} is a file, not a folder` };
|
|
110
|
+
const home = real(rules.home);
|
|
111
|
+
if (!within(dir, home))
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: `${expanded} is outside your home folder (${home}); Den only reads folders inside it`,
|
|
115
|
+
};
|
|
116
|
+
if (within(dir, real(rules.stateDir)))
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: `${expanded} is in Den's own state folder; Den doesn't scan it`,
|
|
120
|
+
};
|
|
121
|
+
return { ok: true, dir };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The rules for this process: its home and Den's state folder. */
|
|
125
|
+
export function processPathRules(stateDir: string): PathRules {
|
|
126
|
+
return { home: os.homedir(), stateDir };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── repos_detect ────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Folder names never scanned (besides every dot-folder). */
|
|
132
|
+
export const SKIP_DIRS: readonly string[] = ["node_modules", "vendor"];
|
|
133
|
+
/** Default and ceiling for how many repos one scan reports. */
|
|
134
|
+
export const MAX_REPOS = 50;
|
|
135
|
+
/** How many folder entries one scan reads, at most. */
|
|
136
|
+
export const MAX_ENTRIES = 5000;
|
|
137
|
+
|
|
138
|
+
export const WORKSPACE_RULE =
|
|
139
|
+
"The repos in one parent folder go in one workspace named after that folder; " +
|
|
140
|
+
"a repo directly in the scanned folder gets the scanned folder's name.";
|
|
141
|
+
|
|
142
|
+
/** A repo already in a workspace (a dev-managed entry's path). */
|
|
143
|
+
export interface RegisteredRepo {
|
|
144
|
+
path: string;
|
|
145
|
+
workspace: string;
|
|
146
|
+
repo: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface DetectedRepo {
|
|
150
|
+
name: string;
|
|
151
|
+
path: string;
|
|
152
|
+
/** 0: the scanned folder itself; 1 or 2: how far down. */
|
|
153
|
+
depth: number;
|
|
154
|
+
/** Where its manifest comes from: its own, a side-car Den holds, or none yet. */
|
|
155
|
+
manifest: "repo" | "sidecar" | "none";
|
|
156
|
+
/** With no manifest: how it runs, when detection decided. */
|
|
157
|
+
runs?: Omit<DetectedCommands, "composeFile"> & { composeFile?: string };
|
|
158
|
+
/** With no manifest and no single answer: the question and the choices. */
|
|
159
|
+
ask?: string;
|
|
160
|
+
choices?: string[];
|
|
161
|
+
/** The workspace suggested for it. */
|
|
162
|
+
workspace: string;
|
|
163
|
+
/** The workspaces it is already in. */
|
|
164
|
+
registered?: { workspace: string; repo: string }[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type ReposDetectResult =
|
|
168
|
+
| {
|
|
169
|
+
ok: true;
|
|
170
|
+
path: string;
|
|
171
|
+
repos: DetectedRepo[];
|
|
172
|
+
workspaces: { name: string; repos: string[] }[];
|
|
173
|
+
truncated: boolean;
|
|
174
|
+
skipped: { symlinks: number; folders: number };
|
|
175
|
+
rule: string;
|
|
176
|
+
next: string[];
|
|
177
|
+
}
|
|
178
|
+
| { ok: false; error: string };
|
|
179
|
+
|
|
180
|
+
/** A folder name as a workspace name: other characters become "-". */
|
|
181
|
+
export function workspaceNameFrom(folder: string): string {
|
|
182
|
+
const name = folder
|
|
183
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
184
|
+
.replace(/^[^A-Za-z0-9]+/, "")
|
|
185
|
+
.slice(0, 64);
|
|
186
|
+
return WORKSPACE_NAME_RE.test(name) ? name : "default";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isRepo(dir: string): boolean {
|
|
190
|
+
try {
|
|
191
|
+
// A worktree's `.git` is a file; a symlinked `.git` is not trusted.
|
|
192
|
+
const st = fs.lstatSync(path.join(dir, ".git"));
|
|
193
|
+
return st.isDirectory() || st.isFile();
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function describeRepo(
|
|
200
|
+
dir: string,
|
|
201
|
+
depth: number,
|
|
202
|
+
workspace: string,
|
|
203
|
+
registered: RegisteredRepo[],
|
|
204
|
+
): DetectedRepo {
|
|
205
|
+
const out: DetectedRepo = {
|
|
206
|
+
name: path.basename(dir),
|
|
207
|
+
path: dir,
|
|
208
|
+
depth,
|
|
209
|
+
manifest: repoManifestPath(dir)
|
|
210
|
+
? "repo"
|
|
211
|
+
: heldManifestPath(dir)
|
|
212
|
+
? "sidecar"
|
|
213
|
+
: "none",
|
|
214
|
+
workspace,
|
|
215
|
+
};
|
|
216
|
+
if (out.manifest === "none") {
|
|
217
|
+
const d = detectRunCommands(dir);
|
|
218
|
+
if (d.decided) out.runs = d.commands;
|
|
219
|
+
else {
|
|
220
|
+
out.ask = d.ask;
|
|
221
|
+
out.choices = d.choices;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const mine = registered.filter((r) => path.resolve(r.path) === dir);
|
|
225
|
+
if (mine.length)
|
|
226
|
+
out.registered = mine.map((r) => ({
|
|
227
|
+
workspace: r.workspace,
|
|
228
|
+
repo: r.repo,
|
|
229
|
+
}));
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The git repos in `p` (see the module comment). Reads only. */
|
|
234
|
+
export function reposDetect(
|
|
235
|
+
p: string | undefined,
|
|
236
|
+
rules: PathRules,
|
|
237
|
+
opts: {
|
|
238
|
+
maxRepos?: number;
|
|
239
|
+
registered?: () => RegisteredRepo[];
|
|
240
|
+
bin?: string;
|
|
241
|
+
} = {},
|
|
242
|
+
): ReposDetectResult {
|
|
243
|
+
const check = checkOnboardPath(p, rules);
|
|
244
|
+
if (!check.ok) return check;
|
|
245
|
+
const root = check.dir;
|
|
246
|
+
const max = Math.max(1, Math.min(opts.maxRepos ?? MAX_REPOS, MAX_REPOS));
|
|
247
|
+
const registered = opts.registered?.() ?? [];
|
|
248
|
+
const stateDir = real(rules.stateDir);
|
|
249
|
+
const rootName = workspaceNameFrom(path.basename(root));
|
|
250
|
+
const repos: DetectedRepo[] = [];
|
|
251
|
+
const skipped = { symlinks: 0, folders: 0 };
|
|
252
|
+
let entries = 0;
|
|
253
|
+
let truncated = false;
|
|
254
|
+
|
|
255
|
+
/** The subfolders worth looking in, never through a symlink. */
|
|
256
|
+
const subdirs = (dir: string): string[] => {
|
|
257
|
+
let list: fs.Dirent[];
|
|
258
|
+
try {
|
|
259
|
+
list = fs.readdirSync(dir, { withFileTypes: true });
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
const out: string[] = [];
|
|
264
|
+
for (const e of list.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
265
|
+
if (++entries > MAX_ENTRIES) {
|
|
266
|
+
truncated = true;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (e.isSymbolicLink()) {
|
|
270
|
+
skipped.symlinks++;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!e.isDirectory()) continue;
|
|
274
|
+
const abs = path.join(dir, e.name);
|
|
275
|
+
if (
|
|
276
|
+
e.name.startsWith(".") ||
|
|
277
|
+
SKIP_DIRS.includes(e.name) ||
|
|
278
|
+
within(abs, stateDir)
|
|
279
|
+
) {
|
|
280
|
+
skipped.folders++;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
out.push(abs);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const add = (dir: string, depth: number, workspace: string): boolean => {
|
|
289
|
+
if (repos.length >= max) {
|
|
290
|
+
truncated = true;
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
repos.push(describeRepo(dir, depth, workspace, registered));
|
|
294
|
+
return true;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
if (isRepo(root)) add(root, 0, rootName);
|
|
298
|
+
else
|
|
299
|
+
outer: for (const one of subdirs(root)) {
|
|
300
|
+
if (isRepo(one)) {
|
|
301
|
+
if (!add(one, 1, rootName)) break;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const group = workspaceNameFrom(path.basename(one));
|
|
305
|
+
for (const two of subdirs(one))
|
|
306
|
+
if (isRepo(two) && !add(two, 2, group)) break outer;
|
|
307
|
+
if (truncated) break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const groups = new Map<string, string[]>();
|
|
311
|
+
for (const r of repos)
|
|
312
|
+
groups.set(r.workspace, [...(groups.get(r.workspace) ?? []), r.name]);
|
|
313
|
+
const bin = opts.bin ?? "den";
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
path: root,
|
|
317
|
+
repos,
|
|
318
|
+
workspaces: [...groups].map(([name, names]) => ({ name, repos: names })),
|
|
319
|
+
truncated,
|
|
320
|
+
skipped,
|
|
321
|
+
rule: WORKSPACE_RULE,
|
|
322
|
+
next: repos.length
|
|
323
|
+
? [`${bin} repo add ${repos[0].path} --dry-run --json`]
|
|
324
|
+
: [],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── manifest_draft ──────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The side-car manifest `repo add` would write for the repo at `p`, checked
|
|
332
|
+
* by the same validator, and nothing written (`repo add --dry-run`).
|
|
333
|
+
*/
|
|
334
|
+
export function manifestDraft(
|
|
335
|
+
p: string | undefined,
|
|
336
|
+
rules: PathRules,
|
|
337
|
+
validate: ManifestValidator,
|
|
338
|
+
opts: { name?: string; run?: string; down?: string; health?: string } = {},
|
|
339
|
+
): RepoAddResult {
|
|
340
|
+
const check = checkOnboardPath(p, rules);
|
|
341
|
+
if (!check.ok) return { ok: false, dryRun: true, error: check.error };
|
|
342
|
+
return repoAdd(
|
|
343
|
+
{
|
|
344
|
+
spec: check.dir,
|
|
345
|
+
sidecar: false,
|
|
346
|
+
json: true,
|
|
347
|
+
dryRun: true,
|
|
348
|
+
...opts,
|
|
349
|
+
},
|
|
350
|
+
validate,
|
|
351
|
+
check.dir,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── onboard_status ──────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
export type StepStatus = "ok" | "warn" | "todo";
|
|
358
|
+
|
|
359
|
+
export interface StatusCheck {
|
|
360
|
+
id: string;
|
|
361
|
+
status: StepStatus;
|
|
362
|
+
detail: string;
|
|
363
|
+
fix?: string;
|
|
364
|
+
ask?: string;
|
|
365
|
+
url?: string;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface McpRegistrationEnv {
|
|
369
|
+
cli: string;
|
|
370
|
+
home: string;
|
|
371
|
+
platform: NodeJS.Platform;
|
|
372
|
+
appData?: string;
|
|
373
|
+
/** The distribution's folder: a project-scope config there counts. */
|
|
374
|
+
projectDir: string;
|
|
375
|
+
which: (bin: string) => boolean;
|
|
376
|
+
read: (file: string) => string | null;
|
|
377
|
+
/** The request came through Den's own MCP server. */
|
|
378
|
+
viaMcp?: boolean;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Whether an agent has Den's MCP: asked through it, or a client's config
|
|
383
|
+
* file names this distribution's server. A client Den registers by running
|
|
384
|
+
* its own CLI (Claude Code, Codex) is never run to check: that is a warning
|
|
385
|
+
* pointing at `<cli> mcp clients`, not a guess.
|
|
386
|
+
*/
|
|
387
|
+
export function mcpRegistration(e: McpRegistrationEnv): StatusCheck {
|
|
388
|
+
const fixFor = (runtime: string) =>
|
|
389
|
+
`${e.cli} mcp connect --runtime ${runtime} --json`;
|
|
390
|
+
if (e.viaMcp)
|
|
391
|
+
return {
|
|
392
|
+
id: "mcp",
|
|
393
|
+
status: "ok",
|
|
394
|
+
detail: "connected: this question came through Den's MCP",
|
|
395
|
+
};
|
|
396
|
+
const spec: ServerSpec = { name: e.cli, command: "", args: [], env: {} };
|
|
397
|
+
const env: ConnectEnv = {
|
|
398
|
+
home: e.home,
|
|
399
|
+
platform: e.platform,
|
|
400
|
+
appData: e.appData,
|
|
401
|
+
projectDir: e.projectDir,
|
|
402
|
+
which: e.which,
|
|
403
|
+
exists: () => false,
|
|
404
|
+
};
|
|
405
|
+
const found: string[] = [];
|
|
406
|
+
const unchecked: string[] = [];
|
|
407
|
+
for (const c of MCP_CLIENTS)
|
|
408
|
+
for (const scope of c.scopes) {
|
|
409
|
+
const a = c.plan(spec, scope, env);
|
|
410
|
+
if (a.kind === "exec") {
|
|
411
|
+
if (scope === "user" && e.which(a.add[0])) unchecked.push(c.id);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const text = e.read(a.file);
|
|
415
|
+
if (!text) continue;
|
|
416
|
+
const has =
|
|
417
|
+
a.kind === "json"
|
|
418
|
+
? (() => {
|
|
419
|
+
try {
|
|
420
|
+
const doc = JSON.parse(text) as Record<
|
|
421
|
+
string,
|
|
422
|
+
Record<string, unknown>
|
|
423
|
+
>;
|
|
424
|
+
return doc?.[a.key]?.[a.name] !== undefined;
|
|
425
|
+
} catch {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
})()
|
|
429
|
+
: tomlHasEntry(text, a.name, a.entry) !== "absent";
|
|
430
|
+
if (has && !found.includes(c.title)) found.push(c.title);
|
|
431
|
+
}
|
|
432
|
+
if (found.length)
|
|
433
|
+
return {
|
|
434
|
+
id: "mcp",
|
|
435
|
+
status: "ok",
|
|
436
|
+
detail: `connected: ${found.join(", ")} ${found.length === 1 ? "has" : "have"} Den's MCP`,
|
|
437
|
+
};
|
|
438
|
+
const runtimeOf = (id: string) =>
|
|
439
|
+
Object.entries(RUNTIME_CLIENTS).find(([, c]) => c === id)?.[0];
|
|
440
|
+
if (unchecked.length) {
|
|
441
|
+
const titles = unchecked.map(
|
|
442
|
+
(id) => MCP_CLIENTS.find((c) => c.id === id)?.title ?? id,
|
|
443
|
+
);
|
|
444
|
+
return {
|
|
445
|
+
id: "mcp",
|
|
446
|
+
status: "warn",
|
|
447
|
+
detail: `${titles.join(" and ")} ${titles.length === 1 ? "is" : "are"} installed; Den can't tell whether ${titles.length === 1 ? "it has" : "they have"} Den's MCP without running ${titles.length === 1 ? "it" : "them"}. \`${e.cli} mcp clients\` shows it`,
|
|
448
|
+
fix: fixFor(runtimeOf(unchecked[0]) ?? "claude"),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
id: "mcp",
|
|
453
|
+
status: "todo",
|
|
454
|
+
detail: "no agent has Den's MCP yet",
|
|
455
|
+
fix: fixFor(`<${Object.keys(RUNTIME_CLIENTS).join("|")}>`),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export interface OnboardStatusDeps {
|
|
460
|
+
/** The command a person runs, e.g. `./bin/acme`. */
|
|
461
|
+
bin: string;
|
|
462
|
+
/** This is a distribution (it has its bin/<cli> shim), not Den's own folder. */
|
|
463
|
+
distribution: boolean;
|
|
464
|
+
/** doctor's ON-2 probes: node, git, docker and the web server. */
|
|
465
|
+
doctor: OnboardDeps;
|
|
466
|
+
/** Workspace names, the active ones, and every registered repo's name. */
|
|
467
|
+
workspaces: () => { names: string[]; active: string[]; repos: string[] };
|
|
468
|
+
/** The services Den started that are running (addresses). */
|
|
469
|
+
running: () => string[];
|
|
470
|
+
/** Whether an agent has Den's MCP (mcpRegistration). */
|
|
471
|
+
mcp: () => StatusCheck;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export interface OnboardStatus {
|
|
475
|
+
ok: boolean;
|
|
476
|
+
checks: StatusCheck[];
|
|
477
|
+
/** The one next step, in plain words, with its command. */
|
|
478
|
+
next: { id: string; step: string; command?: string };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const fromDoctor = (c: OnboardCheck): StatusCheck => ({
|
|
482
|
+
id: c.id,
|
|
483
|
+
status: c.status === "fail" ? "todo" : c.status,
|
|
484
|
+
detail: c.detail,
|
|
485
|
+
...(c.fix ? { fix: c.fix } : {}),
|
|
486
|
+
...(c.ask ? { ask: c.ask } : {}),
|
|
487
|
+
...(c.url ? { url: c.url } : {}),
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/** The next step for each check that isn't ok, in plain words. */
|
|
491
|
+
const STEPS: Record<string, string> = {
|
|
492
|
+
distribution:
|
|
493
|
+
"Create your own Den first: it gets its own folder, name and command.",
|
|
494
|
+
node: "Install a newer Node: Den needs 22.13 or later.",
|
|
495
|
+
git: "Install git.",
|
|
496
|
+
docker: "Start Docker: some of your repos run in containers.",
|
|
497
|
+
web: "Start the web server.",
|
|
498
|
+
workspaces: "Turn on a workspace, so Den runs its services.",
|
|
499
|
+
repos: "Add your first repo. Den can find the repos in a folder for you.",
|
|
500
|
+
service: "Start one service.",
|
|
501
|
+
mcp: "Connect your agent to Den's MCP, so it can see and run your services.",
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
/** What is set up, and the one next step (see the module comment). */
|
|
505
|
+
export async function onboardStatus(
|
|
506
|
+
d: OnboardStatusDeps,
|
|
507
|
+
): Promise<OnboardStatus> {
|
|
508
|
+
// doctor's own MCP check runs `<cli> mcp --call`, a child process that
|
|
509
|
+
// asks the web server back; the status reads config instead (d.mcp).
|
|
510
|
+
const doctor = (
|
|
511
|
+
await onboardingChecks({ ...d.doctor, mcpCall: () => ({ ok: true }) })
|
|
512
|
+
)
|
|
513
|
+
// mcp:approvals is doctor's; the status's own mcp check is d.mcp.
|
|
514
|
+
.filter((c) => c.id !== "mcp" && c.id !== "mcp:approvals")
|
|
515
|
+
.map(fromDoctor);
|
|
516
|
+
const ws = d.workspaces();
|
|
517
|
+
const running = d.running();
|
|
518
|
+
const webOk = doctor.find((c) => c.id === "web")?.status === "ok";
|
|
519
|
+
const checks: StatusCheck[] = [
|
|
520
|
+
d.distribution
|
|
521
|
+
? {
|
|
522
|
+
id: "distribution",
|
|
523
|
+
status: "ok",
|
|
524
|
+
detail: "this is a distribution of its own",
|
|
525
|
+
}
|
|
526
|
+
: {
|
|
527
|
+
id: "distribution",
|
|
528
|
+
status: "warn",
|
|
529
|
+
detail: "this is Den's own folder, not a distribution",
|
|
530
|
+
fix: "den create <name> --yes --json",
|
|
531
|
+
},
|
|
532
|
+
...doctor,
|
|
533
|
+
ws.names.length === 0
|
|
534
|
+
? {
|
|
535
|
+
id: "workspaces",
|
|
536
|
+
status: "todo",
|
|
537
|
+
detail:
|
|
538
|
+
"no workspaces yet (adding the first repo makes one named default)",
|
|
539
|
+
fix: `${d.bin} repo add <folder> --json`,
|
|
540
|
+
}
|
|
541
|
+
: ws.active.length === 0
|
|
542
|
+
? {
|
|
543
|
+
id: "workspaces",
|
|
544
|
+
status: "warn",
|
|
545
|
+
detail: `${ws.names.length} workspace${ws.names.length === 1 ? "" : "s"}, none active`,
|
|
546
|
+
fix: `${d.bin} workspace activate ${ws.names[0]}`,
|
|
547
|
+
}
|
|
548
|
+
: {
|
|
549
|
+
id: "workspaces",
|
|
550
|
+
status: "ok",
|
|
551
|
+
detail: `${ws.names.length} workspace${ws.names.length === 1 ? "" : "s"}, ${ws.active.length} active (${ws.active.join(", ")})`,
|
|
552
|
+
},
|
|
553
|
+
ws.repos.length === 0
|
|
554
|
+
? {
|
|
555
|
+
id: "repos",
|
|
556
|
+
status: "todo",
|
|
557
|
+
detail: "no repos added yet",
|
|
558
|
+
fix: `${d.bin} repo detect <folder> --json`,
|
|
559
|
+
}
|
|
560
|
+
: {
|
|
561
|
+
id: "repos",
|
|
562
|
+
status: "ok",
|
|
563
|
+
detail: `${ws.repos.length} repo${ws.repos.length === 1 ? "" : "s"} added`,
|
|
564
|
+
},
|
|
565
|
+
running.length
|
|
566
|
+
? {
|
|
567
|
+
id: "service",
|
|
568
|
+
status: "ok",
|
|
569
|
+
detail: `${running.length} service${running.length === 1 ? "" : "s"} running`,
|
|
570
|
+
}
|
|
571
|
+
: {
|
|
572
|
+
id: "service",
|
|
573
|
+
status: "todo",
|
|
574
|
+
detail: "no service Den started is running",
|
|
575
|
+
fix: ws.repos.length
|
|
576
|
+
? `${d.bin} up ${ws.repos[0]} --json`
|
|
577
|
+
: `${d.bin} up <repo> --json`,
|
|
578
|
+
},
|
|
579
|
+
d.mcp(),
|
|
580
|
+
];
|
|
581
|
+
// The order a person does them in: a repo needs the web server up first.
|
|
582
|
+
const order = [
|
|
583
|
+
"node",
|
|
584
|
+
"git",
|
|
585
|
+
"distribution",
|
|
586
|
+
"web",
|
|
587
|
+
"repos",
|
|
588
|
+
"workspaces",
|
|
589
|
+
"docker",
|
|
590
|
+
"service",
|
|
591
|
+
"mcp",
|
|
592
|
+
];
|
|
593
|
+
const pending = order
|
|
594
|
+
.map((id) => checks.find((c) => c.id === id))
|
|
595
|
+
.filter((c): c is StatusCheck => !!c && c.status !== "ok");
|
|
596
|
+
const first = pending.find((c) => c.status === "todo") ?? pending[0];
|
|
597
|
+
return {
|
|
598
|
+
ok: pending.length === 0,
|
|
599
|
+
checks,
|
|
600
|
+
next: first
|
|
601
|
+
? {
|
|
602
|
+
id: first.id,
|
|
603
|
+
step: STEPS[first.id] ?? first.detail,
|
|
604
|
+
...(first.fix ? { command: first.fix } : {}),
|
|
605
|
+
}
|
|
606
|
+
: {
|
|
607
|
+
id: "done",
|
|
608
|
+
step: webOk
|
|
609
|
+
? "Everything is set up. Open the web page to see your services."
|
|
610
|
+
: "Everything is set up.",
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
}
|