@monster-orange/den 0.2.8 → 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.
Files changed (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  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 +9 -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/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Where a distribution's plugins live and how added ones are pinned (PL-1a,
3
+ * PL-2a).
4
+ *
5
+ * plugins/<id>/ every plugin, local or added, committed
6
+ * plugins.lock.json one entry per added plugin: source, ref, commit,
7
+ * content hash, kinds, approved capabilities
8
+ *
9
+ * A plugins/<id>/ with no lock entry is local: the distribution's own,
10
+ * edited in place, of any kind. Being present is its approval. An added
11
+ * plugin was vendored by `plugin add` (or `theme add`), and `list` fails when
12
+ * its bytes differ from the lock.
13
+ */
14
+ import { spawnSync } from "child_process";
15
+ import { createHash } from "crypto";
16
+ import * as fs from "fs";
17
+ import * as path from "path";
18
+ import { parse as parseYaml } from "yaml";
19
+ import { PROFILE_FILE } from "../profile.js";
20
+ import { THEME_FILE, themeFiles } from "../theme/contract.js";
21
+ import {
22
+ PLUGIN_FILE,
23
+ PLUGIN_ID,
24
+ themeDirIn,
25
+ type PluginValidation,
26
+ } from "./manifest.js";
27
+
28
+ export const LOCK_FILE = "plugins.lock.json";
29
+ /** Where plugins live in the distribution. */
30
+ export const PLUGINS_DIR = "plugins";
31
+
32
+ export interface PluginSource {
33
+ /** What `git clone` takes. */
34
+ repo: string;
35
+ /** Directory of the plugin inside the repo ("" for the root). */
36
+ subpath: string;
37
+ ref: string;
38
+ }
39
+
40
+ /**
41
+ * `github.com/<owner>/<repo>[/<subpath>]@<ref>` (any host of that shape over
42
+ * https), or a full git URL with an optional `//<subpath>`:
43
+ * `https://git.example/x/themes.git//nightly@v1.0.0`,
44
+ * `git@github.com:o/r.git//nightly@v1.0.0`. The ref is required: a tag or a
45
+ * commit, never "latest".
46
+ */
47
+ export function parsePluginSource(spec: string): PluginSource {
48
+ const at = spec.lastIndexOf("@");
49
+ // `git@host:` has its @ before the first colon; the ref's @ comes after.
50
+ if (at <= 0 || (spec.startsWith("git@") && at < spec.indexOf(":")))
51
+ throw new Error(
52
+ `${spec}: name an exact ref (a tag or a commit): <repo>[/<subpath>]@<ref>`,
53
+ );
54
+ const where = spec.slice(0, at);
55
+ const ref = spec.slice(at + 1);
56
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/.test(ref) || ref.includes(".."))
57
+ throw new Error(`${spec}: "${ref}" is not a usable git ref`);
58
+ const sub = where.indexOf("//", where.indexOf("://") + 3);
59
+ if (/^(https?:\/\/|ssh:\/\/|file:\/\/|git@)/.test(where)) {
60
+ const repo = sub === -1 ? where : where.slice(0, sub);
61
+ return {
62
+ repo,
63
+ subpath: sub === -1 ? "" : cleanSubpath(where.slice(sub + 2)),
64
+ ref,
65
+ };
66
+ }
67
+ const parts = where.split("/").filter(Boolean);
68
+ if (parts.length < 3 || !parts[0].includes("."))
69
+ throw new Error(
70
+ `${spec}: expected <host>/<owner>/<repo>[/<subpath>]@<ref>, or a git URL`,
71
+ );
72
+ const [host, owner, name, ...rest] = parts;
73
+ return {
74
+ repo: `https://${host}/${owner}/${name.replace(/\.git$/, "")}.git`,
75
+ subpath: cleanSubpath(rest.join("/")),
76
+ ref,
77
+ };
78
+ }
79
+
80
+ function cleanSubpath(p: string): string {
81
+ const s = p.replace(/^\/+|\/+$/g, "");
82
+ if (s.split("/").some((x) => x === ".." || x === "."))
83
+ throw new Error(`plugin subpath "${p}" may not contain . or ..`);
84
+ return s;
85
+ }
86
+
87
+ /** The lock's `source`: the repo, plus `//<subpath>` when there is one. */
88
+ export function sourceString(src: PluginSource): string {
89
+ return src.subpath ? `${src.repo}//${src.subpath}` : src.repo;
90
+ }
91
+
92
+ /**
93
+ * Why `add` refuses a file:// source (PL-2a). A path on one machine must
94
+ * never reach a committed lock.
95
+ */
96
+ export const FILE_SOURCE_HINT =
97
+ "for your own plugin, put it in plugins/<id>/ as a local plugin; to try a published one before tagging, push a branch and use --track";
98
+
99
+ /**
100
+ * `<host>/<owner>` of a git URL, lowercased host, or null when it has no
101
+ * owner (a file:// path, a bare host).
102
+ */
103
+ export function hostOwner(
104
+ repo: string,
105
+ ): { host: string; owner: string } | null {
106
+ const m =
107
+ /^(?:https?|ssh):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/([^/]+)\//.exec(repo) ??
108
+ /^[^@\s]+@([^:]+):\/?([^/]+)\//.exec(repo);
109
+ return m ? { host: m[1].toLowerCase(), owner: m[2] } : null;
110
+ }
111
+
112
+ /**
113
+ * `plugins.allow` in the distribution's den.yml: the `<host>/<owner>` pairs
114
+ * `add` accepts (a bare `<host>` accepts every owner there). Null when it is
115
+ * absent: any host. Throws on a malformed value, so a typo can't open it up.
116
+ */
117
+ export function readPluginsAllow(distRoot: string): string[] | null {
118
+ const file = path.join(distRoot, PROFILE_FILE);
119
+ if (!fs.existsSync(file)) return null;
120
+ const doc = parseYaml(fs.readFileSync(file, "utf-8")) as {
121
+ plugins?: { allow?: unknown };
122
+ } | null;
123
+ const allow = doc?.plugins?.allow;
124
+ if (allow === undefined || allow === null) return null;
125
+ if (
126
+ !Array.isArray(allow) ||
127
+ allow.some((a) => typeof a !== "string" || !/^[^/\s]+(\/[^/\s]+)?$/.test(a))
128
+ )
129
+ throw new Error(
130
+ `${PROFILE_FILE}: plugins.allow must be a list of <host>/<owner> (or <host>) strings`,
131
+ );
132
+ return (allow as string[]).map((a) => a.trim());
133
+ }
134
+
135
+ /** Whether `repo` is allowed by `allow` (null: everything is). */
136
+ export function sourceAllowed(repo: string, allow: string[] | null): boolean {
137
+ if (!allow) return true;
138
+ const ho = hostOwner(repo);
139
+ if (!ho) return false;
140
+ return allow.some((a) => {
141
+ const [host, owner] = a.split("/");
142
+ return (
143
+ host.toLowerCase() === ho.host &&
144
+ (owner === undefined || owner.toLowerCase() === ho.owner.toLowerCase())
145
+ );
146
+ });
147
+ }
148
+
149
+ export interface LockEntry {
150
+ id: string;
151
+ name: string;
152
+ version: string;
153
+ source: string;
154
+ ref: string;
155
+ commit: string;
156
+ /** sha256 over the plugin's files, in path order (see pluginContent). */
157
+ sha256: string;
158
+ /** What the plugin provides, from its manifest; a theme is ["theme"]. */
159
+ kinds: string[];
160
+ /** Capabilities approved at add (PL-4). A theme requests none. */
161
+ approved_capabilities: string[];
162
+ /** Added with --track: `ref` is a branch, so a re-add may differ. */
163
+ tracking?: boolean;
164
+ }
165
+
166
+ /** Runs git; returns stdout, throws with stderr on failure. */
167
+ export type GitRunner = (args: string[], cwd?: string) => string;
168
+
169
+ export const defaultGit: GitRunner = (args, cwd) => {
170
+ const r = spawnSync("git", args, {
171
+ cwd,
172
+ encoding: "utf-8",
173
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
174
+ });
175
+ if (r.status !== 0)
176
+ throw new Error(`git ${args[0]} failed: ${(r.stderr || r.stdout).trim()}`);
177
+ return r.stdout.trim();
178
+ };
179
+
180
+ /**
181
+ * What `ref` names in the repo: a full commit SHA, a tag, a branch, or
182
+ * nothing. A tag wins over a branch of the same name.
183
+ */
184
+ export function refKind(
185
+ git: GitRunner,
186
+ src: PluginSource,
187
+ ): "commit" | "tag" | "branch" | "none" {
188
+ if (/^[0-9a-f]{40}$/.test(src.ref)) return "commit";
189
+ const out = git([
190
+ "ls-remote",
191
+ "--tags",
192
+ "--heads",
193
+ src.repo,
194
+ `refs/tags/${src.ref}`,
195
+ `refs/heads/${src.ref}`,
196
+ ]);
197
+ if (out.includes(`refs/tags/${src.ref}`)) return "tag";
198
+ if (out.includes(`refs/heads/${src.ref}`)) return "branch";
199
+ return "none";
200
+ }
201
+
202
+ export function readLock(distRoot: string): Record<string, LockEntry> {
203
+ const file = path.join(distRoot, LOCK_FILE);
204
+ if (!fs.existsSync(file)) return {};
205
+ const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as {
206
+ plugins?: Record<string, LockEntry>;
207
+ };
208
+ return raw.plugins ?? {};
209
+ }
210
+
211
+ export function writeLock(
212
+ distRoot: string,
213
+ plugins: Record<string, LockEntry>,
214
+ ): void {
215
+ const sorted = Object.fromEntries(
216
+ Object.entries(plugins).sort(([a], [b]) => a.localeCompare(b)),
217
+ );
218
+ const file = path.join(distRoot, LOCK_FILE);
219
+ const tmp = `${file}.tmp`;
220
+ fs.writeFileSync(tmp, `${JSON.stringify({ plugins: sorted }, null, 2)}\n`);
221
+ fs.renameSync(tmp, file);
222
+ }
223
+
224
+ /**
225
+ * plugins/<id>/ in the distribution. The id is checked again here (the lock
226
+ * file is editable), so a delete can only ever reach that directory.
227
+ */
228
+ export function pluginDirFor(distRoot: string, id: string): string {
229
+ if (!PLUGIN_ID.test(id)) throw new Error(`"${id}" is not a plugin id`);
230
+ return path.join(distRoot, PLUGINS_DIR, id);
231
+ }
232
+
233
+ /**
234
+ * Every directory in plugins/, sorted, whatever it holds (dot-dirs aside):
235
+ * each one is a plugin, so one without a manifest, or with a name that is
236
+ * not an id, is reported rather than skipped.
237
+ */
238
+ export function pluginDirNames(distRoot: string): string[] {
239
+ const root = path.join(distRoot, PLUGINS_DIR);
240
+ if (!fs.existsSync(root)) return [];
241
+ return fs
242
+ .readdirSync(root, { withFileTypes: true })
243
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
244
+ .map((e) => e.name)
245
+ .sort();
246
+ }
247
+
248
+ /**
249
+ * Symlinks directly in plugins/ (not dot-names). Never a plugin: list and
250
+ * validate report each one, so no entry under plugins/ goes unmentioned.
251
+ */
252
+ export function pluginSymlinkNames(distRoot: string): string[] {
253
+ const root = path.join(distRoot, PLUGINS_DIR);
254
+ if (!fs.existsSync(root)) return [];
255
+ return fs
256
+ .readdirSync(root, { withFileTypes: true })
257
+ .filter((e) => e.isSymbolicLink() && !e.name.startsWith("."))
258
+ .map((e) => e.name)
259
+ .sort();
260
+ }
261
+
262
+ /**
263
+ * Every plugins/<id>/ that provides a theme (a den-theme.json, or a
264
+ * den-plugin.json with a theme entry), locked or local, sorted.
265
+ */
266
+ export function installedThemeIds(distRoot: string): string[] {
267
+ return pluginDirNames(distRoot).filter(
268
+ (id) =>
269
+ PLUGIN_ID.test(id) &&
270
+ themeDirIn(path.join(distRoot, PLUGINS_DIR, id)) !== null,
271
+ );
272
+ }
273
+
274
+ /** LICENSE / OFL.txt files anywhere in the dir travel with it. */
275
+ function licenseFiles(dir: string): string[] {
276
+ const out: string[] = [];
277
+ const walk = (rel: string): void => {
278
+ for (const e of fs.readdirSync(path.join(dir, rel), {
279
+ withFileTypes: true,
280
+ })) {
281
+ const r = rel ? `${rel}/${e.name}` : e.name;
282
+ if (e.isDirectory() && !e.name.startsWith(".")) walk(r);
283
+ else if (/^(LICEN[CS]E|OFL|COPYING)(\.(txt|md))?$/i.test(e.name))
284
+ out.push(r);
285
+ }
286
+ };
287
+ walk("");
288
+ return out;
289
+ }
290
+
291
+ /** Every regular file under `dir`, relative, skipping dot-entries. */
292
+ function allFiles(dir: string): string[] {
293
+ const out: string[] = [];
294
+ const walk = (rel: string): void => {
295
+ for (const e of fs.readdirSync(path.join(dir, rel), {
296
+ withFileTypes: true,
297
+ })) {
298
+ if (e.name.startsWith(".")) continue;
299
+ const r = rel ? `${rel}/${e.name}` : e.name;
300
+ if (e.isDirectory()) walk(r);
301
+ else if (e.isFile()) out.push(r);
302
+ }
303
+ };
304
+ walk("");
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * The files a validated plugin consists of, sorted: what `add` vendors and
310
+ * the lock hashes. An implicit theme plugin is its manifest, the files it
311
+ * names and its license files (as theme add has always vendored, so existing
312
+ * lock hashes still match); a den-plugin.json plugin is every file in its
313
+ * directory but dot-files.
314
+ */
315
+ export function pluginContent(dir: string, v: PluginValidation): string[] {
316
+ const p = v.plugin!;
317
+ const files =
318
+ p.implicit && p.theme
319
+ ? [THEME_FILE, ...themeFiles(p.theme), ...licenseFiles(dir)]
320
+ : [PLUGIN_FILE, ...allFiles(dir)];
321
+ return files.filter((f, i, a) => a.indexOf(f) === i).sort();
322
+ }
323
+
324
+ export function hashFiles(dir: string, files: string[]): string {
325
+ const h = createHash("sha256");
326
+ for (const f of files) {
327
+ h.update(`${f}\0`);
328
+ h.update(fs.readFileSync(path.join(dir, f)));
329
+ h.update("\0");
330
+ }
331
+ return h.digest("hex");
332
+ }
333
+
334
+ /**
335
+ * Files under `dir` that git has not committed as they are: untracked
336
+ * (ignored ones too, since Den loads them all the same), modified or staged.
337
+ * Null when `dir` is not in a git work tree: Den can't say, so it says
338
+ * nothing.
339
+ */
340
+ export function uncommittedFiles(dir: string): string[] | null {
341
+ const r = spawnSync(
342
+ "git",
343
+ [
344
+ "status",
345
+ "--porcelain=v1",
346
+ "--untracked-files=all",
347
+ "--ignored=matching",
348
+ "--",
349
+ ".",
350
+ ],
351
+ { cwd: dir, encoding: "utf-8" },
352
+ );
353
+ if (r.status !== 0) return null;
354
+ return r.stdout
355
+ .split("\n")
356
+ .filter((l) => l.length > 3)
357
+ .map((l) => l.slice(3).replace(/^"|"$/g, ""));
358
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Records from before 0.3.0-dev.37 (0.3.0-dev.38, a consumer's dev.37
3
+ * finding). A daemon record written before `up` recorded process groups has
4
+ * no pgid, and often no pid either, while the service it describes may still
5
+ * be running (a vite on its strict port). `up` and `down` decide what such a
6
+ * record means through adoptLegacyProcess, once:
7
+ *
8
+ * 1. port first: when the service has a known port (servicePort), the
9
+ * process listening on it, if its cwd is inside the record's repoPath,
10
+ * is the service. Den adopts its process group: the listener's pgid
11
+ * and the group leader's start time (the listener's own when the
12
+ * leader has gone).
13
+ * 2. else a recorded pid that is alive, when every token of the
14
+ * configured up command (leading VAR=value assignments and an `npx` /
15
+ * `npm exec` prefix stripped) appears in the argv of some process in
16
+ * that pid's group: adopted the same way.
17
+ * 3. else the record is stale: nothing is signalled. A listener on the
18
+ * port from outside repoPath is reported (`busy`), never touched.
19
+ *
20
+ * Nothing here signals a process. `ps` and `lsof` run under LC_ALL=C.
21
+ */
22
+ import { execFileSync } from "child_process";
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import type { RecordedProcessGroup } from "./process-group.js";
26
+
27
+ const C_ENV = { ...process.env, LC_ALL: "C", LANG: "C" };
28
+
29
+ function run(cmd: string, args: string[]): string | null {
30
+ try {
31
+ return execFileSync(cmd, args, {
32
+ env: C_ENV,
33
+ encoding: "utf-8",
34
+ stdio: ["ignore", "pipe", "ignore"],
35
+ timeout: 5000,
36
+ });
37
+ } catch (e) {
38
+ // lsof and ps exit 1 when nothing matches; what they printed still counts.
39
+ const out = (e as { stdout?: unknown }).stdout;
40
+ return typeof out === "string" ? out : null;
41
+ }
42
+ }
43
+
44
+ /** The host tools, injectable for tests of the decision alone. */
45
+ export interface LegacyProbe {
46
+ /** Pids listening on TCP `port`. */
47
+ listeners(port: number): number[];
48
+ /** A process's cwd, or null. */
49
+ cwd(pid: number): string | null;
50
+ /** Every live process: pid, pgid, start time, argv (one line). */
51
+ processes(): ProcRow[];
52
+ }
53
+
54
+ export interface ProcRow {
55
+ pid: number;
56
+ pgid: number;
57
+ lstart: string;
58
+ command: string;
59
+ }
60
+
61
+ export const hostProbe: LegacyProbe = {
62
+ listeners(port) {
63
+ const out = run("lsof", ["-nP", `-tiTCP:${port}`, "-sTCP:LISTEN"]) ?? "";
64
+ return out
65
+ .split("\n")
66
+ .map((l) => Number(l.trim()))
67
+ .filter((n) => Number.isInteger(n) && n > 0);
68
+ },
69
+ cwd(pid) {
70
+ const out = run("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
71
+ const line = out?.split("\n").find((l) => l.startsWith("n"));
72
+ return line ? line.slice(1) : null;
73
+ },
74
+ processes() {
75
+ // lstart is five words in the C locale: "Thu Nov 5 10:00:00 2026".
76
+ const out = run("ps", ["-A", "-o", "pid=,pgid=,stat=,lstart=,command="]);
77
+ const rows: ProcRow[] = [];
78
+ for (const line of (out ?? "").split("\n")) {
79
+ const m = line
80
+ .trim()
81
+ .match(
82
+ /^(\d+)\s+(\d+)\s+(\S+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.*)$/,
83
+ );
84
+ if (!m || m[3].startsWith("Z")) continue;
85
+ rows.push({
86
+ pid: Number(m[1]),
87
+ pgid: Number(m[2]),
88
+ lstart: m[4].replace(/\s+/g, " "),
89
+ command: m[5],
90
+ });
91
+ }
92
+ return rows;
93
+ },
94
+ };
95
+
96
+ /** A shell command's words, quotes removed (enough to compare, not to run). */
97
+ export function shellWords(cmd: string): string[] {
98
+ const words: string[] = [];
99
+ const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
100
+ let m: RegExpExecArray | null;
101
+ while ((m = re.exec(cmd))) words.push(m[1] ?? m[2] ?? m[3]);
102
+ return words;
103
+ }
104
+
105
+ const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
106
+
107
+ /**
108
+ * The tokens a running up must show: the up command's words without the
109
+ * leading VAR=value assignments and an `npx` / `npm exec` prefix (npm runs
110
+ * the binary under its own name, `node …/.bin/vite`).
111
+ */
112
+ export function upTokens(upCommand: string): string[] {
113
+ const words = shellWords(upCommand);
114
+ let i = 0;
115
+ while (i < words.length && ASSIGN.test(words[i])) i++;
116
+ if (words[i] === "npx") {
117
+ i++;
118
+ // npx's own flags (`npx --yes vite`) are npx's, not the service's.
119
+ while (i < words.length && words[i].startsWith("-")) i++;
120
+ } else if (words[i] === "npm" && words[i + 1] === "exec") i += 2;
121
+ return words.slice(i);
122
+ }
123
+
124
+ /** Whether `token` is one of the argv words of `command` (or its basename). */
125
+ function inArgv(token: string, command: string): boolean {
126
+ if (/\s/.test(token)) return command.includes(token);
127
+ return shellWords(command).some(
128
+ (w) => w === token || w.endsWith(`/${token}`) || w === `--${token}`,
129
+ );
130
+ }
131
+
132
+ /**
133
+ * A service's known port, the first found of: an explicit `port`, the port
134
+ * in its health URL (localhost or 127.0.0.1 forms), a `--port N`,
135
+ * `--port=N` or `-p N` in its up command.
136
+ */
137
+ export function servicePort(svc: {
138
+ port?: unknown;
139
+ healthUrl?: string | null;
140
+ upCommand?: string | null;
141
+ }): number | null {
142
+ const ok = (n: number): number | null =>
143
+ Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
144
+ if (svc.port !== undefined && svc.port !== null) {
145
+ const p = ok(Number(svc.port));
146
+ if (p) return p;
147
+ }
148
+ if (svc.healthUrl) {
149
+ try {
150
+ const u = new URL(svc.healthUrl);
151
+ if (u.port) {
152
+ const p = ok(Number(u.port));
153
+ if (p) return p;
154
+ }
155
+ } catch {
156
+ /* not a URL */
157
+ }
158
+ }
159
+ const words = shellWords(svc.upCommand ?? "");
160
+ for (let i = 0; i < words.length; i++) {
161
+ const eq = words[i].match(/^--port=(\d+)$/);
162
+ if (eq) return ok(Number(eq[1]));
163
+ if ((words[i] === "--port" || words[i] === "-p") && words[i + 1]) {
164
+ const p = ok(Number(words[i + 1]));
165
+ if (p) return p;
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function realOrSelf(p: string): string {
172
+ try {
173
+ return fs.realpathSync(p);
174
+ } catch {
175
+ return path.resolve(p);
176
+ }
177
+ }
178
+
179
+ function inside(dir: string, root: string): boolean {
180
+ const d = realOrSelf(dir);
181
+ const r = realOrSelf(root);
182
+ return d === r || d.startsWith(r.endsWith(path.sep) ? r : r + path.sep);
183
+ }
184
+
185
+ export type LegacyVerdict =
186
+ | {
187
+ kind: "adopted";
188
+ group: RecordedProcessGroup;
189
+ /** The listener (port) or the recorded pid. */
190
+ pid: number;
191
+ port: number | null;
192
+ }
193
+ | {
194
+ kind: "stale";
195
+ /** A listener on the port Den won't adopt: left alone. */
196
+ busy?: LegacyBusy;
197
+ };
198
+
199
+ /**
200
+ * A listener on the service's port that is not adopted: its cwd is outside
201
+ * repoPath ("outside"), or it is in the repo but in Den's own process group
202
+ * ("own-group": stopping that group would stop Den) or in init's ("init").
203
+ */
204
+ export interface LegacyBusy {
205
+ port: number;
206
+ pid: number;
207
+ cwd: string | null;
208
+ reason: "outside" | "own-group" | "init";
209
+ }
210
+
211
+ /** Why a busy port's listener is not `address`, for a warning line. */
212
+ export function busyListenerText(
213
+ busy: LegacyBusy,
214
+ repoPath: string,
215
+ address: string,
216
+ ): string {
217
+ const head =
218
+ `port ${busy.port} is in use by pid ${busy.pid}` +
219
+ (busy.cwd ? ` (cwd ${busy.cwd})` : "");
220
+ if (busy.reason === "own-group")
221
+ return `${head}, in Den's own process group; not adopted`;
222
+ if (busy.reason === "init")
223
+ return `${head}, in process group 1 (init); not adopted`;
224
+ return `${head}, outside ${repoPath}: not ${address}, left alone`;
225
+ }
226
+
227
+ /** The group of `member`, as `down` records one: leader's start, else its own. */
228
+ function groupOf(
229
+ member: ProcRow,
230
+ rows: ProcRow[],
231
+ ): RecordedProcessGroup | null {
232
+ // Never adopt Den's own group or init's.
233
+ if (member.pgid <= 1) return null;
234
+ const own = rows.find((r) => r.pid === process.pid);
235
+ if (own && own.pgid === member.pgid) return null;
236
+ const leader = rows.find((r) => r.pid === member.pgid);
237
+ if (!leader) {
238
+ // The leader is gone: the group is recorded by the member that leads it
239
+ // now in the record's sense (classifyGroup accepts any live member).
240
+ return { pgid: member.pgid, startedAt: member.lstart };
241
+ }
242
+ return { pgid: member.pgid, startedAt: leader.lstart };
243
+ }
244
+
245
+ export function adoptLegacyProcess(
246
+ rec: {
247
+ repoPath: string;
248
+ pid?: number;
249
+ port: number | null;
250
+ upCommand: string | null;
251
+ },
252
+ probe: LegacyProbe = hostProbe,
253
+ ): LegacyVerdict {
254
+ let rows: ProcRow[] | null = null;
255
+ const procs = (): ProcRow[] => (rows ??= probe.processes());
256
+ let busy: LegacyBusy | undefined;
257
+
258
+ if (rec.port) {
259
+ for (const pid of probe.listeners(rec.port)) {
260
+ const cwd = probe.cwd(pid);
261
+ const row = procs().find((r) => r.pid === pid);
262
+ let reason: LegacyBusy["reason"] = "outside";
263
+ if (cwd && row && inside(cwd, rec.repoPath)) {
264
+ const group = groupOf(row, procs());
265
+ if (group) return { kind: "adopted", group, pid, port: rec.port };
266
+ reason = row.pgid <= 1 ? "init" : "own-group";
267
+ }
268
+ busy ??= { port: rec.port, pid, cwd, reason };
269
+ }
270
+ }
271
+
272
+ if (typeof rec.pid === "number" && rec.pid > 0 && rec.upCommand) {
273
+ const row = procs().find((r) => r.pid === rec.pid);
274
+ const tokens = upTokens(rec.upCommand);
275
+ if (row && tokens.length) {
276
+ const members = procs().filter((r) => r.pgid === row.pgid);
277
+ if (tokens.every((t) => members.some((m) => inArgv(t, m.command)))) {
278
+ const group = groupOf(row, procs());
279
+ if (group) return { kind: "adopted", group, pid: row.pid, port: null };
280
+ }
281
+ }
282
+ }
283
+
284
+ return busy ? { kind: "stale", busy } : { kind: "stale" };
285
+ }