@monster-orange/den 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +99 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -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
|
+
}
|