@monster-orange/den 0.0.0 → 0.2.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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +101 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +403 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +124 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +757 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,1073 @@
1
+ import chalk from "chalk";
2
+ import { execFileSync, spawn, spawnSync } from "child_process";
3
+ import * as fs from "fs";
4
+ import * as http from "http";
5
+ import * as path from "path";
6
+ import {
7
+ CLI_SPAWN_TIMEOUT_MS,
8
+ DEFAULT_PORT,
9
+ OP_ACCOUNT_DEFAULT,
10
+ } from "../../shared/constants.js";
11
+ import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
12
+ import { hasDenUiMarker } from "../../shared/ui-marker.js";
13
+ import { engineBin } from "../../shared/engine.js";
14
+ import { readWebPort } from "../../shared/edge/config.js";
15
+
16
+ import { product } from "../../shared/product.js";
17
+ interface WebCommandOptions {
18
+ action: "up" | "down" | "restart";
19
+ port: number;
20
+ openBrowser: boolean;
21
+ silent: boolean;
22
+ setupBrowserAccess: boolean;
23
+ }
24
+
25
+ interface StartedServer {
26
+ pid: number | undefined;
27
+ logPath: string;
28
+ }
29
+
30
+ interface WebServerState {
31
+ pid: number | undefined;
32
+ port: number;
33
+ root: string;
34
+ logPath: string;
35
+ startedAt: string;
36
+ command: string;
37
+ }
38
+
39
+ interface StopServerResult {
40
+ stopped: boolean;
41
+ forced: boolean;
42
+ pids: number[];
43
+ }
44
+
45
+ type BrowserFocusResult =
46
+ | { status: "focused"; app: string }
47
+ | { status: "not-found" }
48
+ | { status: "unsupported" }
49
+ | { status: "error"; apps: string[]; error: string };
50
+
51
+ type BrowserOpenResult =
52
+ | BrowserFocusResult
53
+ | { status: "opened" }
54
+ | { status: "open-failed"; error: string };
55
+
56
+ // Generous enough for a cold tsx start (the server now runs from live TS source
57
+ // by default), so `den web` doesn't print a false "did not become ready" while
58
+ // the server is in fact coming up.
59
+ const WEB_START_TIMEOUT_MS = 45_000;
60
+ const WEB_START_POLL_MS = 250;
61
+
62
+ const BROWSER_APPS: Array<{
63
+ name: string;
64
+ kind: "chromium" | "safari";
65
+ }> = [
66
+ { name: "Google Chrome", kind: "chromium" },
67
+ { name: "Google Chrome Canary", kind: "chromium" },
68
+ { name: "Microsoft Edge", kind: "chromium" },
69
+ { name: "Brave Browser", kind: "chromium" },
70
+ { name: "Arc", kind: "chromium" },
71
+ { name: "Safari", kind: "safari" },
72
+ ];
73
+
74
+ function configuredPort(): number {
75
+ try {
76
+ return readWebPort();
77
+ } catch {
78
+ return DEFAULT_PORT;
79
+ }
80
+ }
81
+
82
+ function parsePort(value: string | undefined): number {
83
+ const raw = value?.trim() || String(configuredPort());
84
+ const port = Number(raw);
85
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
86
+ throw new Error(`Invalid web port: ${raw}`);
87
+ }
88
+ return port;
89
+ }
90
+
91
+ export function parseWebCommandArgs(
92
+ args: string[],
93
+ env: NodeJS.ProcessEnv = process.env,
94
+ ): WebCommandOptions {
95
+ const options: WebCommandOptions = {
96
+ action: "up",
97
+ port: parsePort(env.PORT),
98
+ openBrowser: true,
99
+ silent: true,
100
+ setupBrowserAccess: false,
101
+ };
102
+ let actionSeen = false;
103
+
104
+ for (let i = 0; i < args.length; i += 1) {
105
+ const arg = args[i];
106
+ if (
107
+ arg === "up" ||
108
+ arg === "down" ||
109
+ arg === "restart" ||
110
+ arg === "start" || // legacy alias for up
111
+ arg === "stop" // legacy alias for down
112
+ ) {
113
+ if (actionSeen) throw new Error("Only one web action may be specified");
114
+ options.action = arg === "start" ? "up" : arg === "stop" ? "down" : arg;
115
+ actionSeen = true;
116
+ continue;
117
+ }
118
+ if (arg === "--no-open") {
119
+ options.openBrowser = false;
120
+ continue;
121
+ }
122
+ if (arg === "--setup-browser-access") {
123
+ options.setupBrowserAccess = true;
124
+ options.openBrowser = false;
125
+ continue;
126
+ }
127
+ if (arg === "--verbose") {
128
+ options.silent = false;
129
+ continue;
130
+ }
131
+ if (arg === "--silent") {
132
+ options.silent = true;
133
+ continue;
134
+ }
135
+ if (arg === "--port" || arg === "-p") {
136
+ const value = args[i + 1];
137
+ if (!value || value.startsWith("-")) {
138
+ throw new Error(`${arg} requires a value`);
139
+ }
140
+ options.port = parsePort(value);
141
+ i += 1;
142
+ continue;
143
+ }
144
+ if (arg.startsWith("--port=")) {
145
+ options.port = parsePort(arg.slice("--port=".length));
146
+ continue;
147
+ }
148
+ if (!arg.startsWith("-")) {
149
+ throw new Error(`Unknown web action: ${arg}`);
150
+ }
151
+ throw new Error(`Unknown web option: ${arg}`);
152
+ }
153
+
154
+ if (options.setupBrowserAccess && options.action !== "up") {
155
+ throw new Error(
156
+ `--setup-browser-access is only valid with ${product().cli} web up`,
157
+ );
158
+ }
159
+
160
+ return options;
161
+ }
162
+
163
+ export function hasDenHostsEntry(hostsContent: string): boolean {
164
+ return hostsContent.split(/\r?\n/).some((line) => {
165
+ const withoutComment = line.split("#")[0]?.trim() ?? "";
166
+ if (!withoutComment) return false;
167
+ const parts = withoutComment.split(/\s+/);
168
+ return (
169
+ parts[0] === "127.0.0.1" && parts.slice(1).includes(`${product().cli}`)
170
+ );
171
+ });
172
+ }
173
+
174
+ function readHostsFile(): string {
175
+ try {
176
+ return fs.readFileSync("/etc/hosts", "utf-8");
177
+ } catch {
178
+ return "";
179
+ }
180
+ }
181
+
182
+ export function resolveDenWebHost(hostsContent = readHostsFile()): string {
183
+ return hasDenHostsEntry(hostsContent) ? `${product().cli}` : "localhost";
184
+ }
185
+
186
+ export function buildDenWebUrl(
187
+ port: number,
188
+ hostsContent = readHostsFile(),
189
+ ): string {
190
+ return `http://${resolveDenWebHost(hostsContent)}:${port}`;
191
+ }
192
+
193
+ export function denWebUrlAliases(port: number): string[] {
194
+ return [
195
+ `http://${product().cli}:${port}`,
196
+ `http://${product().cli}.localhost:${port}`,
197
+ `http://localhost:${port}`,
198
+ `http://127.0.0.1:${port}`,
199
+ ];
200
+ }
201
+
202
+ export function isDenWebTabUrl(rawUrl: string, port: number): boolean {
203
+ try {
204
+ const parsed = new URL(rawUrl);
205
+ return (
206
+ parsed.protocol === "http:" &&
207
+ [
208
+ `${product().cli}`,
209
+ `${product().cli}.localhost`,
210
+ "localhost",
211
+ "127.0.0.1",
212
+ ].includes(parsed.hostname) &&
213
+ parsed.port === String(port)
214
+ );
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ async function getHttpText(
221
+ port: number,
222
+ requestPath: string,
223
+ ): Promise<{ statusCode: number; body: string } | null> {
224
+ return new Promise((resolve) => {
225
+ let settled = false;
226
+ const finish = (
227
+ value: { statusCode: number; body: string } | null,
228
+ ): void => {
229
+ if (settled) return;
230
+ settled = true;
231
+ resolve(value);
232
+ };
233
+
234
+ const req = http.get(
235
+ {
236
+ host: "127.0.0.1",
237
+ port,
238
+ path: requestPath,
239
+ timeout: 1_500,
240
+ },
241
+ (res) => {
242
+ let body = "";
243
+ res.setEncoding("utf-8");
244
+ res.on("data", (chunk: string) => {
245
+ body += chunk;
246
+ if (body.length > 1024 * 1024) {
247
+ req.destroy();
248
+ finish(null);
249
+ }
250
+ });
251
+ res.on("end", () => {
252
+ finish({ statusCode: res.statusCode ?? 0, body });
253
+ });
254
+ },
255
+ );
256
+
257
+ req.on("timeout", () => {
258
+ req.destroy();
259
+ finish(null);
260
+ });
261
+ req.on("error", () => finish(null));
262
+ });
263
+ }
264
+
265
+ async function isDenWebApiRunning(port: number): Promise<boolean> {
266
+ const response = await getHttpText(port, "/api/health");
267
+ if (!response || response.statusCode !== 200) return false;
268
+ try {
269
+ return Array.isArray(JSON.parse(response.body));
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ async function isDenWebUiReady(port: number): Promise<boolean> {
276
+ const response = await getHttpText(port, "/index.html");
277
+ return (
278
+ response?.statusCode === 200 &&
279
+ /<html[\s>]/i.test(response.body) &&
280
+ hasDenUiMarker(response.body)
281
+ );
282
+ }
283
+
284
+ async function isDenWebRunning(port: number): Promise<boolean> {
285
+ return (await isDenWebApiRunning(port)) && (await isDenWebUiReady(port));
286
+ }
287
+
288
+ async function waitForDenWeb(port: number): Promise<boolean> {
289
+ const deadline = Date.now() + WEB_START_TIMEOUT_MS;
290
+ while (Date.now() < deadline) {
291
+ if (await isDenWebRunning(port)) return true;
292
+ await new Promise((resolve) => setTimeout(resolve, WEB_START_POLL_MS));
293
+ }
294
+ return false;
295
+ }
296
+
297
+ /** How to run the web server: Den's own files, from the engine root. */
298
+ function resolveServerEntrypoint(engine: string = getEngineRoot()): {
299
+ cmd: string;
300
+ args: string[];
301
+ } {
302
+ const builtServer = path.join(engine, "dist/src/ui/server/index.js");
303
+
304
+ // Escape hatch: force the compiled build (e.g. to test the prod artifact).
305
+ if (process.env.DEN_USE_DIST === "1" && fs.existsSync(builtServer)) {
306
+ return { cmd: process.execPath, args: [builtServer] };
307
+ }
308
+
309
+ // Prefer live TypeScript source in a dev checkout (tsx present). A built dist/
310
+ // is gitignored and only refreshed by `npm run build`, so preferring it
311
+ // silently runs a stale server AND serves stale web assets
312
+ // (dist/src/ui/server/public) — the footgun that makes UI edits appear not to
313
+ // take effect. The server resolves PUBLIC_DIR relative to its own module, so
314
+ // running from src/ serves src/ui/server/public directly. Fall back to the
315
+ // compiled build only when devDependencies (tsx) aren't installed.
316
+ const tsxBin = engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx");
317
+ const srcServer = path.join(engine, "src/ui/server/index.ts");
318
+ if (fs.existsSync(tsxBin) && fs.existsSync(srcServer)) {
319
+ return { cmd: tsxBin, args: [srcServer] };
320
+ }
321
+
322
+ if (fs.existsSync(builtServer)) {
323
+ return { cmd: process.execPath, args: [builtServer] };
324
+ }
325
+
326
+ return { cmd: tsxBin, args: [srcServer] };
327
+ }
328
+
329
+ function startDenWebServer(options: WebCommandOptions): StartedServer {
330
+ const root = getDistRoot();
331
+ const entrypoint = resolveServerEntrypoint();
332
+ const logDir = path.join(getStateDir(), "logs", `${product().cli}`);
333
+ fs.mkdirSync(logDir, { recursive: true });
334
+ const logPath = path.join(logDir, "web.log");
335
+ fs.appendFileSync(
336
+ logPath,
337
+ `\n--- ${product().cli} web start ${new Date().toISOString()} ---\n`,
338
+ );
339
+
340
+ const logFd = fs.openSync(logPath, "a");
341
+ try {
342
+ const child = spawn(
343
+ entrypoint.cmd,
344
+ [...entrypoint.args, ...(options.silent ? ["--silent"] : [])],
345
+ {
346
+ cwd: root,
347
+ detached: true,
348
+ env: {
349
+ ...process.env,
350
+ PORT: String(options.port),
351
+ OP_ACCOUNT: process.env.OP_ACCOUNT ?? OP_ACCOUNT_DEFAULT,
352
+ },
353
+ stdio: ["ignore", logFd, logFd],
354
+ },
355
+ );
356
+ child.unref();
357
+ const started = { pid: child.pid, logPath };
358
+ writeWebServerState({
359
+ ...started,
360
+ port: options.port,
361
+ root,
362
+ startedAt: new Date().toISOString(),
363
+ command: `${entrypoint.cmd} ${[
364
+ ...entrypoint.args,
365
+ ...(options.silent ? ["--silent"] : []),
366
+ ].join(" ")}`,
367
+ });
368
+ return started;
369
+ } finally {
370
+ fs.closeSync(logFd);
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Spawn `den web restart` as a DETACHED child so a running server can
376
+ * restart ITSELF (e.g. from the "stale server" UI): the child stops whatever is
377
+ * listening on `port` — the current server — then starts a fresh daemon,
378
+ * surviving the current process's death. Returns the short-lived driver pid.
379
+ * Mirrors resolveServerEntrypoint's preference for live tsx source.
380
+ */
381
+ export function spawnDetachedWebRestart(port: number): number | undefined {
382
+ const root = getDistRoot();
383
+ const engine = getEngineRoot();
384
+ const tsxBin = engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx");
385
+ const srcCli = path.join(engine, "src/ui/cli/index.ts");
386
+ const builtCli = path.join(engine, "dist/src/ui/cli/index.js");
387
+
388
+ let cmd: string;
389
+ let baseArgs: string[];
390
+ if (process.env.DEN_USE_DIST === "1" && fs.existsSync(builtCli)) {
391
+ cmd = process.execPath;
392
+ baseArgs = [builtCli];
393
+ } else if (fs.existsSync(tsxBin) && fs.existsSync(srcCli)) {
394
+ cmd = tsxBin;
395
+ baseArgs = [srcCli];
396
+ } else if (fs.existsSync(builtCli)) {
397
+ cmd = process.execPath;
398
+ baseArgs = [builtCli];
399
+ } else {
400
+ cmd = tsxBin;
401
+ baseArgs = [srcCli];
402
+ }
403
+
404
+ const logDir = path.join(getStateDir(), "logs", `${product().cli}`);
405
+ fs.mkdirSync(logDir, { recursive: true });
406
+ const logFd = fs.openSync(path.join(logDir, "web.log"), "a");
407
+ try {
408
+ const child = spawn(
409
+ cmd,
410
+ [...baseArgs, "web", "restart", "--no-open", "--port", String(port)],
411
+ {
412
+ cwd: root,
413
+ detached: true,
414
+ env: {
415
+ ...process.env,
416
+ PORT: String(port),
417
+ OP_ACCOUNT: process.env.OP_ACCOUNT ?? OP_ACCOUNT_DEFAULT,
418
+ },
419
+ stdio: ["ignore", logFd, logFd],
420
+ },
421
+ );
422
+ child.unref();
423
+ return child.pid;
424
+ } finally {
425
+ fs.closeSync(logFd);
426
+ }
427
+ }
428
+
429
+ function webServerStatePath(): string {
430
+ return path.join(getStateDir(), "web.json");
431
+ }
432
+
433
+ function readWebServerState(): WebServerState | null {
434
+ try {
435
+ const parsed = JSON.parse(
436
+ fs.readFileSync(webServerStatePath(), "utf-8"),
437
+ ) as Partial<WebServerState>;
438
+ if (
439
+ typeof parsed.pid !== "number" ||
440
+ !Number.isInteger(parsed.pid) ||
441
+ typeof parsed.port !== "number" ||
442
+ !Number.isInteger(parsed.port)
443
+ ) {
444
+ return null;
445
+ }
446
+ if (typeof parsed.root !== "string" || typeof parsed.logPath !== "string") {
447
+ return null;
448
+ }
449
+ return {
450
+ pid: parsed.pid,
451
+ port: parsed.port,
452
+ root: parsed.root,
453
+ logPath: parsed.logPath,
454
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : "",
455
+ command: typeof parsed.command === "string" ? parsed.command : "",
456
+ };
457
+ } catch {
458
+ return null;
459
+ }
460
+ }
461
+
462
+ function writeWebServerState(state: WebServerState): void {
463
+ const statePath = webServerStatePath();
464
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
465
+ const tmp = `${statePath}.${process.pid}.tmp`;
466
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
467
+ fs.renameSync(tmp, statePath);
468
+ }
469
+
470
+ function removeWebServerState(): void {
471
+ try {
472
+ fs.unlinkSync(webServerStatePath());
473
+ } catch {
474
+ /* absent or already removed */
475
+ }
476
+ }
477
+
478
+ function listListeningPids(port: number): number[] {
479
+ const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
480
+ encoding: "utf-8",
481
+ timeout: CLI_SPAWN_TIMEOUT_MS,
482
+ });
483
+ if (result.status !== 0) return [];
484
+ return Array.from(
485
+ new Set(
486
+ result.stdout
487
+ .split(/\r?\n/)
488
+ .map((line) => Number(line.trim()))
489
+ .filter((pid) => Number.isInteger(pid) && pid > 0),
490
+ ),
491
+ );
492
+ }
493
+
494
+ function commandForPid(pid: number): string {
495
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
496
+ encoding: "utf-8",
497
+ timeout: CLI_SPAWN_TIMEOUT_MS,
498
+ });
499
+ return result.status === 0 ? result.stdout.trim() : "";
500
+ }
501
+
502
+ /**
503
+ * Whether `command` is this Den web server: its entry point, run from one of
504
+ * `roots` — the distribution root (an npm install lives under it) or the
505
+ * engine root (a linked checkout, `npm i ../den`, lives outside it).
506
+ */
507
+ export function isDenWebCommand(
508
+ command: string,
509
+ roots: string[] = webRoots(),
510
+ ): boolean {
511
+ const hasServerEntrypoint =
512
+ command.includes("src/ui/server/index.ts") ||
513
+ command.includes("dist/src/ui/server/index.js");
514
+ if (!hasServerEntrypoint) return false;
515
+ return roots.some((r) => command.includes(path.resolve(r)));
516
+ }
517
+
518
+ function webRoots(root: string = getDistRoot()): string[] {
519
+ const roots = [root];
520
+ try {
521
+ roots.push(getEngineRoot());
522
+ } catch {
523
+ /* no engine root found: the distribution root alone */
524
+ }
525
+ return roots;
526
+ }
527
+
528
+ async function waitForPortRelease(port: number): Promise<boolean> {
529
+ const deadline = Date.now() + CLI_SPAWN_TIMEOUT_MS;
530
+ while (Date.now() < deadline) {
531
+ if (listListeningPids(port).length === 0) return true;
532
+ await new Promise((resolve) => setTimeout(resolve, 150));
533
+ }
534
+ return false;
535
+ }
536
+
537
+ async function waitForPidsExit(pids: number[]): Promise<boolean> {
538
+ const deadline = Date.now() + CLI_SPAWN_TIMEOUT_MS;
539
+ while (Date.now() < deadline) {
540
+ const anyAlive = pids.some((pid) => {
541
+ try {
542
+ process.kill(pid, 0);
543
+ return true;
544
+ } catch {
545
+ return false;
546
+ }
547
+ });
548
+ if (!anyAlive) return true;
549
+ await new Promise((resolve) => setTimeout(resolve, 150));
550
+ }
551
+ return false;
552
+ }
553
+
554
+ function findDenWebPids(port: number): number[] {
555
+ const root = getDistRoot();
556
+ const pids = new Set<number>();
557
+ const state = readWebServerState();
558
+ if (state?.port === port && state.pid) {
559
+ const command = commandForPid(state.pid);
560
+ if (command && isDenWebCommand(command, webRoots(state.root || root))) {
561
+ pids.add(state.pid);
562
+ }
563
+ }
564
+
565
+ for (const pid of listListeningPids(port)) {
566
+ if (isDenWebCommand(commandForPid(pid), webRoots(root))) {
567
+ pids.add(pid);
568
+ }
569
+ }
570
+
571
+ return Array.from(pids);
572
+ }
573
+
574
+ /**
575
+ * Signal a den-web process AND its process group. The server is spawned with
576
+ * `detached: true` (a process-group leader) and spawns `tail -F` log streamers
577
+ * as children in that group; signalling only the server pid leaves those tails
578
+ * orphaned (reparented to init, `tail -F` runs forever). Signal the whole group
579
+ * (negative pid) first, then the pid itself as a fallback. Detached daemon
580
+ * SERVICES are their own group leaders, so they're untouched — only the web
581
+ * server and its streamers stop.
582
+ */
583
+ export function killWebProcess(pid: number, signal: NodeJS.Signals): void {
584
+ try {
585
+ process.kill(-pid, signal); // whole process group
586
+ } catch {
587
+ /* not a group leader, or already gone */
588
+ }
589
+ try {
590
+ process.kill(pid, signal);
591
+ } catch {
592
+ /* already gone */
593
+ }
594
+ }
595
+
596
+ async function stopDenWebServer(port: number): Promise<StopServerResult> {
597
+ const pids = findDenWebPids(port);
598
+ if (pids.length === 0) {
599
+ const state = readWebServerState();
600
+ if (state?.port === port) removeWebServerState();
601
+ return { stopped: false, forced: false, pids: [] };
602
+ }
603
+
604
+ for (const pid of pids) killWebProcess(pid, "SIGTERM");
605
+
606
+ const graceful =
607
+ (await waitForPidsExit(pids)) && (await waitForPortRelease(port));
608
+ if (graceful) {
609
+ removeWebServerState();
610
+ return { stopped: true, forced: false, pids };
611
+ }
612
+
613
+ for (const pid of pids) killWebProcess(pid, "SIGKILL");
614
+
615
+ const forced =
616
+ (await waitForPidsExit(pids)) && (await waitForPortRelease(port));
617
+ if (forced) removeWebServerState();
618
+ return { stopped: forced, forced: true, pids };
619
+ }
620
+
621
+ async function stopBrokenDenWebServer(port: number): Promise<boolean> {
622
+ return (await stopDenWebServer(port)).stopped;
623
+ }
624
+
625
+ function recordRunningDenWebServer(port: number): void {
626
+ const root = getDistRoot();
627
+ const state = readWebServerState();
628
+ if (state?.port === port && state.pid) {
629
+ const command = commandForPid(state.pid);
630
+ if (command && isDenWebCommand(command, webRoots(state.root || root)))
631
+ return;
632
+ }
633
+
634
+ const pids = listListeningPids(port).filter((pid) =>
635
+ isDenWebCommand(commandForPid(pid), webRoots(root)),
636
+ );
637
+ const pid = pids[0];
638
+ if (!pid) return;
639
+
640
+ const logPath = path.join(
641
+ getStateDir(),
642
+ "logs",
643
+ `${product().cli}`,
644
+ "web.log",
645
+ );
646
+ writeWebServerState({
647
+ pid,
648
+ port,
649
+ root,
650
+ logPath,
651
+ startedAt: new Date().toISOString(),
652
+ command: commandForPid(pid),
653
+ });
654
+ }
655
+
656
+ function appleScriptString(value: string): string {
657
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
658
+ }
659
+
660
+ function appleScriptUrlPredicate(varName: string, port: number): string {
661
+ return denWebUrlAliases(port)
662
+ .flatMap((url) => [
663
+ `${varName} is ${appleScriptString(url)}`,
664
+ `${varName} starts with ${appleScriptString(`${url}/`)}`,
665
+ `${varName} starts with ${appleScriptString(`${url}?`)}`,
666
+ ])
667
+ .join(" or ");
668
+ }
669
+
670
+ function buildChromiumFocusScript(appName: string, port: number): string {
671
+ const predicate = appleScriptUrlPredicate("u", port);
672
+ return `
673
+ if application ${appleScriptString(appName)} is running then
674
+ tell application ${appleScriptString(appName)}
675
+ repeat with w in windows
676
+ set tabIndex to 0
677
+ repeat with t in tabs of w
678
+ set tabIndex to tabIndex + 1
679
+ set u to URL of t
680
+ if u is not missing value and (${predicate}) then
681
+ set active tab index of w to tabIndex
682
+ set index of w to 1
683
+ activate
684
+ return "focused"
685
+ end if
686
+ end repeat
687
+ end repeat
688
+ return "not-found"
689
+ end tell
690
+ end if
691
+ return "not-running"
692
+ `;
693
+ }
694
+
695
+ function buildSafariFocusScript(port: number): string {
696
+ const predicate = appleScriptUrlPredicate("u", port);
697
+ return `
698
+ if application "Safari" is running then
699
+ tell application "Safari"
700
+ repeat with w in windows
701
+ repeat with t in tabs of w
702
+ set u to URL of t
703
+ if u is not missing value and (${predicate}) then
704
+ set current tab of w to t
705
+ set index of w to 1
706
+ activate
707
+ return "focused"
708
+ end if
709
+ end repeat
710
+ end repeat
711
+ return "not-found"
712
+ end tell
713
+ end if
714
+ return "not-running"
715
+ `;
716
+ }
717
+
718
+ function runAppleScript(
719
+ script: string,
720
+ timeout = CLI_SPAWN_TIMEOUT_MS,
721
+ ): string {
722
+ return execFileSync("osascript", [], {
723
+ input: script,
724
+ encoding: "utf-8",
725
+ timeout,
726
+ stdio: ["pipe", "pipe", "pipe"],
727
+ }).trim();
728
+ }
729
+
730
+ function focusExistingBrowserTab(port: number): BrowserFocusResult {
731
+ if (process.platform !== "darwin") return { status: "unsupported" };
732
+
733
+ let inspectedBrowser = false;
734
+ const failedApps: string[] = [];
735
+ let firstError = "";
736
+
737
+ for (const app of BROWSER_APPS) {
738
+ let result = "not-found";
739
+ try {
740
+ result = runAppleScript(
741
+ app.kind === "safari"
742
+ ? buildSafariFocusScript(port)
743
+ : buildChromiumFocusScript(app.name, port),
744
+ );
745
+ } catch (error) {
746
+ const message = error instanceof Error ? error.message : String(error);
747
+ failedApps.push(app.name);
748
+ firstError ||= message;
749
+ continue;
750
+ }
751
+
752
+ if (result === "focused") return { status: "focused", app: app.name };
753
+ if (result === "not-found") inspectedBrowser = true;
754
+ }
755
+
756
+ if (!inspectedBrowser && failedApps.length > 0) {
757
+ return { status: "error", apps: failedApps, error: firstError };
758
+ }
759
+
760
+ return { status: "not-found" };
761
+ }
762
+
763
+ function openUrl(url: string): BrowserOpenResult {
764
+ const cmd =
765
+ process.platform === "darwin"
766
+ ? "open"
767
+ : process.platform === "win32"
768
+ ? "cmd"
769
+ : "xdg-open";
770
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
771
+ const result = spawnSync(cmd, args, {
772
+ stdio: "ignore",
773
+ timeout: CLI_SPAWN_TIMEOUT_MS,
774
+ });
775
+ if (result.status === 0) return { status: "opened" };
776
+ const error =
777
+ result.error?.message ??
778
+ `browser opener exited with status ${result.status ?? "unknown"}`;
779
+ return { status: "open-failed", error };
780
+ }
781
+
782
+ function openOrFocusDenWebUrl(url: string, port: number): BrowserOpenResult {
783
+ const focusResult = focusExistingBrowserTab(port);
784
+ if (focusResult.status === "focused") return focusResult;
785
+
786
+ if (focusResult.status === "error" && process.platform === "darwin") {
787
+ return focusResult;
788
+ }
789
+
790
+ return openUrl(url);
791
+ }
792
+
793
+ function buildBrowserAccessProbeScript(appName: string): string {
794
+ return `
795
+ if application ${appleScriptString(appName)} is running then
796
+ tell application ${appleScriptString(appName)}
797
+ get count of windows
798
+ end tell
799
+ return "granted"
800
+ end if
801
+ return "not-running"
802
+ `;
803
+ }
804
+
805
+ function detectTerminalBundleId(): string | null {
806
+ const termProgram = process.env.TERM_PROGRAM ?? "";
807
+ if (termProgram === "iTerm.app") return "com.googlecode.iterm2";
808
+ if (termProgram === "Apple_Terminal") return "com.apple.Terminal";
809
+ if (termProgram === "vscode") return "com.microsoft.VSCode";
810
+ return null;
811
+ }
812
+
813
+ function automationResetHint(): string {
814
+ const bundleId = detectTerminalBundleId();
815
+ if (bundleId) return `tccutil reset AppleEvents ${bundleId}`;
816
+ return "tccutil reset AppleEvents";
817
+ }
818
+
819
+ function isAutomationDenied(error: string): boolean {
820
+ return /-1743|not authorized|not permitted|not allowed/i.test(error);
821
+ }
822
+
823
+ function setupBrowserAccess(): void {
824
+ if (process.platform !== "darwin") {
825
+ console.log(
826
+ chalk.yellow("\n Browser tab reuse is only automated on macOS.\n"),
827
+ );
828
+ return;
829
+ }
830
+
831
+ console.log(chalk.cyan("\n Requesting browser tab access..."));
832
+ console.log(
833
+ chalk.gray(
834
+ ` Keep the browser you use for ${product().title} open and approve the macOS Automation prompt if it appears.`,
835
+ ),
836
+ );
837
+
838
+ let runningBrowsers = 0;
839
+ let grantedBrowsers = 0;
840
+ let deniedBrowsers = 0;
841
+
842
+ for (const app of BROWSER_APPS) {
843
+ try {
844
+ const result = runAppleScript(
845
+ buildBrowserAccessProbeScript(app.name),
846
+ 60_000,
847
+ );
848
+ if (result === "not-running") continue;
849
+
850
+ runningBrowsers += 1;
851
+ grantedBrowsers += 1;
852
+ console.log(chalk.green(` OK ${app.name} access granted`));
853
+ } catch (error) {
854
+ const message = error instanceof Error ? error.message : String(error);
855
+ runningBrowsers += 1;
856
+ deniedBrowsers += 1;
857
+ console.log(chalk.yellow(` WARN ${app.name} access was not granted`));
858
+ if (!isAutomationDenied(message)) {
859
+ console.log(chalk.gray(` ${message}`));
860
+ }
861
+ }
862
+ }
863
+
864
+ if (runningBrowsers === 0) {
865
+ console.log(
866
+ chalk.yellow(
867
+ "\n No supported running browser was found. Open Chrome, Safari, Edge, Brave, or Arc, then rerun:",
868
+ ),
869
+ );
870
+ console.log(chalk.gray(` ${product().cli} web --setup-browser-access\n`));
871
+ return;
872
+ }
873
+
874
+ if (grantedBrowsers > 0 && deniedBrowsers === 0) {
875
+ console.log(
876
+ chalk.green(
877
+ `\n OK Browser access is ready. \`${product().cli} web\` can now reuse an existing tab.\n`,
878
+ ),
879
+ );
880
+ return;
881
+ }
882
+
883
+ console.log(
884
+ chalk.yellow(
885
+ `\n macOS did not grant access for every running browser ${product().title} checked.`,
886
+ ),
887
+ );
888
+ console.log(
889
+ chalk.gray(
890
+ " Open System Settings -> Privacy & Security -> Automation and allow your terminal to control your browser.",
891
+ ),
892
+ );
893
+ console.log(
894
+ chalk.gray(
895
+ " If no prompt appeared, reset the terminal's AppleEvents permission and rerun setup:",
896
+ ),
897
+ );
898
+ console.log(chalk.gray(` ${automationResetHint()}`));
899
+ console.log(chalk.gray(` ${product().cli} web --setup-browser-access\n`));
900
+ }
901
+
902
+ export function displayWebHelp(): void {
903
+ console.log(chalk.bold("\nUsage:"));
904
+ console.log(
905
+ ` ${product().cli} web [up|down|restart] [--port <port>] [--no-open] [--verbose]`,
906
+ );
907
+ console.log(` ${product().cli} web --setup-browser-access`);
908
+ console.log();
909
+ console.log(chalk.bold("Actions:"));
910
+ console.log(
911
+ ` up Start or focus ${product().title} web (default)`,
912
+ );
913
+ console.log(
914
+ ` down Stop the ${product().title} web daemon only`,
915
+ );
916
+ console.log(` restart Stop then start ${product().title} web`);
917
+ console.log(" (start/stop remain accepted as legacy aliases for up/down)");
918
+ console.log();
919
+ console.log(chalk.bold("Options:"));
920
+ console.log(" --port, -p <port> Use a non-default web port");
921
+ console.log(
922
+ " --no-open Start or check the server without opening a browser",
923
+ );
924
+ console.log(
925
+ " --setup-browser-access Request macOS Automation access for browser tab reuse",
926
+ );
927
+ console.log(
928
+ " --verbose Keep verbose server request logging enabled",
929
+ );
930
+ console.log(
931
+ " --silent Suppress verbose server request logging (default)",
932
+ );
933
+ console.log();
934
+ }
935
+
936
+ export async function cmdWeb(args: string[]): Promise<void> {
937
+ if (args.includes("--help") || args.includes("-h")) {
938
+ displayWebHelp();
939
+ return;
940
+ }
941
+
942
+ const options = parseWebCommandArgs(args);
943
+ if (options.setupBrowserAccess) {
944
+ setupBrowserAccess();
945
+ return;
946
+ }
947
+
948
+ const url = buildDenWebUrl(options.port);
949
+
950
+ if (options.action === "down") {
951
+ const stopped = await stopDenWebServer(options.port);
952
+ if (stopped.stopped) {
953
+ const mode = stopped.forced ? "force stopped" : "stopped";
954
+ console.log(
955
+ chalk.green(
956
+ `\n OK ${product().title} web ${mode} (${stopped.pids.join(", ")})\n`,
957
+ ),
958
+ );
959
+ return;
960
+ }
961
+ if (stopped.pids.length > 0) {
962
+ throw new Error(
963
+ `Could not stop ${product().title} web on port ${options.port}. See ~/.${product().name}/logs/${product().cli}/web.log`,
964
+ );
965
+ }
966
+ console.log(
967
+ chalk.green(`\n OK ${product().title} web is not running at ${url}\n`),
968
+ );
969
+ return;
970
+ }
971
+
972
+ if (options.action === "restart") {
973
+ const stopped = await stopDenWebServer(options.port);
974
+ if (stopped.pids.length > 0 && !stopped.stopped) {
975
+ throw new Error(
976
+ `Could not stop ${product().title} web on port ${options.port}.`,
977
+ );
978
+ }
979
+ const status = stopped.stopped
980
+ ? stopped.forced
981
+ ? "force stopped"
982
+ : "stopped"
983
+ : "not running";
984
+ console.log(
985
+ chalk.gray(`\n ${product().title} web ${status}; starting again...`),
986
+ );
987
+ }
988
+
989
+ const apiRunning = await isDenWebApiRunning(options.port);
990
+ let running = apiRunning && (await isDenWebUiReady(options.port));
991
+ let started: StartedServer | null = null;
992
+
993
+ if (apiRunning && !running) {
994
+ console.log(
995
+ chalk.yellow(
996
+ `\n Existing ${product().title} web server on ${url} is responding, but the UI assets are not available.`,
997
+ ),
998
+ );
999
+ console.log(chalk.gray(` Restarting ${product().title} web...`));
1000
+ const stopped = await stopBrokenDenWebServer(options.port);
1001
+ if (!stopped) {
1002
+ throw new Error(
1003
+ `Could not stop the broken ${product().title} web server on port ${options.port}.`,
1004
+ );
1005
+ }
1006
+ }
1007
+
1008
+ if (!running) {
1009
+ console.log(chalk.cyan(`\n Starting ${product().cli} web on ${url}...`));
1010
+ try {
1011
+ started = startDenWebServer(options);
1012
+ running = await waitForDenWeb(options.port);
1013
+ if (!running) {
1014
+ throw new Error(
1015
+ `${product().title} web did not become ready. See ${started.logPath}`,
1016
+ );
1017
+ }
1018
+ console.log(
1019
+ chalk.green(
1020
+ ` OK ${product().title} web started${started.pid ? ` (pid ${started.pid})` : ""}`,
1021
+ ),
1022
+ );
1023
+ console.log(chalk.gray(` Log: ${started.logPath}`));
1024
+ } catch (error) {
1025
+ console.log(chalk.red(` ERROR Failed to start ${product().cli} web`));
1026
+ throw error;
1027
+ }
1028
+ } else {
1029
+ recordRunningDenWebServer(options.port);
1030
+ console.log(
1031
+ chalk.green(`\n OK ${product().title} web is already running at ${url}`),
1032
+ );
1033
+ }
1034
+
1035
+ if (!options.openBrowser) {
1036
+ console.log(chalk.gray(` Browser open skipped. Visit: ${url}\n`));
1037
+ return;
1038
+ }
1039
+
1040
+ const browser = openOrFocusDenWebUrl(url, options.port);
1041
+ if (browser.status === "focused") {
1042
+ console.log(chalk.green(` OK Focused existing ${browser.app} tab`));
1043
+ console.log(chalk.gray(` ${url}\n`));
1044
+ return;
1045
+ }
1046
+ if (browser.status === "opened") {
1047
+ console.log(
1048
+ chalk.green(` OK Opened ${product().cli} web in your browser`),
1049
+ );
1050
+ console.log(chalk.gray(` ${url}\n`));
1051
+ return;
1052
+ }
1053
+
1054
+ if (browser.status === "error") {
1055
+ const appList = browser.apps.join(", ");
1056
+ console.log(
1057
+ chalk.yellow(
1058
+ `\n Could not inspect browser tabs, so ${product().title} did not open a new tab.`,
1059
+ ),
1060
+ );
1061
+ console.log(
1062
+ chalk.gray(
1063
+ ` macOS may need Automation permission for your terminal to control ${appList}.`,
1064
+ ),
1065
+ );
1066
+ console.log(
1067
+ chalk.gray(` Run: ${product().cli} web --setup-browser-access`),
1068
+ );
1069
+ } else {
1070
+ console.log(chalk.yellow("\n Could not open a browser automatically."));
1071
+ }
1072
+ console.log(chalk.gray(` Visit: ${url}\n`));
1073
+ }