@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,3656 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Den CLI
|
|
5
|
+
* Main entry point for the interactive CLI
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import oraSpinner from "ora";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Spinners are progress, not diagnostics: on a terminal they animate on
|
|
13
|
+
* stderr as usual; piped (CI, scripts) their lines go to stdout so stderr
|
|
14
|
+
* carries only real warnings and errors.
|
|
15
|
+
*/
|
|
16
|
+
const ora = (text: string) =>
|
|
17
|
+
oraSpinner({
|
|
18
|
+
text,
|
|
19
|
+
stream: process.stderr.isTTY ? process.stderr : process.stdout,
|
|
20
|
+
});
|
|
21
|
+
import Table from "cli-table3";
|
|
22
|
+
import { cliPrompts } from "./prompts.js";
|
|
23
|
+
import { resultsViewer } from "./results-viewer.js";
|
|
24
|
+
import { testRunner } from "./test-runner.js";
|
|
25
|
+
import { envSelector } from "../../shared/environment/env-selector.js";
|
|
26
|
+
import {
|
|
27
|
+
recordInvocation,
|
|
28
|
+
recordFailure,
|
|
29
|
+
startTimer,
|
|
30
|
+
} from "../../shared/dev-metrics/emit.js";
|
|
31
|
+
import type { Environment, Pillar } from "../../shared/types/index.js";
|
|
32
|
+
import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
|
|
33
|
+
import { resolvePerformanceModules } from "./module-selection.js";
|
|
34
|
+
import {
|
|
35
|
+
readCatalog,
|
|
36
|
+
resolveRepoGhSlug,
|
|
37
|
+
upsertCatalogEntry,
|
|
38
|
+
} from "../../shared/environment-manager/catalog.js";
|
|
39
|
+
import { seedSecretsFromSibling } from "../../shared/environment-manager/secrets-check.js";
|
|
40
|
+
import {
|
|
41
|
+
readWorkspacesFile,
|
|
42
|
+
writeWorkspacesFile,
|
|
43
|
+
getActiveWorkspace,
|
|
44
|
+
getFocusedWorkspaceName,
|
|
45
|
+
isWorkspaceActive,
|
|
46
|
+
activateWorkspace,
|
|
47
|
+
deactivateWorkspace,
|
|
48
|
+
setActiveWorkspace,
|
|
49
|
+
createWorkspace,
|
|
50
|
+
deleteWorkspaceConfig,
|
|
51
|
+
parseAddress,
|
|
52
|
+
noAutostartRepos,
|
|
53
|
+
generateVSCodeWorkspace,
|
|
54
|
+
syncJetBrainsWorkspaceSymlinks,
|
|
55
|
+
DEN_SERVICES_DIR,
|
|
56
|
+
DEN_WORKSPACES_DIR,
|
|
57
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
58
|
+
import {
|
|
59
|
+
deleteWorkspaceDirectory,
|
|
60
|
+
checkBehindCount,
|
|
61
|
+
pullRepo,
|
|
62
|
+
ensureManagedServiceClone,
|
|
63
|
+
runSetupHidden,
|
|
64
|
+
runSetupInteractive,
|
|
65
|
+
hasSetupSucceeded,
|
|
66
|
+
hasSetupCommand,
|
|
67
|
+
markSetupSucceeded,
|
|
68
|
+
fetchAndFastForward,
|
|
69
|
+
} from "../../shared/environment-manager/workspace-manager.js";
|
|
70
|
+
import {
|
|
71
|
+
runServiceCommand,
|
|
72
|
+
claimHealthyService,
|
|
73
|
+
getWorkspaceServices,
|
|
74
|
+
ensureDaemonRegistryLoaded,
|
|
75
|
+
hasRunningOpctlContainers,
|
|
76
|
+
releaseWorkspaceDaemons,
|
|
77
|
+
resolveServiceRuntime,
|
|
78
|
+
} from "../../shared/environment-manager/service-runner.js";
|
|
79
|
+
import {
|
|
80
|
+
defaultWorkspaceRepos,
|
|
81
|
+
defaultWorkspaceNeedsSeeding,
|
|
82
|
+
defaultWorkspaceHasServices,
|
|
83
|
+
seedDefaultWorkspace,
|
|
84
|
+
topUpDefaultWorkspace,
|
|
85
|
+
markDefaultWorkspaceDeclined,
|
|
86
|
+
} from "../../shared/environment-manager/default-workspace.js";
|
|
87
|
+
import {
|
|
88
|
+
checkCatalogHealth,
|
|
89
|
+
rollupRepoHealth,
|
|
90
|
+
sortByRepoOrder,
|
|
91
|
+
} from "../../shared/environment-manager/health.js";
|
|
92
|
+
import { checkServiceHealth } from "../../shared/environment-manager/health.js";
|
|
93
|
+
import {
|
|
94
|
+
resolveStartupOrder,
|
|
95
|
+
getWaitDeps,
|
|
96
|
+
} from "../../shared/environment-manager/dag.js";
|
|
97
|
+
import { waitForReady } from "../../shared/environment-manager/readiness.js";
|
|
98
|
+
import { resolveService } from "../../shared/environment-manager/service-runner.js";
|
|
99
|
+
import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
|
|
100
|
+
import {
|
|
101
|
+
getReposRoot,
|
|
102
|
+
mergeUserConfig,
|
|
103
|
+
readDenConfig,
|
|
104
|
+
readUserConfig,
|
|
105
|
+
} from "../../shared/den-config.js";
|
|
106
|
+
import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
|
|
107
|
+
import {
|
|
108
|
+
hardDepsOf,
|
|
109
|
+
orderForUp,
|
|
110
|
+
orderForDown,
|
|
111
|
+
deriveStartTargets,
|
|
112
|
+
} from "../../shared/environment-manager/derivation/derived-up.js";
|
|
113
|
+
import type { DerivedGraph } from "../../shared/environment-manager/derivation/types.js";
|
|
114
|
+
import { loadAliasResolver } from "../../shared/environment-manager/derivation/load-alias.js";
|
|
115
|
+
import {
|
|
116
|
+
cmdApps,
|
|
117
|
+
describeContributor,
|
|
118
|
+
focusedAppRepos,
|
|
119
|
+
takeAppFlag,
|
|
120
|
+
} from "./applications-command.js";
|
|
121
|
+
import { loadApplications } from "../../shared/environment-manager/load-applications.js";
|
|
122
|
+
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
123
|
+
import type { RepoKind } from "../../shared/types/index.js";
|
|
124
|
+
import { localCommandsFor } from "../../shared/stacks/manifest-commands.js";
|
|
125
|
+
import {
|
|
126
|
+
buildUpPlan,
|
|
127
|
+
type NodePlan,
|
|
128
|
+
} from "../../shared/environment-manager/up-plan.js";
|
|
129
|
+
import { execSync } from "child_process";
|
|
130
|
+
import * as fs from "fs";
|
|
131
|
+
import { readManifest } from "../../shared/manifest/read-manifest.js";
|
|
132
|
+
import * as nodePath from "path";
|
|
133
|
+
import { runInit } from "./init.js";
|
|
134
|
+
import { validateManifest } from "../../shared/manifest/validate-manifest.js";
|
|
135
|
+
import {
|
|
136
|
+
getDistRoot,
|
|
137
|
+
getEngineRoot,
|
|
138
|
+
getInvokeCwd,
|
|
139
|
+
} from "../../shared/paths.js";
|
|
140
|
+
import { engineBin } from "../../shared/engine.js";
|
|
141
|
+
import { runCreate } from "./create.js";
|
|
142
|
+
import {
|
|
143
|
+
confirmWithTimeout,
|
|
144
|
+
recoverStalledSetup,
|
|
145
|
+
STALLED_SETUP_CONFIRM_TIMEOUT_MS,
|
|
146
|
+
} from "./stalled-setup-recovery.js";
|
|
147
|
+
import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
|
|
148
|
+
import { cmdConfig } from "./config-command.js";
|
|
149
|
+
import { cmdEdge } from "./edge-command.js";
|
|
150
|
+
import { cmdStack } from "./stack-command.js";
|
|
151
|
+
import { cmdWeb, displayWebHelp } from "./web-command.js";
|
|
152
|
+
import { cmdGraph } from "./graph-command.js";
|
|
153
|
+
import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
|
|
154
|
+
import {
|
|
155
|
+
parseCliArgs,
|
|
156
|
+
unknownCommandFlags,
|
|
157
|
+
workspaceFlagRequiresActive,
|
|
158
|
+
type ParsedCliArgs,
|
|
159
|
+
} from "./flag-parser.js";
|
|
160
|
+
import {
|
|
161
|
+
resolveInvocationTarget,
|
|
162
|
+
findInvocationRepoRoot,
|
|
163
|
+
resolveCwdRepo,
|
|
164
|
+
} from "./invocation-target.js";
|
|
165
|
+
import { preflightSecrets } from "./secrets-preflight.js";
|
|
166
|
+
import {
|
|
167
|
+
formatHealthDot,
|
|
168
|
+
formatHealthLabel,
|
|
169
|
+
formatHealthReason,
|
|
170
|
+
} from "./health-display.js";
|
|
171
|
+
import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
|
|
172
|
+
|
|
173
|
+
import { product } from "../../shared/product.js";
|
|
174
|
+
const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service, and workspace orchestration`;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Concise `<command> -h` help for commands without a dedicated help renderer.
|
|
178
|
+
* Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
|
|
179
|
+
*/
|
|
180
|
+
const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
181
|
+
down: {
|
|
182
|
+
usage: "down [repo] [flags]",
|
|
183
|
+
lines: [
|
|
184
|
+
"Stop the cwd (or named) repo's derived dependency closure, in reverse order.",
|
|
185
|
+
"-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
|
|
186
|
+
"every down script runs — no already-stopped skip).",
|
|
187
|
+
"Flags: -a/--all · -A/--app <app> · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
restart: {
|
|
191
|
+
usage: "restart [service...] [-A <app>]",
|
|
192
|
+
lines: [
|
|
193
|
+
`Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
status: {
|
|
197
|
+
usage: "status [-A <app>]",
|
|
198
|
+
lines: [
|
|
199
|
+
"Show service health for the active workspace (or one application).",
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
logs: {
|
|
203
|
+
usage: "logs [repo|repo/subsystem]",
|
|
204
|
+
lines: [
|
|
205
|
+
"Tail live local logs for the cwd (or named) repo's front-door service.",
|
|
206
|
+
"Sources, in order: the manifest's logs.local command, the service's",
|
|
207
|
+
`running container (docker logs -f), or the ${product().cli} daemon startup log.`,
|
|
208
|
+
`If the repo isn't running, offers to start it first (${product().cli} up).`,
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
services: {
|
|
212
|
+
usage: "services",
|
|
213
|
+
lines: ["List all registered services in the catalog."],
|
|
214
|
+
},
|
|
215
|
+
register: {
|
|
216
|
+
usage: "register [path]",
|
|
217
|
+
lines: [
|
|
218
|
+
"With a path: add that checkout (dev-managed) under its manifest name.",
|
|
219
|
+
"Without one: pick the repo interactively.",
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
setup: {
|
|
223
|
+
usage: "setup [service...] [--force|-f]",
|
|
224
|
+
lines: [
|
|
225
|
+
"Run 'make setup' once per system (clones missing managed repos first).",
|
|
226
|
+
"Default targets MANAGED clones only — setup's codegen/installs",
|
|
227
|
+
"shouldn't touch your own dev-managed checkouts uninvited.",
|
|
228
|
+
"--force (-f) includes dev-managed repos; naming a service always works.",
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
sync: {
|
|
232
|
+
usage: "sync [service...]",
|
|
233
|
+
lines: ["Check managed repos for upstream updates and offer to pull."],
|
|
234
|
+
},
|
|
235
|
+
workspace: {
|
|
236
|
+
usage: "workspace <list|use|create|delete|code> [name]",
|
|
237
|
+
lines: ["Manage workspaces (named sets of services + their modes)."],
|
|
238
|
+
},
|
|
239
|
+
run: {
|
|
240
|
+
usage: "run [options]",
|
|
241
|
+
lines: ["Run tests (interactive picker, or pillar/module/env flags)."],
|
|
242
|
+
},
|
|
243
|
+
"view-results": {
|
|
244
|
+
usage: "view-results",
|
|
245
|
+
lines: ["Browse recent test results."],
|
|
246
|
+
},
|
|
247
|
+
create: {
|
|
248
|
+
usage:
|
|
249
|
+
"create <name> [dir] [--title <title>] [--cli <cli>] [--env-prefix <PREFIX>]",
|
|
250
|
+
lines: [
|
|
251
|
+
"Scaffold a new distribution (den.yml, config, bin shim, Makefile) in [dir].",
|
|
252
|
+
"Also: npx @monster-orange/den create <name>. See docs/distributions.md.",
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
validate: {
|
|
256
|
+
usage: "validate [path|url]",
|
|
257
|
+
lines: [
|
|
258
|
+
`Validate a ${product().manifestYml} manifest (defaults to the cwd repo).`,
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
init: {
|
|
262
|
+
usage: "init [repo-dir]",
|
|
263
|
+
lines: [
|
|
264
|
+
`In the ${product().cli} root: seed/reset the default workspace (all repos managed).`,
|
|
265
|
+
`In a service repo: scaffold that repo's ${product().manifestYml} manifest.`,
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
config: {
|
|
269
|
+
usage: "config [get|set ...]",
|
|
270
|
+
lines: [`View or edit ${product().cli} configuration.`],
|
|
271
|
+
},
|
|
272
|
+
apps: {
|
|
273
|
+
usage: "apps [--json] (alias: applications)",
|
|
274
|
+
lines: [
|
|
275
|
+
"Org > Application > Repo > Services. Applications: config `applications:`,",
|
|
276
|
+
"manifest `application:`; any other repo is its own application.",
|
|
277
|
+
"Act on one: up|down|restart|status -A <app>.",
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
stack: {
|
|
281
|
+
usage:
|
|
282
|
+
"stack [ls|up|down|restart|health|ps|logs] <name…> [--build] [-w <workspace>]",
|
|
283
|
+
lines: [
|
|
284
|
+
"Run repos' compose stacks with no host ports, at https://<route>.<stack>.<edge.domain>.",
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
edge: {
|
|
288
|
+
usage: "edge [up|down|status|trust|untrust|routes]",
|
|
289
|
+
lines: [
|
|
290
|
+
"This org's HTTPS edge: own CA + cert, routes at <name>.<edge.domain>.",
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
"local-dev": {
|
|
294
|
+
usage: "local-dev [on|off]",
|
|
295
|
+
lines: [
|
|
296
|
+
"Toggle the local-dev experience (web Local Dev section + service mgmt).",
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
menu: {
|
|
300
|
+
usage: "menu",
|
|
301
|
+
lines: ["Open the interactive menu (pillar/module/env picker)."],
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
|
|
306
|
+
return (
|
|
307
|
+
explicitBaseApiUrl?.trim() ||
|
|
308
|
+
process.env.BASE_URL ||
|
|
309
|
+
process.env.BASE_API_URL ||
|
|
310
|
+
undefined
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Dev-tooling telemetry helpers ─────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Registered den CLI commands — the ONLY values allowed in the telemetry
|
|
318
|
+
* `command` attribute. Unknown input collapses to "unknown" so raw argv (which
|
|
319
|
+
* can contain paths or secrets) never reaches telemetry.
|
|
320
|
+
*/
|
|
321
|
+
const TELEMETRY_COMMANDS = new Set<string>([
|
|
322
|
+
"run",
|
|
323
|
+
"view-results",
|
|
324
|
+
"menu",
|
|
325
|
+
"web",
|
|
326
|
+
"up",
|
|
327
|
+
"down",
|
|
328
|
+
"restart",
|
|
329
|
+
"status",
|
|
330
|
+
"logs",
|
|
331
|
+
"services",
|
|
332
|
+
"graph",
|
|
333
|
+
"register",
|
|
334
|
+
"use-local",
|
|
335
|
+
`use-managed`,
|
|
336
|
+
"setup",
|
|
337
|
+
"workspace",
|
|
338
|
+
"sync",
|
|
339
|
+
"init",
|
|
340
|
+
"validate",
|
|
341
|
+
"create",
|
|
342
|
+
"local-dev",
|
|
343
|
+
"config",
|
|
344
|
+
"edge",
|
|
345
|
+
"stack",
|
|
346
|
+
"apps",
|
|
347
|
+
"applications",
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
/** Map a raw command token to a PII-safe, registered telemetry command name. */
|
|
351
|
+
function telemetryCommandName(command?: string): string {
|
|
352
|
+
if (!command) return "none";
|
|
353
|
+
if (command === "--help" || command === "-h") return "help";
|
|
354
|
+
if (command === "--version" || command === "-v") return "version";
|
|
355
|
+
return TELEMETRY_COMMANDS.has(command) ? command : "unknown";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Error *class* for telemetry (never the message — PII). */
|
|
359
|
+
function telemetryReason(error: unknown): string {
|
|
360
|
+
const name = (error as { constructor?: { name?: string } })?.constructor
|
|
361
|
+
?.name;
|
|
362
|
+
return name && name !== "Object" ? name : "Error";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
class DenCLI {
|
|
366
|
+
private static readonly serviceCommands = new Set([
|
|
367
|
+
"up",
|
|
368
|
+
"down",
|
|
369
|
+
"restart",
|
|
370
|
+
"status",
|
|
371
|
+
"logs",
|
|
372
|
+
"services",
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Display the branded banner for the help screen.
|
|
377
|
+
*/
|
|
378
|
+
private displayHelpBanner(): void {
|
|
379
|
+
console.log();
|
|
380
|
+
for (const line of DEN_BANNER_LINES) {
|
|
381
|
+
console.log(chalk.cyan(line));
|
|
382
|
+
}
|
|
383
|
+
console.log();
|
|
384
|
+
console.log(chalk.bold(DEN_HELP_DESCRIPTION));
|
|
385
|
+
console.log(chalk.gray(DEN_DIVIDER));
|
|
386
|
+
console.log();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Main CLI loop
|
|
391
|
+
*/
|
|
392
|
+
async run(): Promise<void> {
|
|
393
|
+
// Parse command line arguments
|
|
394
|
+
const args = process.argv.slice(2);
|
|
395
|
+
|
|
396
|
+
// Dev-tooling telemetry: one `devtool.den.invocation` per CLI run. The
|
|
397
|
+
// exit hook captures every termination path (normal completion,
|
|
398
|
+
// process.exit, error) with the real exit code and writes the JSONL line
|
|
399
|
+
// synchronously. `telCmd` is always a REGISTERED command name (never raw
|
|
400
|
+
// argv — PII); it is refined once the args parse successfully.
|
|
401
|
+
const stopTimer = startTimer();
|
|
402
|
+
let telCmd = telemetryCommandName(args.find((a) => !a.startsWith("-")));
|
|
403
|
+
process.once("exit", (code) => {
|
|
404
|
+
recordInvocation({
|
|
405
|
+
command: telCmd,
|
|
406
|
+
surface: "cli",
|
|
407
|
+
durationMs: stopTimer(),
|
|
408
|
+
exit: code,
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
if (args.length > 0) {
|
|
413
|
+
try {
|
|
414
|
+
const parsed = parseCliArgs(args);
|
|
415
|
+
telCmd = telemetryCommandName(parsed.command);
|
|
416
|
+
this.applyGlobalFlags(parsed);
|
|
417
|
+
this.prepareServiceCommand(parsed.command);
|
|
418
|
+
await this.handleCommand(
|
|
419
|
+
parsed.command ? [parsed.command, ...parsed.args] : [],
|
|
420
|
+
);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
recordFailure({
|
|
423
|
+
command: telCmd,
|
|
424
|
+
surface: "cli",
|
|
425
|
+
reason: telemetryReason(error),
|
|
426
|
+
durationMs: stopTimer(),
|
|
427
|
+
});
|
|
428
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}`));
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Bare `den` shows help. The interactive menu is `den menu`
|
|
435
|
+
// (what `make test` / `npm run cli` invoke).
|
|
436
|
+
this.displayHelp();
|
|
437
|
+
process.exit(0);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Handle command line arguments
|
|
442
|
+
*/
|
|
443
|
+
private applyGlobalFlags(parsed: ParsedCliArgs): void {
|
|
444
|
+
if (parsed.flags.workspace) {
|
|
445
|
+
const file = readWorkspacesFile();
|
|
446
|
+
if (!file.workspaces[parsed.flags.workspace]) {
|
|
447
|
+
throw new Error(`Workspace "${parsed.flags.workspace}" does not exist`);
|
|
448
|
+
}
|
|
449
|
+
if (
|
|
450
|
+
workspaceFlagRequiresActive(parsed.command ?? "") &&
|
|
451
|
+
!isWorkspaceActive(parsed.flags.workspace)
|
|
452
|
+
) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`Workspace "${parsed.flags.workspace}" is not active — run \`${product().cli} workspace activate ${parsed.flags.workspace}\` first`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
process.env.DEN_WORKSPACE_OVERRIDE = parsed.flags.workspace;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (parsed.flags.forceTabs !== undefined) {
|
|
461
|
+
process.env.DEN_FORCE_TABS = parsed.flags.forceTabs ? "1" : "0";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (parsed.flags.skipSecrets) {
|
|
465
|
+
process.env.DEN_SKIP_SECRETS = "1";
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private prepareServiceCommand(command: string | undefined): void {
|
|
470
|
+
if (!command || !DenCLI.serviceCommands.has(command)) return;
|
|
471
|
+
ensureDaemonRegistryLoaded();
|
|
472
|
+
cleanupStaleTabs();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private async handleCommand(args: string[]): Promise<void> {
|
|
476
|
+
const command = args[0];
|
|
477
|
+
|
|
478
|
+
// Args before the first `--` terminator are den's own; everything
|
|
479
|
+
// after it is passthrough intended for a child command.
|
|
480
|
+
const terminator = args.indexOf("--");
|
|
481
|
+
const ownArgs = terminator === -1 ? args : args.slice(0, terminator);
|
|
482
|
+
|
|
483
|
+
// `<command> -h/--help` → that command's help (every command supports it).
|
|
484
|
+
// (`-h`/`--help` AS the command falls through to the switch → global help.)
|
|
485
|
+
if (
|
|
486
|
+
command &&
|
|
487
|
+
!["-h", "--help", "-v", "--version"].includes(command) &&
|
|
488
|
+
(ownArgs.includes("-h") || ownArgs.includes("--help"))
|
|
489
|
+
) {
|
|
490
|
+
this.printCommandHelp(command);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Mutation subcommands must not silently ignore a mistyped flag — a
|
|
495
|
+
// typo'd --dry-run would otherwise perform a REAL bring-up.
|
|
496
|
+
if (command) {
|
|
497
|
+
const unknown = unknownCommandFlags(command, args.slice(1));
|
|
498
|
+
if (unknown.length > 0) {
|
|
499
|
+
console.log(
|
|
500
|
+
chalk.red(
|
|
501
|
+
`Unknown flag${unknown.length > 1 ? "s" : ""} for '${command}': ${unknown.join(", ")}`,
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
console.log(
|
|
505
|
+
chalk.gray(
|
|
506
|
+
`Run \`${product().cli} ${command} --help\` for accepted flags`,
|
|
507
|
+
),
|
|
508
|
+
);
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
switch (command) {
|
|
515
|
+
case "run":
|
|
516
|
+
await this.runTestsCommand(args.slice(1));
|
|
517
|
+
break;
|
|
518
|
+
|
|
519
|
+
case "view-results":
|
|
520
|
+
await this.viewResultsCommand();
|
|
521
|
+
break;
|
|
522
|
+
|
|
523
|
+
case "menu":
|
|
524
|
+
// Interactive menu (pillar/module/env picker). inquirer holds stdin
|
|
525
|
+
// open after the last prompt, so exit explicitly when it returns.
|
|
526
|
+
await this.interactiveMode();
|
|
527
|
+
process.exit(0);
|
|
528
|
+
break;
|
|
529
|
+
|
|
530
|
+
case "web":
|
|
531
|
+
await cmdWeb(args.slice(1));
|
|
532
|
+
break;
|
|
533
|
+
|
|
534
|
+
// ── Environment manager commands ──────────────────────────────────
|
|
535
|
+
case "up":
|
|
536
|
+
await this.cmdServiceUp(args.slice(1));
|
|
537
|
+
break;
|
|
538
|
+
|
|
539
|
+
case "down":
|
|
540
|
+
await this.cmdServiceDown(args.slice(1));
|
|
541
|
+
break;
|
|
542
|
+
|
|
543
|
+
case "restart":
|
|
544
|
+
await this.cmdServiceRestart(args.slice(1));
|
|
545
|
+
break;
|
|
546
|
+
|
|
547
|
+
case "status":
|
|
548
|
+
await this.cmdServiceStatus(args.slice(1));
|
|
549
|
+
break;
|
|
550
|
+
|
|
551
|
+
case "logs":
|
|
552
|
+
await this.cmdLogs(args.slice(1));
|
|
553
|
+
break;
|
|
554
|
+
|
|
555
|
+
case "services":
|
|
556
|
+
await this.cmdListServices();
|
|
557
|
+
break;
|
|
558
|
+
|
|
559
|
+
case "graph":
|
|
560
|
+
await this.ensureWorkspaceSeeded();
|
|
561
|
+
await cmdGraph(args.slice(1));
|
|
562
|
+
break;
|
|
563
|
+
|
|
564
|
+
case "register":
|
|
565
|
+
await this.cmdRegister(args.slice(1));
|
|
566
|
+
break;
|
|
567
|
+
|
|
568
|
+
case "use-local":
|
|
569
|
+
await this.cmdUseMode(args.slice(1), "dev-managed");
|
|
570
|
+
break;
|
|
571
|
+
|
|
572
|
+
case `use-managed`:
|
|
573
|
+
await this.cmdUseMode(args.slice(1), "managed");
|
|
574
|
+
break;
|
|
575
|
+
|
|
576
|
+
case "setup":
|
|
577
|
+
await this.cmdSetup(args.slice(1));
|
|
578
|
+
break;
|
|
579
|
+
|
|
580
|
+
case "workspace":
|
|
581
|
+
await this.cmdWorkspace(args.slice(1));
|
|
582
|
+
break;
|
|
583
|
+
|
|
584
|
+
case "sync":
|
|
585
|
+
await this.cmdSync(args.slice(1));
|
|
586
|
+
break;
|
|
587
|
+
|
|
588
|
+
case "init": {
|
|
589
|
+
// In the den root, `init` seeds/resets the default workspace; pointed
|
|
590
|
+
// at a service repo it scaffolds that repo's .den.yml (manifest init).
|
|
591
|
+
const initTarget = args[1] ?? getInvokeCwd();
|
|
592
|
+
if (nodePath.resolve(initTarget) === getDistRoot()) {
|
|
593
|
+
await this.seedDefaultWorkspaceFlow("init");
|
|
594
|
+
} else {
|
|
595
|
+
await runInit(initTarget);
|
|
596
|
+
}
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
case "validate":
|
|
601
|
+
await this.cmdValidate(args.slice(1));
|
|
602
|
+
break;
|
|
603
|
+
|
|
604
|
+
case "create": {
|
|
605
|
+
// bin/den routes `create` to its own entry (it needs no den.yml);
|
|
606
|
+
// this covers the CLI run directly from a checkout.
|
|
607
|
+
const code = runCreate(args.slice(1), getInvokeCwd());
|
|
608
|
+
if (code !== 0) process.exit(code);
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
case "local-dev":
|
|
613
|
+
await this.cmdLocalDev(args[1]);
|
|
614
|
+
break;
|
|
615
|
+
|
|
616
|
+
case "config":
|
|
617
|
+
await cmdConfig(args.slice(1));
|
|
618
|
+
break;
|
|
619
|
+
|
|
620
|
+
case "edge":
|
|
621
|
+
await cmdEdge(args.slice(1));
|
|
622
|
+
break;
|
|
623
|
+
|
|
624
|
+
case "stack":
|
|
625
|
+
await cmdStack(args.slice(1));
|
|
626
|
+
break;
|
|
627
|
+
|
|
628
|
+
case "apps":
|
|
629
|
+
case "applications":
|
|
630
|
+
await this.ensureWorkspaceSeeded();
|
|
631
|
+
cmdApps(args.slice(1));
|
|
632
|
+
break;
|
|
633
|
+
|
|
634
|
+
case "--help":
|
|
635
|
+
case "-h":
|
|
636
|
+
this.displayHelp();
|
|
637
|
+
break;
|
|
638
|
+
|
|
639
|
+
case "--version":
|
|
640
|
+
case "-v":
|
|
641
|
+
this.displayVersion();
|
|
642
|
+
break;
|
|
643
|
+
|
|
644
|
+
default:
|
|
645
|
+
console.log(chalk.red(`Unknown command: ${command}`));
|
|
646
|
+
console.log(chalk.gray("Run with --help for usage information"));
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
} catch (error) {
|
|
650
|
+
recordFailure({
|
|
651
|
+
command: telemetryCommandName(command),
|
|
652
|
+
surface: "cli",
|
|
653
|
+
reason: telemetryReason(error),
|
|
654
|
+
});
|
|
655
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}`));
|
|
656
|
+
process.exit(1);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Interactive mode
|
|
662
|
+
*/
|
|
663
|
+
private async interactiveMode(): Promise<void> {
|
|
664
|
+
let running = true;
|
|
665
|
+
|
|
666
|
+
while (running) {
|
|
667
|
+
try {
|
|
668
|
+
const action = await cliPrompts.promptForMainAction();
|
|
669
|
+
|
|
670
|
+
switch (action) {
|
|
671
|
+
case "run":
|
|
672
|
+
await this.runTestsInteractive();
|
|
673
|
+
break;
|
|
674
|
+
|
|
675
|
+
case "results":
|
|
676
|
+
await this.openHtmlReport();
|
|
677
|
+
break;
|
|
678
|
+
|
|
679
|
+
case "exit":
|
|
680
|
+
running = false;
|
|
681
|
+
console.log(chalk.cyan("\n👋 Goodbye!\n"));
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if ((error as { isTtyError?: boolean }).isTtyError) {
|
|
686
|
+
console.error(
|
|
687
|
+
chalk.red("Prompt could not be rendered in this environment"),
|
|
688
|
+
);
|
|
689
|
+
process.exit(1);
|
|
690
|
+
} else {
|
|
691
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Run tests (interactive)
|
|
699
|
+
*/
|
|
700
|
+
private async runTestsInteractive(): Promise<void> {
|
|
701
|
+
const pillar = await cliPrompts.promptForPillar();
|
|
702
|
+
|
|
703
|
+
// Ask which module to test (works for all pillars)
|
|
704
|
+
const module = await cliPrompts.promptForModule(pillar);
|
|
705
|
+
|
|
706
|
+
// Handle "Back" selection — return to main menu
|
|
707
|
+
if (module === "back") {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// For smoke: if a specific service was chosen (not "all"), ask for collection
|
|
712
|
+
let smokeCollection: string | undefined;
|
|
713
|
+
if (pillar === "smoke" && module !== null) {
|
|
714
|
+
const collection = await cliPrompts.promptForSmokeCollection(module);
|
|
715
|
+
if (collection === "back") {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
smokeCollection = collection ?? undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const environment = await cliPrompts.promptForEnvironment();
|
|
722
|
+
|
|
723
|
+
// Verify environment config exists
|
|
724
|
+
try {
|
|
725
|
+
await envSelector.loadEnvironment(environment);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
console.log(
|
|
728
|
+
chalk.red(`\n❌ Environment config not found for: ${environment}\n`),
|
|
729
|
+
);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Run tests
|
|
734
|
+
try {
|
|
735
|
+
const modules =
|
|
736
|
+
pillar === "performance"
|
|
737
|
+
? resolvePerformanceModules({
|
|
738
|
+
availableModules: await moduleScanner.scanModules("performance"),
|
|
739
|
+
selectedModule: module,
|
|
740
|
+
})
|
|
741
|
+
: module
|
|
742
|
+
? [module]
|
|
743
|
+
: undefined;
|
|
744
|
+
|
|
745
|
+
await testRunner.runTests({
|
|
746
|
+
pillar,
|
|
747
|
+
environment,
|
|
748
|
+
modules,
|
|
749
|
+
smokeCollection,
|
|
750
|
+
baseApiUrl: resolveCliBaseApiUrl(),
|
|
751
|
+
});
|
|
752
|
+
} catch (error) {
|
|
753
|
+
console.error(
|
|
754
|
+
chalk.red(`\n❌ Test execution failed: ${(error as Error).message}\n`),
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Run tests (command)
|
|
761
|
+
*/
|
|
762
|
+
private async runTestsCommand(args: string[]): Promise<void> {
|
|
763
|
+
// Parse arguments
|
|
764
|
+
const pillarArg = args.find((arg) => arg.startsWith("--pillar="));
|
|
765
|
+
const envArg = args.find((arg) => arg.startsWith("--environment="));
|
|
766
|
+
const moduleArgs = args
|
|
767
|
+
.filter((arg) => arg.startsWith("--module="))
|
|
768
|
+
.map((arg) => arg.split("=")[1])
|
|
769
|
+
.filter(Boolean);
|
|
770
|
+
const repoArg = args.find((arg) => arg.startsWith("--repo="));
|
|
771
|
+
const baseApiUrlArg = args.find((arg) => arg.startsWith("--baseApiUrl="));
|
|
772
|
+
const useLocalCheckout = args.includes("--use-local-checkout");
|
|
773
|
+
|
|
774
|
+
if (pillarArg && envArg) {
|
|
775
|
+
const pillar = pillarArg.split("=")[1] as Pillar;
|
|
776
|
+
const environment = envArg.split("=")[1] as Environment;
|
|
777
|
+
const baseApiUrl = baseApiUrlArg?.split("=")[1];
|
|
778
|
+
|
|
779
|
+
let modules: string[] | undefined;
|
|
780
|
+
if (pillar === "performance") {
|
|
781
|
+
if (moduleArgs.length > 0) {
|
|
782
|
+
modules = moduleArgs;
|
|
783
|
+
} else {
|
|
784
|
+
modules = resolvePerformanceModules({
|
|
785
|
+
availableModules: await moduleScanner.scanModules("performance"),
|
|
786
|
+
selectedModule: null,
|
|
787
|
+
repoName: repoArg?.split("=")[1] ?? null,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
} else {
|
|
791
|
+
modules = moduleArgs.length > 0 ? moduleArgs : undefined;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Run tests
|
|
795
|
+
try {
|
|
796
|
+
await testRunner.runTests({
|
|
797
|
+
pillar,
|
|
798
|
+
environment,
|
|
799
|
+
modules,
|
|
800
|
+
baseApiUrl: resolveCliBaseApiUrl(baseApiUrl),
|
|
801
|
+
useLocalCheckout,
|
|
802
|
+
});
|
|
803
|
+
} catch (error) {
|
|
804
|
+
console.error(
|
|
805
|
+
chalk.red(
|
|
806
|
+
`\n❌ Test execution failed: ${(error as Error).message}\n`,
|
|
807
|
+
),
|
|
808
|
+
);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// No arguments, go interactive
|
|
816
|
+
await this.runTestsInteractive();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Open HTML report
|
|
821
|
+
*/
|
|
822
|
+
private async openHtmlReport(): Promise<void> {
|
|
823
|
+
console.log(chalk.cyan("\n📊 Opening interactive test report...\n"));
|
|
824
|
+
|
|
825
|
+
const { spawn } = await import("child_process");
|
|
826
|
+
|
|
827
|
+
const reportProcess = spawn(
|
|
828
|
+
engineBin("playwright"),
|
|
829
|
+
["show-report", "playwright-report"],
|
|
830
|
+
{
|
|
831
|
+
cwd: getDistRoot(),
|
|
832
|
+
stdio: "inherit",
|
|
833
|
+
},
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
await new Promise<void>((resolve, reject) => {
|
|
837
|
+
reportProcess.on("close", (code) => {
|
|
838
|
+
if (code === 0) {
|
|
839
|
+
resolve();
|
|
840
|
+
} else if (code === 1) {
|
|
841
|
+
console.log(chalk.yellow("\n⚠️ No test report found."));
|
|
842
|
+
console.log(chalk.gray("Run some tests first:\n"));
|
|
843
|
+
console.log(chalk.cyan(" make test-basic # Run basic tests"));
|
|
844
|
+
console.log(chalk.cyan(" make test-auth # Run auth tests"));
|
|
845
|
+
console.log(
|
|
846
|
+
chalk.cyan(" make test # Interactive test runner\n"),
|
|
847
|
+
);
|
|
848
|
+
resolve();
|
|
849
|
+
} else {
|
|
850
|
+
reject(new Error(`Report viewer exited with code ${code}`));
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
reportProcess.on("error", (error) => {
|
|
855
|
+
reject(error);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* View results (interactive)
|
|
862
|
+
*/
|
|
863
|
+
private async viewResultsInteractive(): Promise<void> {
|
|
864
|
+
const days = await cliPrompts.promptForResultsDays();
|
|
865
|
+
|
|
866
|
+
const spinner = ora("Loading results...").start();
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
spinner.stop();
|
|
870
|
+
await resultsViewer.displaySummary(days);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
spinner.fail(chalk.red("Failed to load results"));
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* View results (command)
|
|
879
|
+
*/
|
|
880
|
+
private async viewResultsCommand(): Promise<void> {
|
|
881
|
+
await this.viewResultsInteractive();
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ── Environment manager: interactive sub-menu ──────────────────────────────
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Clone any managed repos in the active workspace that don't exist on disk yet.
|
|
888
|
+
* Works from workspace config + catalog only — no manifest needed — so it's safe
|
|
889
|
+
* to call before manifests are readable (i.e. the bootstrap / setup scenario).
|
|
890
|
+
*/
|
|
891
|
+
private cloneMissingManagedRepos(): void {
|
|
892
|
+
const ws = getActiveWorkspace();
|
|
893
|
+
if (!ws) return;
|
|
894
|
+
const catalog = readCatalog();
|
|
895
|
+
|
|
896
|
+
for (const [address, wsEntry] of Object.entries(ws.config.services)) {
|
|
897
|
+
if (wsEntry.mode !== "managed") continue;
|
|
898
|
+
const { repoName } = parseAddress(address);
|
|
899
|
+
const catalogEntry = catalog[repoName];
|
|
900
|
+
if (!catalogEntry) continue;
|
|
901
|
+
const branch = wsEntry.branch ?? "main";
|
|
902
|
+
try {
|
|
903
|
+
ensureManagedServiceClone(repoName, catalogEntry.gh, branch);
|
|
904
|
+
} catch (err) {
|
|
905
|
+
console.log(
|
|
906
|
+
chalk.red(
|
|
907
|
+
` ✗ Failed to clone ${repoName}: ${(err as Error).message}`,
|
|
908
|
+
),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* `den up --pull`: fast-forward managed clones in the start set
|
|
916
|
+
* before bringing them up. Dev-managed repos are the developer's own checkout
|
|
917
|
+
* and are never touched. Missing clones are skipped (they get freshly cloned
|
|
918
|
+
* anyway). Fast-forward-only never clobbers — a diverged clone is reported and
|
|
919
|
+
* left as-is, and the up proceeds on whatever commit is checked out.
|
|
920
|
+
*/
|
|
921
|
+
private pullManagedClonesFor(repoNames: string[]): void {
|
|
922
|
+
const ws = getActiveWorkspace();
|
|
923
|
+
if (!ws) return;
|
|
924
|
+
const want = new Set(repoNames);
|
|
925
|
+
const seen = new Set<string>();
|
|
926
|
+
let any = false;
|
|
927
|
+
for (const [address, wsEntry] of Object.entries(ws.config.services)) {
|
|
928
|
+
if (wsEntry.mode !== "managed") continue;
|
|
929
|
+
const { repoName } = parseAddress(address);
|
|
930
|
+
if (!want.has(repoName) || seen.has(repoName)) continue;
|
|
931
|
+
seen.add(repoName);
|
|
932
|
+
const branch = wsEntry.branch ?? "main";
|
|
933
|
+
const cloneDir = nodePath.join(DEN_SERVICES_DIR, repoName, branch);
|
|
934
|
+
if (!fs.existsSync(cloneDir)) continue; // missing → freshly cloned below
|
|
935
|
+
any = true;
|
|
936
|
+
const spinner = ora(`pull ${chalk.bold(repoName)}...`).start();
|
|
937
|
+
const r = fetchAndFastForward(cloneDir);
|
|
938
|
+
if (r.pulled) {
|
|
939
|
+
spinner.succeed(
|
|
940
|
+
`pulled ${chalk.bold(repoName)} (+${r.behind} commit${r.behind === 1 ? "" : "s"})`,
|
|
941
|
+
);
|
|
942
|
+
} else if (r.behind === 0) {
|
|
943
|
+
spinner.succeed(`${chalk.bold(repoName)} up to date`);
|
|
944
|
+
} else if (r.behind === -1) {
|
|
945
|
+
spinner.warn(
|
|
946
|
+
`${chalk.bold(repoName)} — no upstream / unreachable, skipped`,
|
|
947
|
+
);
|
|
948
|
+
} else {
|
|
949
|
+
spinner.fail(
|
|
950
|
+
`pull ${chalk.bold(repoName)} — ${r.behind} behind but fast-forward failed`,
|
|
951
|
+
);
|
|
952
|
+
console.log(
|
|
953
|
+
chalk.yellow(
|
|
954
|
+
` Diverged or local changes in ${cloneDir}. Resolve manually ` +
|
|
955
|
+
`(or \`${product().cli} sync\`); starting on the current commit.`,
|
|
956
|
+
),
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (any) console.log();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Ensure the managed repos in `repoNames` are cloned AND set up before
|
|
965
|
+
* we try to run their `make up`. Only clones repos whose clone dir is MISSING —
|
|
966
|
+
* never pulls an existing clone (that's `den sync`'s job; an implicit pull
|
|
967
|
+
* mid-up would be surprising and could fail on local changes). For each freshly
|
|
968
|
+
* cloned repo it runs setup hidden + non-interactive (mirroring the web UX)
|
|
969
|
+
* rather than popping a Terminal window. Returns the repo names that failed to
|
|
970
|
+
* clone or set up so the caller can fail-fast.
|
|
971
|
+
*/
|
|
972
|
+
private async ensureManagedClonesFor(repoNames: string[]): Promise<string[]> {
|
|
973
|
+
const ws = getActiveWorkspace();
|
|
974
|
+
if (!ws) return [];
|
|
975
|
+
const catalog = readCatalog();
|
|
976
|
+
const want = new Set(repoNames);
|
|
977
|
+
const handled = new Set<string>();
|
|
978
|
+
const failed: string[] = [];
|
|
979
|
+
// Repos that need setup run/re-run: freshly cloned, OR cloned previously but
|
|
980
|
+
// never verifiably set up (no success marker — e.g. a prior setup failed).
|
|
981
|
+
const needsSetup: { repoName: string; repoPath: string }[] = [];
|
|
982
|
+
|
|
983
|
+
for (const [address, wsEntry] of Object.entries(ws.config.services)) {
|
|
984
|
+
if (wsEntry.mode !== "managed") continue;
|
|
985
|
+
const { repoName } = parseAddress(address);
|
|
986
|
+
if (!want.has(repoName) || handled.has(repoName)) continue;
|
|
987
|
+
const branch = wsEntry.branch ?? "main";
|
|
988
|
+
const cloneDir = nodePath.join(DEN_SERVICES_DIR, repoName, branch);
|
|
989
|
+
handled.add(repoName);
|
|
990
|
+
|
|
991
|
+
if (fs.existsSync(cloneDir)) {
|
|
992
|
+
// Already cloned. Only re-run setup when it hasn't verifiably succeeded
|
|
993
|
+
// (no marker) AND the repo actually has a setup command — so a clone
|
|
994
|
+
// whose setup previously failed is repaired instead of started broken.
|
|
995
|
+
if (!hasSetupSucceeded(cloneDir) && hasSetupCommand(cloneDir)) {
|
|
996
|
+
needsSetup.push({ repoName, repoPath: cloneDir });
|
|
997
|
+
}
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Catalog entries written by the dev-managed add flow carry `gh: ""`, so
|
|
1002
|
+
// an entry can exist yet be unclonable. Derive the conventional slug and
|
|
1003
|
+
// persist the repair rather than cloning from an empty URL.
|
|
1004
|
+
const ghSlug = resolveRepoGhSlug(repoName, catalog);
|
|
1005
|
+
if (!catalog[repoName]?.gh) upsertCatalogEntry(repoName, { gh: ghSlug });
|
|
1006
|
+
try {
|
|
1007
|
+
// runSetup=false: clone only — setup is run below, hidden + non-interactive.
|
|
1008
|
+
ensureManagedServiceClone(repoName, ghSlug, branch, false);
|
|
1009
|
+
// Fill placeholder secrets from another checkout of the same repo
|
|
1010
|
+
// before setup runs — see seedSecretsFromSibling.
|
|
1011
|
+
const seeded = seedSecretsFromSibling(repoName, cloneDir);
|
|
1012
|
+
if (seeded) {
|
|
1013
|
+
console.log(
|
|
1014
|
+
chalk.gray(
|
|
1015
|
+
` ↳ filled ${seeded.filled.length} local secret(s) from ${seeded.donorPath}: ${seeded.filled.join(", ")}`,
|
|
1016
|
+
),
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
needsSetup.push({ repoName, repoPath: cloneDir });
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
console.log(
|
|
1022
|
+
chalk.red(
|
|
1023
|
+
` ✗ Failed to clone ${repoName}: ${(err as Error).message}`,
|
|
1024
|
+
),
|
|
1025
|
+
);
|
|
1026
|
+
failed.push(repoName);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Run setup non-interactively (no Terminal windows). The non-interactive env
|
|
1031
|
+
// (CI/HUSKY/git creds) lets it run unattended; GUI auth (1Password Touch ID)
|
|
1032
|
+
// may still surface. On failure, surface the actual error output (the tail,
|
|
1033
|
+
// including stderr) so the user sees WHY — not just a bare exit code — and
|
|
1034
|
+
// record nothing, so the next `den up` retries instead of starting broken.
|
|
1035
|
+
// If it stalled on an interactive prompt, offer an in-terminal re-run so the
|
|
1036
|
+
// user can answer it without leaving `den up`.
|
|
1037
|
+
for (const { repoName, repoPath } of needsSetup) {
|
|
1038
|
+
const spinner = ora(`setup ${chalk.bold(repoName)}...`).start();
|
|
1039
|
+
const tail: string[] = [];
|
|
1040
|
+
const { code, stalledPrompt } = await runSetupHidden(repoPath, (line) => {
|
|
1041
|
+
tail.push(line);
|
|
1042
|
+
if (tail.length > 25) tail.shift();
|
|
1043
|
+
spinner.text = `setup ${chalk.bold(repoName)}: ${chalk.gray(line.slice(0, 80))}`;
|
|
1044
|
+
});
|
|
1045
|
+
if (code === 0) {
|
|
1046
|
+
markSetupSucceeded(repoPath);
|
|
1047
|
+
spinner.succeed(`setup ${chalk.bold(repoName)}`);
|
|
1048
|
+
} else if (stalledPrompt) {
|
|
1049
|
+
spinner.fail(
|
|
1050
|
+
`setup ${chalk.bold(repoName)} needs an interactive terminal`,
|
|
1051
|
+
);
|
|
1052
|
+
// The hidden child is already dead (stall guard killed its process
|
|
1053
|
+
// group) and setups run one at a time in the foreground, so the
|
|
1054
|
+
// terminal is free. The confirm auto-declines after a timeout so an
|
|
1055
|
+
// unattended pty can never hang `up` (the guarantee d64bacb made).
|
|
1056
|
+
const recovered = await recoverStalledSetup(
|
|
1057
|
+
repoName,
|
|
1058
|
+
repoPath,
|
|
1059
|
+
stalledPrompt,
|
|
1060
|
+
{
|
|
1061
|
+
isInteractive: () =>
|
|
1062
|
+
!!(process.stdin.isTTY && process.stdout.isTTY),
|
|
1063
|
+
confirm: (message) =>
|
|
1064
|
+
confirmWithTimeout(message, STALLED_SETUP_CONFIRM_TIMEOUT_MS),
|
|
1065
|
+
runSetupInteractive,
|
|
1066
|
+
markSetupSucceeded,
|
|
1067
|
+
log: (line) => console.log(line),
|
|
1068
|
+
},
|
|
1069
|
+
);
|
|
1070
|
+
if (!recovered) failed.push(repoName);
|
|
1071
|
+
} else {
|
|
1072
|
+
spinner.fail(`setup ${chalk.bold(repoName)} failed (exit ${code})`);
|
|
1073
|
+
console.log(
|
|
1074
|
+
chalk.red(`\n ── setup output for ${repoName} (last lines) ──`),
|
|
1075
|
+
);
|
|
1076
|
+
for (const l of tail) console.log(chalk.gray(` │ ${l}`));
|
|
1077
|
+
console.log(
|
|
1078
|
+
chalk.red(` ───────────────────────────────────────────────`),
|
|
1079
|
+
);
|
|
1080
|
+
console.log(
|
|
1081
|
+
chalk.yellow(
|
|
1082
|
+
` Fix setup in ${repoPath}, then re-run \`${product().cli} up\` ` +
|
|
1083
|
+
`(or \`${product().cli} setup ${repoName}\`).\n`,
|
|
1084
|
+
),
|
|
1085
|
+
);
|
|
1086
|
+
failed.push(repoName);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return failed;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** The workspace a command acts on: -w override, else the focused one. */
|
|
1094
|
+
private focusedWs(): string {
|
|
1095
|
+
return (
|
|
1096
|
+
process.env.DEN_WORKSPACE_OVERRIDE ??
|
|
1097
|
+
getFocusedWorkspaceName() ??
|
|
1098
|
+
"default"
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** Return addresses from the workspace that have the given local command in their manifest. */
|
|
1103
|
+
private workspaceAddressesWithCommand(command: string): string[] {
|
|
1104
|
+
return getWorkspaceServices()
|
|
1105
|
+
.filter((s) => !!localCommandsFor(s, this.focusedWs())[command])
|
|
1106
|
+
.map((s) => s.address);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private async manageWorkspacesInteractive(): Promise<void> {
|
|
1110
|
+
let running = true;
|
|
1111
|
+
while (running) {
|
|
1112
|
+
const action = await cliPrompts.promptForWorkspaceAction();
|
|
1113
|
+
const file = readWorkspacesFile();
|
|
1114
|
+
|
|
1115
|
+
switch (action) {
|
|
1116
|
+
case "list":
|
|
1117
|
+
await this.cmdWorkspace(["list"]);
|
|
1118
|
+
break;
|
|
1119
|
+
case "use": {
|
|
1120
|
+
const names = Object.keys(file.workspaces);
|
|
1121
|
+
const selected = await cliPrompts.promptSelectWorkspace(
|
|
1122
|
+
names,
|
|
1123
|
+
file.focus ?? file.active[0],
|
|
1124
|
+
);
|
|
1125
|
+
setActiveWorkspace(selected);
|
|
1126
|
+
console.log(
|
|
1127
|
+
chalk.green(
|
|
1128
|
+
`\n ✓ Activated and focused workspace: ${chalk.bold(selected)}\n`,
|
|
1129
|
+
),
|
|
1130
|
+
);
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
case "create": {
|
|
1134
|
+
const name = await cliPrompts.promptForWorkspaceName(
|
|
1135
|
+
Object.keys(file.workspaces),
|
|
1136
|
+
);
|
|
1137
|
+
createWorkspace(name, { services: {} });
|
|
1138
|
+
const activate = await cliPrompts.confirm(
|
|
1139
|
+
`Switch to workspace "${name}" now?`,
|
|
1140
|
+
true,
|
|
1141
|
+
);
|
|
1142
|
+
if (activate) setActiveWorkspace(name);
|
|
1143
|
+
console.log(
|
|
1144
|
+
chalk.green(`\n ✓ Created workspace: ${chalk.bold(name)}\n`),
|
|
1145
|
+
);
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
case "add-service":
|
|
1149
|
+
// Register handles the full flow: path → gh slug → mode/branch → workspace entry
|
|
1150
|
+
await this.cmdRegister([]);
|
|
1151
|
+
break;
|
|
1152
|
+
case "remove-service": {
|
|
1153
|
+
const ws = getActiveWorkspace();
|
|
1154
|
+
if (!ws) {
|
|
1155
|
+
console.log(chalk.red("\n No active workspace.\n"));
|
|
1156
|
+
break;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const existing = Object.keys(ws.config.services);
|
|
1160
|
+
if (existing.length === 0) {
|
|
1161
|
+
console.log(chalk.yellow("\n No services in this workspace.\n"));
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const selected = await cliPrompts.promptSelectServices(
|
|
1166
|
+
existing,
|
|
1167
|
+
`Remove from workspace "${ws.name}":`,
|
|
1168
|
+
);
|
|
1169
|
+
if (selected.length === 0) break;
|
|
1170
|
+
|
|
1171
|
+
const updatedFile = readWorkspacesFile();
|
|
1172
|
+
for (const addr of selected) {
|
|
1173
|
+
delete updatedFile.workspaces[ws.name].services[addr];
|
|
1174
|
+
}
|
|
1175
|
+
writeWorkspacesFile(updatedFile);
|
|
1176
|
+
console.log(
|
|
1177
|
+
chalk.green(
|
|
1178
|
+
`\n ✓ Removed ${selected.length} service(s) from ${ws.name}\n`,
|
|
1179
|
+
),
|
|
1180
|
+
);
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1183
|
+
case "delete": {
|
|
1184
|
+
const names = Object.keys(file.workspaces).filter(
|
|
1185
|
+
(n) => n !== "default",
|
|
1186
|
+
);
|
|
1187
|
+
if (names.length === 0) {
|
|
1188
|
+
console.log(
|
|
1189
|
+
chalk.yellow(
|
|
1190
|
+
"\n No workspaces to delete (cannot delete default).\n",
|
|
1191
|
+
),
|
|
1192
|
+
);
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
const name = await cliPrompts.promptSelectWorkspace(names, "");
|
|
1196
|
+
const confirmed = await cliPrompts.confirm(
|
|
1197
|
+
`Delete workspace "${name}"?`,
|
|
1198
|
+
false,
|
|
1199
|
+
);
|
|
1200
|
+
if (!confirmed) break;
|
|
1201
|
+
deleteWorkspaceDirectory(name);
|
|
1202
|
+
deleteWorkspaceConfig(name);
|
|
1203
|
+
console.log(chalk.green(`\n ✓ Deleted workspace: ${name}\n`));
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1206
|
+
case "back":
|
|
1207
|
+
running = false;
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// ── Environment manager: non-interactive commands ─────────────────────────
|
|
1214
|
+
|
|
1215
|
+
/** Run a named command (up/down/restart) on one or more service addresses. */
|
|
1216
|
+
private async runServicesCommand(
|
|
1217
|
+
addresses: string[],
|
|
1218
|
+
command: string,
|
|
1219
|
+
plan?: {
|
|
1220
|
+
/** Pre-computed run order (from the derived graph) — skips manifest topo. */
|
|
1221
|
+
order: string[];
|
|
1222
|
+
/** Hard deps to block on per address (derived), replacing manifest wait. */
|
|
1223
|
+
waitDepsFor?: (address: string) => string[];
|
|
1224
|
+
/** Abort the whole flow on the first failure (derived `up`). */
|
|
1225
|
+
failFast?: boolean;
|
|
1226
|
+
/** Skip ANY already-healthy node (derived `up` starts only what's down). */
|
|
1227
|
+
skipHealthyAll?: boolean;
|
|
1228
|
+
},
|
|
1229
|
+
opts?: { force?: boolean },
|
|
1230
|
+
): Promise<void> {
|
|
1231
|
+
// How long to show startup output before handing control back to the menu
|
|
1232
|
+
// for detachable (long-running) commands like `up`.
|
|
1233
|
+
const DETACH_WINDOW_MS = 4000;
|
|
1234
|
+
const workspace =
|
|
1235
|
+
process.env.DEN_WORKSPACE_OVERRIDE ??
|
|
1236
|
+
getFocusedWorkspaceName() ??
|
|
1237
|
+
undefined;
|
|
1238
|
+
const force = opts?.force ?? false;
|
|
1239
|
+
|
|
1240
|
+
// Resolve dependency order. Derived mode supplies the order directly;
|
|
1241
|
+
// otherwise topo-sort manifest depends_on (`down` → reverse).
|
|
1242
|
+
let ordered = addresses;
|
|
1243
|
+
if (plan) {
|
|
1244
|
+
ordered = plan.order;
|
|
1245
|
+
} else {
|
|
1246
|
+
try {
|
|
1247
|
+
const catalog = readCatalog();
|
|
1248
|
+
const ws = getActiveWorkspace();
|
|
1249
|
+
const startupOrder = ws
|
|
1250
|
+
? resolveStartupOrder(addresses, catalog, ws.config, ws.name)
|
|
1251
|
+
: addresses;
|
|
1252
|
+
if (command === "down") {
|
|
1253
|
+
ordered = [...startupOrder].reverse();
|
|
1254
|
+
} else {
|
|
1255
|
+
ordered = startupOrder;
|
|
1256
|
+
if (ordered.length > addresses.length) {
|
|
1257
|
+
const added = ordered.filter((a) => !addresses.includes(a));
|
|
1258
|
+
console.log(
|
|
1259
|
+
chalk.gray(`\n Including dependencies: ${added.join(", ")}\n`),
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
} catch (e: unknown) {
|
|
1264
|
+
console.log(chalk.red(`\n ⚠ ${(e as Error).message}\n`));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Build a catalog + workspace reference once per run for readiness lookups.
|
|
1270
|
+
const catalog = readCatalog();
|
|
1271
|
+
const ws = getActiveWorkspace();
|
|
1272
|
+
// Derived `up` treats every node as a dependency, so any already-healthy
|
|
1273
|
+
// node may be skipped; otherwise only non-seed deps are skip-eligible.
|
|
1274
|
+
const seedAddresses = plan?.skipHealthyAll
|
|
1275
|
+
? new Set<string>()
|
|
1276
|
+
: new Set(addresses);
|
|
1277
|
+
const runtime = resolveServiceRuntime(command);
|
|
1278
|
+
|
|
1279
|
+
// Fail-fast transparency: a partial abort intentionally leaves earlier
|
|
1280
|
+
// nodes running (they're often useful for debugging the failure) — but we
|
|
1281
|
+
// must SAY so, and post-detach service deaths must still trip fail-fast.
|
|
1282
|
+
const startedThisRun: string[] = [];
|
|
1283
|
+
const lateFailures = new Map<string, number>();
|
|
1284
|
+
const printPartialState = (): void => {
|
|
1285
|
+
if (startedThisRun.length === 0) return;
|
|
1286
|
+
console.log(
|
|
1287
|
+
chalk.yellow(
|
|
1288
|
+
` Left running from this run: ${startedThisRun.join(", ")}`,
|
|
1289
|
+
),
|
|
1290
|
+
);
|
|
1291
|
+
console.log(chalk.gray(` Stop them with: ${product().cli} down`));
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
for (const address of ordered) {
|
|
1295
|
+
// A previously-detached service that has since died non-zero defeats the
|
|
1296
|
+
// point of continuing — its dependents will fail on a missing dep.
|
|
1297
|
+
if (plan?.failFast && lateFailures.size > 0) {
|
|
1298
|
+
const [failedAddr, code] = [...lateFailures.entries()][0];
|
|
1299
|
+
console.log(
|
|
1300
|
+
chalk.red(
|
|
1301
|
+
` ✗ ${chalk.bold(failedAddr)} exited (${code}) after detaching — aborting (fail-fast).`,
|
|
1302
|
+
),
|
|
1303
|
+
);
|
|
1304
|
+
printPartialState();
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
if (command === "up" && !seedAddresses.has(address)) {
|
|
1308
|
+
const resolved = resolveService(address, workspace);
|
|
1309
|
+
if (resolved) {
|
|
1310
|
+
const health = await checkServiceHealth(resolved, "local");
|
|
1311
|
+
if (health.health === "ok") {
|
|
1312
|
+
// Skipping must not lose the refcount — this workspace is an owner
|
|
1313
|
+
// of what's running, whether den tracked it or not.
|
|
1314
|
+
const claim = workspace
|
|
1315
|
+
? claimHealthyService(resolved, workspace)
|
|
1316
|
+
: "untracked";
|
|
1317
|
+
const suffix =
|
|
1318
|
+
claim === "adopted"
|
|
1319
|
+
? " (adopted its running containers)"
|
|
1320
|
+
: claim === "attached"
|
|
1321
|
+
? ` (owned by "${workspace}" too)`
|
|
1322
|
+
: "";
|
|
1323
|
+
console.log(
|
|
1324
|
+
chalk.gray(
|
|
1325
|
+
` • ${chalk.bold(address)} already healthy — skipping dependency up${suffix}`,
|
|
1326
|
+
),
|
|
1327
|
+
);
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Block on any `wait: true` deps before starting this service on up/restart.
|
|
1334
|
+
if ((command === "up" || command === "restart") && ws) {
|
|
1335
|
+
const waitDeps = plan?.waitDepsFor
|
|
1336
|
+
? plan.waitDepsFor(address)
|
|
1337
|
+
: getWaitDeps(address, catalog, ws.config, ws.name);
|
|
1338
|
+
let readinessFailed = false;
|
|
1339
|
+
for (const depAddr of waitDeps) {
|
|
1340
|
+
const depResolved = resolveService(depAddr);
|
|
1341
|
+
if (!depResolved) {
|
|
1342
|
+
console.log(
|
|
1343
|
+
chalk.yellow(
|
|
1344
|
+
` ⚠ wait dep ${depAddr} not resolvable — skipping readiness check`,
|
|
1345
|
+
),
|
|
1346
|
+
);
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
const spinner = ora(
|
|
1350
|
+
`waiting for ${chalk.bold(depAddr)} to be ready…`,
|
|
1351
|
+
).start();
|
|
1352
|
+
const result = await waitForReady(depResolved, "local", {
|
|
1353
|
+
onTick: (detail) => {
|
|
1354
|
+
spinner.text = `waiting for ${chalk.bold(depAddr)}: ${chalk.gray(detail)}`;
|
|
1355
|
+
},
|
|
1356
|
+
});
|
|
1357
|
+
if (!result.ok) {
|
|
1358
|
+
spinner.fail(
|
|
1359
|
+
`${chalk.bold(depAddr)} not ready after ${Math.round(result.elapsedMs / 1000)}s (${result.detail ?? "timeout"})`,
|
|
1360
|
+
);
|
|
1361
|
+
readinessFailed = true;
|
|
1362
|
+
break;
|
|
1363
|
+
}
|
|
1364
|
+
spinner.succeed(
|
|
1365
|
+
result.skipped
|
|
1366
|
+
? `${chalk.bold(depAddr)} (no health check — proceeding)`
|
|
1367
|
+
: `${chalk.bold(depAddr)} ready (${Math.round(result.elapsedMs / 1000)}s)`,
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
if (readinessFailed) {
|
|
1371
|
+
console.log(
|
|
1372
|
+
chalk.red(
|
|
1373
|
+
` ✗ ${command} ${chalk.bold(address)} — blocked on readiness`,
|
|
1374
|
+
),
|
|
1375
|
+
);
|
|
1376
|
+
if (plan?.failFast) {
|
|
1377
|
+
console.log(
|
|
1378
|
+
chalk.red(
|
|
1379
|
+
` Aborting (fail-fast): a dependency did not become ready.`,
|
|
1380
|
+
),
|
|
1381
|
+
);
|
|
1382
|
+
printPartialState();
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const started = await new Promise<boolean>((resolve) => {
|
|
1390
|
+
const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
|
|
1391
|
+
let detachTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1392
|
+
let settled = false;
|
|
1393
|
+
// Keep the tail of the command's output. onLine only updates the
|
|
1394
|
+
// transient spinner text, so on failure the actual reason (e.g.
|
|
1395
|
+
// "could not read .den.yml in <path>") vanished with the spinner and
|
|
1396
|
+
// the user saw a bare "exit 1" — which is exactly how a broken fresh
|
|
1397
|
+
// checkout looked like an inexplicable mass failure.
|
|
1398
|
+
const lastLines: string[] = [];
|
|
1399
|
+
let result: ReturnType<typeof runServiceCommand> | null = null;
|
|
1400
|
+
let detachable = false;
|
|
1401
|
+
let interactive = false;
|
|
1402
|
+
|
|
1403
|
+
const settleFromClose = (exitCode: number): void => {
|
|
1404
|
+
if (settled) return;
|
|
1405
|
+
settled = true;
|
|
1406
|
+
if (detachTimer) clearTimeout(detachTimer);
|
|
1407
|
+
if (interactive) {
|
|
1408
|
+
// Reprint the result line after the interactive session ends.
|
|
1409
|
+
if (exitCode === 0) {
|
|
1410
|
+
console.log(chalk.green(` ✓ ${command} ${chalk.bold(address)}`));
|
|
1411
|
+
} else {
|
|
1412
|
+
console.log(
|
|
1413
|
+
chalk.red(
|
|
1414
|
+
` ✗ ${command} ${chalk.bold(address)} (exit ${exitCode})`,
|
|
1415
|
+
),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
} else if (exitCode === 0) {
|
|
1419
|
+
spinner.succeed(`${command} ${chalk.bold(address)}`);
|
|
1420
|
+
} else {
|
|
1421
|
+
spinner.fail(
|
|
1422
|
+
`${command} ${chalk.bold(address)} (exit ${exitCode})`,
|
|
1423
|
+
);
|
|
1424
|
+
for (const l of lastLines) {
|
|
1425
|
+
console.log(chalk.gray(` ${l}`));
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
resolve(exitCode === 0);
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
const settleAsBackground = (): void => {
|
|
1432
|
+
if (settled) return;
|
|
1433
|
+
settled = true;
|
|
1434
|
+
|
|
1435
|
+
// Detachable daemon mode returns a tail process for startup streaming.
|
|
1436
|
+
// Stop that tail only; the service itself was already daemonized.
|
|
1437
|
+
const kill = (
|
|
1438
|
+
result?.proc as
|
|
1439
|
+
| { kill?: (signal?: NodeJS.Signals) => boolean }
|
|
1440
|
+
| undefined
|
|
1441
|
+
)?.kill;
|
|
1442
|
+
try {
|
|
1443
|
+
kill?.call(result?.proc, "SIGTERM");
|
|
1444
|
+
} catch {
|
|
1445
|
+
/* best-effort tail cleanup */
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// The service process may have already died non-zero INSIDE the
|
|
1449
|
+
// detach window (onServiceExit) — that's a start failure, not a
|
|
1450
|
+
// background success.
|
|
1451
|
+
const lateExit = lateFailures.get(address);
|
|
1452
|
+
if (lateExit !== undefined) {
|
|
1453
|
+
spinner.fail(
|
|
1454
|
+
`${command} ${chalk.bold(address)} (service exited ${lateExit} during startup)`,
|
|
1455
|
+
);
|
|
1456
|
+
resolve(false);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
spinner.succeed(
|
|
1460
|
+
`${command} ${chalk.bold(address)} ${chalk.gray("(running in background)")}`,
|
|
1461
|
+
);
|
|
1462
|
+
resolve(true);
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
result = runServiceCommand({
|
|
1466
|
+
address,
|
|
1467
|
+
command,
|
|
1468
|
+
runtime,
|
|
1469
|
+
workspace,
|
|
1470
|
+
force: command === "down" ? force : false,
|
|
1471
|
+
onLine: (line) => {
|
|
1472
|
+
spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
|
|
1473
|
+
const trimmed = line.trim();
|
|
1474
|
+
if (trimmed) {
|
|
1475
|
+
lastLines.push(trimmed.slice(0, 160));
|
|
1476
|
+
if (lastLines.length > 3) lastLines.shift();
|
|
1477
|
+
}
|
|
1478
|
+
},
|
|
1479
|
+
onClose: (exitCode) => {
|
|
1480
|
+
settleFromClose(exitCode);
|
|
1481
|
+
},
|
|
1482
|
+
// Daemon mode settles via the detach timer + tail close — never the
|
|
1483
|
+
// service's real exit. Record non-zero service deaths so a failure
|
|
1484
|
+
// inside OR after the detach window still trips fail-fast.
|
|
1485
|
+
onServiceExit: (exitCode) => {
|
|
1486
|
+
if (exitCode !== 0 && (command === "up" || command === "restart")) {
|
|
1487
|
+
lateFailures.set(address, exitCode);
|
|
1488
|
+
}
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
if (settled) return;
|
|
1492
|
+
|
|
1493
|
+
detachable = result.detachable;
|
|
1494
|
+
interactive = result.interactive;
|
|
1495
|
+
|
|
1496
|
+
if (interactive) {
|
|
1497
|
+
// HITL — suspend the spinner immediately so the TTY is clean for user input.
|
|
1498
|
+
spinner.stop();
|
|
1499
|
+
console.log(
|
|
1500
|
+
chalk.cyan(
|
|
1501
|
+
`\n ↳ Interactive setup for ${chalk.bold(address)} — follow the prompts below:`,
|
|
1502
|
+
),
|
|
1503
|
+
);
|
|
1504
|
+
console.log(chalk.gray(" " + "─".repeat(60)));
|
|
1505
|
+
return; // onClose drives resolve()
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// Only use the detach timer for long-running startup commands.
|
|
1509
|
+
// `down` and all other teardown commands must run to completion before
|
|
1510
|
+
// moving to the next service — concurrent Docker teardowns conflict.
|
|
1511
|
+
if (detachable && (command === "up" || command === "restart")) {
|
|
1512
|
+
detachTimer = setTimeout(settleAsBackground, DETACH_WINDOW_MS);
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
if (plan?.failFast && !started) {
|
|
1517
|
+
console.log(
|
|
1518
|
+
chalk.red(
|
|
1519
|
+
` Aborting (fail-fast): ${chalk.bold(address)} failed to start.`,
|
|
1520
|
+
),
|
|
1521
|
+
);
|
|
1522
|
+
printPartialState();
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (started && (command === "up" || command === "restart")) {
|
|
1526
|
+
startedThisRun.push(address);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Post-run sweep: even without fail-fast, surface services that detached
|
|
1531
|
+
// "successfully" but whose process has since exited non-zero.
|
|
1532
|
+
if (lateFailures.size > 0) {
|
|
1533
|
+
for (const [addr, code] of lateFailures) {
|
|
1534
|
+
if (startedThisRun.includes(addr)) {
|
|
1535
|
+
console.log(
|
|
1536
|
+
chalk.yellow(
|
|
1537
|
+
` ⚠ ${chalk.bold(addr)} exited (${code}) after detaching — check its log (${product().cli} web → /logs).`,
|
|
1538
|
+
),
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
private resolveNoArgServiceTargets(
|
|
1546
|
+
command: "up" | "down" | "restart",
|
|
1547
|
+
): string[] {
|
|
1548
|
+
const ws = getActiveWorkspace();
|
|
1549
|
+
const catalog = readCatalog();
|
|
1550
|
+
if (ws) {
|
|
1551
|
+
const invocation = resolveInvocationTarget(ws.name, ws.config, catalog);
|
|
1552
|
+
if (invocation) {
|
|
1553
|
+
console.log(chalk.cyan(`\n Workspace: ${chalk.bold(ws.name)}`));
|
|
1554
|
+
console.log(chalk.gray(` Invocation repo: ${invocation.repoRoot}`));
|
|
1555
|
+
console.log(
|
|
1556
|
+
chalk.gray(` Resolved target: ${invocation.addresses.join(", ")}\n`),
|
|
1557
|
+
);
|
|
1558
|
+
return invocation.addresses;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (command === "down") {
|
|
1563
|
+
// Down stops everything in the workspace — auto-start only governs the
|
|
1564
|
+
// STARTING half of workspace-wide ops.
|
|
1565
|
+
return getWorkspaceServices().map((s) => s.address);
|
|
1566
|
+
}
|
|
1567
|
+
// up/restart with no resolvable target: nothing to start implicitly.
|
|
1568
|
+
return [];
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/** Repo-system name for the current working directory, if any. */
|
|
1572
|
+
private cwdSeedRepo(): string | undefined {
|
|
1573
|
+
const root = findInvocationRepoRoot();
|
|
1574
|
+
return root ? nodePath.basename(root) : undefined;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Resolve a derive seed: an explicit known-repo arg, else the cwd repo if it
|
|
1579
|
+
* is a known repo. Returns undefined (no logging) when neither applies — the
|
|
1580
|
+
* caller then falls back to the legacy manifest path.
|
|
1581
|
+
*/
|
|
1582
|
+
private derivedSeed(seedArg: string | undefined): string | undefined {
|
|
1583
|
+
const knownRepos = loadAliasResolver().knownRepos;
|
|
1584
|
+
const seed = seedArg ?? this.cwdSeedRepo();
|
|
1585
|
+
return seed && knownRepos.has(seed) ? seed : undefined;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/** Render the read-only `den up --plan` view: per-node command + flags. */
|
|
1589
|
+
private renderUpPlan(nodes: NodePlan[]): void {
|
|
1590
|
+
const tag = (n: NodePlan): string => {
|
|
1591
|
+
const parts: string[] = [];
|
|
1592
|
+
if (n.noAutostart)
|
|
1593
|
+
parts.push(chalk.yellow("auto-start off, not-started"));
|
|
1594
|
+
if (n.interactive)
|
|
1595
|
+
parts.push(
|
|
1596
|
+
chalk.red(
|
|
1597
|
+
n.interactiveKind
|
|
1598
|
+
? `interactive (${n.interactiveKind})`
|
|
1599
|
+
: "interactive",
|
|
1600
|
+
),
|
|
1601
|
+
);
|
|
1602
|
+
if (!n.upCommand) parts.push(chalk.red("no up command"));
|
|
1603
|
+
if (parts.length === 0) parts.push(chalk.green("headless"));
|
|
1604
|
+
return parts.join(" ");
|
|
1605
|
+
};
|
|
1606
|
+
console.log(chalk.bold(" Plan (no changes will be made):\n"));
|
|
1607
|
+
for (const n of nodes) {
|
|
1608
|
+
const willStart = n.defaultAction === "start";
|
|
1609
|
+
const marker = willStart ? chalk.green("●") : chalk.yellow("○");
|
|
1610
|
+
console.log(` ${marker} ${chalk.bold(n.repo)} [${tag(n)}]`);
|
|
1611
|
+
console.log(
|
|
1612
|
+
chalk.gray(
|
|
1613
|
+
` up: ${n.upCommand ?? "(none found)"} (${n.upSource})`,
|
|
1614
|
+
),
|
|
1615
|
+
);
|
|
1616
|
+
if (n.healthUrl) console.log(chalk.gray(` health: ${n.healthUrl}`));
|
|
1617
|
+
if (n.interactive)
|
|
1618
|
+
console.log(chalk.gray(` ⚠ ${n.interactiveReason}`));
|
|
1619
|
+
for (const note of n.notes) console.log(chalk.gray(` • ${note}`));
|
|
1620
|
+
}
|
|
1621
|
+
const ask = nodes.filter((n) => n.defaultAction === "skip");
|
|
1622
|
+
console.log();
|
|
1623
|
+
console.log(
|
|
1624
|
+
chalk.gray(
|
|
1625
|
+
` ● auto-start (headless) ○ needs a decision (optional/interactive) — ` +
|
|
1626
|
+
`${ask.length} node(s): ${ask.map((n) => n.repo).join(", ") || "none"}`,
|
|
1627
|
+
),
|
|
1628
|
+
);
|
|
1629
|
+
console.log(
|
|
1630
|
+
chalk.gray(
|
|
1631
|
+
` Run '${product().cli} up' to start; outliers are prompted (CLI) / toggled (web) / skipped (CI).\n`,
|
|
1632
|
+
),
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** Bring up the seed repo's derived dependency closure (hard-ordered, fail-fast). */
|
|
1637
|
+
private async runDerivedUp(
|
|
1638
|
+
seed: string,
|
|
1639
|
+
include: boolean,
|
|
1640
|
+
dryRun: boolean,
|
|
1641
|
+
plan = false,
|
|
1642
|
+
pull = false,
|
|
1643
|
+
): Promise<void> {
|
|
1644
|
+
const full = deriveGraph({ scope: "all" });
|
|
1645
|
+
// Same positional closure the web "up with deps" uses: everything the seed
|
|
1646
|
+
// sits ON TOP OF, never anything above it. Keeps the two entry points from
|
|
1647
|
+
// drifting — see startClosure.
|
|
1648
|
+
const derived = deriveStartTargets(full, seed, include);
|
|
1649
|
+
const { graph } = derived;
|
|
1650
|
+
const rawOrder = this.runtimeOnly(derived.order, "started");
|
|
1651
|
+
if (rawOrder.length === 0) {
|
|
1652
|
+
console.log(
|
|
1653
|
+
chalk.gray(
|
|
1654
|
+
`\n ${chalk.bold(seed)} has no derived dependencies to start.\n`,
|
|
1655
|
+
),
|
|
1656
|
+
);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
// Systems the user toggled to auto-start OFF in this workspace are not
|
|
1660
|
+
// started by a workspace-wide up — only when one IS the seed (you ran
|
|
1661
|
+
// `den up` from that repo / named it). They still appear in `--plan`,
|
|
1662
|
+
// flagged.
|
|
1663
|
+
const noAutostartSet = this.noAutostartSet();
|
|
1664
|
+
const order = rawOrder.filter((n) => n === seed || !noAutostartSet.has(n));
|
|
1665
|
+
const skippedOff = rawOrder.filter(
|
|
1666
|
+
(n) => n !== seed && noAutostartSet.has(n),
|
|
1667
|
+
);
|
|
1668
|
+
console.log(
|
|
1669
|
+
chalk.cyan(
|
|
1670
|
+
`\n Derived up for ${chalk.bold(seed)}${include ? " (including self)" : " (dependencies only)"}`,
|
|
1671
|
+
),
|
|
1672
|
+
);
|
|
1673
|
+
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
1674
|
+
if (skippedOff.length > 0) {
|
|
1675
|
+
console.log(
|
|
1676
|
+
chalk.gray(
|
|
1677
|
+
` Skipping (auto-start off): ${skippedOff.join(", ")} ` +
|
|
1678
|
+
`(toggle in the web ⋮ menu, or start explicitly with \`${product().cli} up <repo>\`)\n`,
|
|
1679
|
+
),
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
// Retired config key — the toggle moved to per-workspace `autostart`.
|
|
1683
|
+
const staleOptional = (
|
|
1684
|
+
readDenConfig().graph as { optional?: string[] } | undefined
|
|
1685
|
+
)?.optional;
|
|
1686
|
+
if (staleOptional?.length) {
|
|
1687
|
+
console.log(
|
|
1688
|
+
chalk.gray(
|
|
1689
|
+
" Note: config graph.optional is no longer used — toggle auto-start per system in the web ⋮ menu (or workspaces.yml).\n",
|
|
1690
|
+
),
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
// `--plan`: surface the discovered+classified plan and stop. Same discovery
|
|
1694
|
+
// as a real `up`; just doesn't execute. Shows the FULL set with auto-start-
|
|
1695
|
+
// off flagged so you can see what would be skipped and why.
|
|
1696
|
+
if (plan) {
|
|
1697
|
+
this.renderUpPlan(
|
|
1698
|
+
buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
|
|
1699
|
+
);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (dryRun) {
|
|
1703
|
+
console.log(chalk.gray(" (--dry-run: not starting anything)\n"));
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
// Check the secrets backend first so any first-clone `make setup` (and the up
|
|
1707
|
+
// commands) have developer secrets available.
|
|
1708
|
+
preflightSecrets();
|
|
1709
|
+
// `--pull`: fast-forward existing managed clones in the start set
|
|
1710
|
+
// before bringing them up (dev-managed checkouts are never touched).
|
|
1711
|
+
if (pull) this.pullManagedClonesFor(order);
|
|
1712
|
+
// Bring the start-set's managed clones on disk AND set them up (hidden,
|
|
1713
|
+
// non-interactive) before running their `make up` — a managed repo that's
|
|
1714
|
+
// configured but not cloned would otherwise fail with "could not read
|
|
1715
|
+
// .den.yml". Fail-fast if any clone or setup fails.
|
|
1716
|
+
const failedClones = await this.ensureManagedClonesFor(order);
|
|
1717
|
+
if (failedClones.length > 0) {
|
|
1718
|
+
console.log(
|
|
1719
|
+
chalk.red(
|
|
1720
|
+
`\n Aborting (fail-fast): clone/setup failed for ${failedClones.join(", ")}.\n`,
|
|
1721
|
+
),
|
|
1722
|
+
);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
await this.runServicesCommand(order, "up", {
|
|
1726
|
+
order,
|
|
1727
|
+
waitDepsFor: (address) => hardDepsOf(graph, address),
|
|
1728
|
+
failFast: true,
|
|
1729
|
+
skipHealthyAll: true,
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* Stop the seed repo and the dependencies it was started with — the same
|
|
1735
|
+
* closure `den up <seed>` brings up, torn down in reverse (seed first,
|
|
1736
|
+
* foundations last), so the two halves of the pair operate on one set. Matches
|
|
1737
|
+
* the web card's "down with deps".
|
|
1738
|
+
*/
|
|
1739
|
+
private async runDerivedDown(
|
|
1740
|
+
seed: string,
|
|
1741
|
+
dryRun: boolean,
|
|
1742
|
+
force = false,
|
|
1743
|
+
): Promise<void> {
|
|
1744
|
+
const full = deriveGraph({ scope: "all" });
|
|
1745
|
+
const upOrder = this.runtimeOnly(
|
|
1746
|
+
deriveStartTargets(full, seed).order,
|
|
1747
|
+
"stopped",
|
|
1748
|
+
);
|
|
1749
|
+
if (upOrder.length === 0) return;
|
|
1750
|
+
// Down stops EVERYTHING in the closure — the auto-start toggle only governs
|
|
1751
|
+
// the starting half ("quiet my machine" quiets all of them).
|
|
1752
|
+
const order = [...upOrder].reverse();
|
|
1753
|
+
// Stopping shared foundations can break systems OUTSIDE the closure that are
|
|
1754
|
+
// still running on them. Name them rather than letting them fall over
|
|
1755
|
+
// silently.
|
|
1756
|
+
const inClosure = new Set(order);
|
|
1757
|
+
const atRisk = [
|
|
1758
|
+
...new Set(
|
|
1759
|
+
full.edges
|
|
1760
|
+
.filter(
|
|
1761
|
+
(e) =>
|
|
1762
|
+
e.kind !== "jwks" &&
|
|
1763
|
+
e.ordering !== false &&
|
|
1764
|
+
inClosure.has(e.to) &&
|
|
1765
|
+
!inClosure.has(e.from),
|
|
1766
|
+
)
|
|
1767
|
+
.map((e) => e.from),
|
|
1768
|
+
),
|
|
1769
|
+
].sort();
|
|
1770
|
+
console.log(
|
|
1771
|
+
chalk.cyan(`\n Derived down for ${chalk.bold(seed)} (+ dependencies)`),
|
|
1772
|
+
);
|
|
1773
|
+
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
1774
|
+
if (atRisk.length > 0) {
|
|
1775
|
+
console.log(
|
|
1776
|
+
chalk.yellow(
|
|
1777
|
+
` ⚠ also depends on what this stops: ${atRisk.join(", ")}\n` +
|
|
1778
|
+
` stop or restart them if they misbehave\n`,
|
|
1779
|
+
),
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
if (dryRun) {
|
|
1783
|
+
console.log(chalk.gray(" (--dry-run: not stopping anything)\n"));
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
await this.runServicesCommand(order, "down", { order }, { force });
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** Workspace repos with no runtime (modules, libraries) → their kind. */
|
|
1790
|
+
private noRuntimeRepos(): Map<string, RepoKind> {
|
|
1791
|
+
const out = new Map<string, RepoKind>();
|
|
1792
|
+
for (const s of getWorkspaceServices()) {
|
|
1793
|
+
const kind = repoKind(s.repoPath);
|
|
1794
|
+
if (!hasRuntime(kind)) out.set(s.repoName, kind);
|
|
1795
|
+
}
|
|
1796
|
+
return out;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/** Drop modules/libraries from a start/stop order, saying so once. */
|
|
1800
|
+
private runtimeOnly(order: string[], verb: "started" | "stopped"): string[] {
|
|
1801
|
+
const none = this.noRuntimeRepos();
|
|
1802
|
+
const skipped = order.filter((r) => none.has(r));
|
|
1803
|
+
if (skipped.length > 0)
|
|
1804
|
+
console.log(
|
|
1805
|
+
chalk.gray(
|
|
1806
|
+
` Not ${verb} (no runtime of their own): ${skipped.map((r) => `${r} (${none.get(r)})`).join(", ")}`,
|
|
1807
|
+
),
|
|
1808
|
+
);
|
|
1809
|
+
return order.filter((r) => !none.has(r));
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* Every repo in the active workspace, sorted by the full derived up order —
|
|
1814
|
+
* the CLI counterpart of the web UI's master Up/Down buttons. cwd-agnostic.
|
|
1815
|
+
* Returns null when there is no active workspace (or it has no services).
|
|
1816
|
+
*/
|
|
1817
|
+
private allWorkspaceOrder(only?: Set<string>): {
|
|
1818
|
+
rawOrder: string[];
|
|
1819
|
+
graph: DerivedGraph;
|
|
1820
|
+
} | null {
|
|
1821
|
+
// Modules and libraries ship inside another repo's runtime: never started.
|
|
1822
|
+
const services = getWorkspaceServices().filter(
|
|
1823
|
+
(s) =>
|
|
1824
|
+
(!only || only.has(s.repoName)) && hasRuntime(repoKind(s.repoPath)),
|
|
1825
|
+
);
|
|
1826
|
+
if (services.length === 0) return null;
|
|
1827
|
+
const repos = new Set(services.map((s) => s.repoName));
|
|
1828
|
+
const graph = deriveGraph({ scope: "all" });
|
|
1829
|
+
const rank = new Map(orderForUp(graph).map((repo, i) => [repo, i]));
|
|
1830
|
+
const rawOrder = [...repos].sort(
|
|
1831
|
+
(a, b) =>
|
|
1832
|
+
(rank.get(a) ?? Number.MAX_SAFE_INTEGER) -
|
|
1833
|
+
(rank.get(b) ?? Number.MAX_SAFE_INTEGER),
|
|
1834
|
+
);
|
|
1835
|
+
return { rawOrder, graph };
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
private async runAllUp(
|
|
1839
|
+
dryRun: boolean,
|
|
1840
|
+
plan: boolean,
|
|
1841
|
+
pull: boolean,
|
|
1842
|
+
only?: Set<string>,
|
|
1843
|
+
): Promise<void> {
|
|
1844
|
+
const target = this.allWorkspaceOrder(only);
|
|
1845
|
+
if (!target) {
|
|
1846
|
+
console.log(
|
|
1847
|
+
chalk.yellow(
|
|
1848
|
+
"\n No services in the active workspace — nothing to start.\n",
|
|
1849
|
+
),
|
|
1850
|
+
);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const { rawOrder, graph } = target;
|
|
1854
|
+
const noAutostartSet = this.noAutostartSet();
|
|
1855
|
+
const skippedOff = rawOrder.filter((n) => noAutostartSet.has(n));
|
|
1856
|
+
const candidates = rawOrder.filter((n) => !noAutostartSet.has(n));
|
|
1857
|
+
|
|
1858
|
+
// plan / --dry-run are read-only and must run BEFORE the bootstrap below.
|
|
1859
|
+
// With clones missing the true order can't be known yet, so dry-run shows
|
|
1860
|
+
// the candidate set.
|
|
1861
|
+
if (plan) {
|
|
1862
|
+
this.renderUpPlan(
|
|
1863
|
+
buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
|
|
1864
|
+
);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
if (dryRun) {
|
|
1868
|
+
console.log(
|
|
1869
|
+
chalk.cyan(
|
|
1870
|
+
`\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(candidates.length))} candidate system(s)`,
|
|
1871
|
+
),
|
|
1872
|
+
);
|
|
1873
|
+
console.log(chalk.gray(` Order: ${candidates.join(" → ")}\n`));
|
|
1874
|
+
if (skippedOff.length > 0) {
|
|
1875
|
+
console.log(
|
|
1876
|
+
chalk.gray(` Skipping (auto-start off): ${skippedOff.join(", ")}\n`),
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
console.log(
|
|
1880
|
+
chalk.gray(
|
|
1881
|
+
" (--dry-run: not starting anything; a real run clones + sets up any missing managed repos first)\n",
|
|
1882
|
+
),
|
|
1883
|
+
);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (candidates.length === 0) {
|
|
1887
|
+
console.log(chalk.yellow("\n Nothing to start.\n"));
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// Bootstrap FIRST. The no-up filter below reads manifests, and a fresh
|
|
1892
|
+
// workspace has no clones to read — filtering first turned `up -a` into
|
|
1893
|
+
// "Nothing to start" on exactly the machine that needed it most. Starting
|
|
1894
|
+
// everything IS the stated intent, and managed repos are den's
|
|
1895
|
+
// to materialise: clone + setup happen implicitly, no separate command.
|
|
1896
|
+
preflightSecrets();
|
|
1897
|
+
if (pull) this.pullManagedClonesFor(candidates);
|
|
1898
|
+
const failedClones = await this.ensureManagedClonesFor(candidates);
|
|
1899
|
+
if (failedClones.length > 0) {
|
|
1900
|
+
console.log(
|
|
1901
|
+
chalk.red(
|
|
1902
|
+
`\n Aborting (fail-fast): clone/setup failed for ${failedClones.join(", ")}.\n`,
|
|
1903
|
+
),
|
|
1904
|
+
);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Like the web master ▶ Up: a system with no runnable local `up` command
|
|
1909
|
+
// is skipped, not fatal. `-a` starts independent peers — one unrunnable
|
|
1910
|
+
// repo must not strand the rest (runServiceCommand exits 1 on a missing
|
|
1911
|
+
// command, and a fail-fast would turn that into a full abort). Now that
|
|
1912
|
+
// clones exist, the manifest lookup is meaningful.
|
|
1913
|
+
const order: string[] = [];
|
|
1914
|
+
const skippedNoUp: string[] = [];
|
|
1915
|
+
for (const repo of candidates) {
|
|
1916
|
+
const resolved = resolveService(repo);
|
|
1917
|
+
const upDef = resolved
|
|
1918
|
+
? localCommandsFor(resolved, this.focusedWs())["up"]
|
|
1919
|
+
: undefined;
|
|
1920
|
+
(upDef ? order : skippedNoUp).push(repo);
|
|
1921
|
+
}
|
|
1922
|
+
console.log(
|
|
1923
|
+
chalk.cyan(
|
|
1924
|
+
`\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} systems`,
|
|
1925
|
+
),
|
|
1926
|
+
);
|
|
1927
|
+
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
1928
|
+
if (skippedOff.length > 0) {
|
|
1929
|
+
console.log(
|
|
1930
|
+
chalk.gray(
|
|
1931
|
+
` Skipping (auto-start off): ${skippedOff.join(", ")} ` +
|
|
1932
|
+
`(toggle in the web ⋮ menu, or start explicitly with \`${product().cli} up <repo>\`)\n`,
|
|
1933
|
+
),
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
if (skippedNoUp.length > 0) {
|
|
1937
|
+
console.log(
|
|
1938
|
+
chalk.gray(
|
|
1939
|
+
` Skipping (no local up command): ${skippedNoUp.join(", ")}\n`,
|
|
1940
|
+
),
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
if (order.length === 0) {
|
|
1944
|
+
console.log(chalk.yellow(" Nothing to start.\n"));
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
// Preflight, pull and clones+setup already ran above, before the manifest
|
|
1948
|
+
// filter. From here: the ordered start with hard-dep gating and
|
|
1949
|
+
// healthy-skip. Unlike runDerivedUp there is NO failFast on the start
|
|
1950
|
+
// loop: `-a`'s systems are independent peers — one failure must not
|
|
1951
|
+
// strand the rest (the web master button continues too).
|
|
1952
|
+
const repoSet = new Set(order);
|
|
1953
|
+
await this.runServicesCommand(order, "up", {
|
|
1954
|
+
order,
|
|
1955
|
+
// Hard deps outside the workspace can't be waited on — filter to the set
|
|
1956
|
+
// we are actually starting.
|
|
1957
|
+
waitDepsFor: (address) =>
|
|
1958
|
+
hardDepsOf(graph, address).filter((dep) => repoSet.has(dep)),
|
|
1959
|
+
skipHealthyAll: true,
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
private async runAllDown(
|
|
1964
|
+
dryRun: boolean,
|
|
1965
|
+
force = false,
|
|
1966
|
+
only?: Set<string>,
|
|
1967
|
+
): Promise<void> {
|
|
1968
|
+
const target = this.allWorkspaceOrder(only);
|
|
1969
|
+
if (!target) {
|
|
1970
|
+
console.log(
|
|
1971
|
+
chalk.yellow(
|
|
1972
|
+
"\n No services in the active workspace — nothing to stop.\n",
|
|
1973
|
+
),
|
|
1974
|
+
);
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
// Reverse of the up order. Includes auto-start-off systems — the toggle
|
|
1978
|
+
// only governs starting ("quiet my machine" quiets all of them). Unlike
|
|
1979
|
+
// the web master Down, there is NO already-stopped skip here: every down
|
|
1980
|
+
// script runs (down on a stopped service is a cheap no-op), so systems
|
|
1981
|
+
// invisible to the container/health checks still get stopped.
|
|
1982
|
+
const order = [...target.rawOrder].reverse();
|
|
1983
|
+
console.log(
|
|
1984
|
+
chalk.cyan(
|
|
1985
|
+
`\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))} systems`,
|
|
1986
|
+
),
|
|
1987
|
+
);
|
|
1988
|
+
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
1989
|
+
if (dryRun) {
|
|
1990
|
+
console.log(chalk.gray(" (--dry-run: not stopping anything)\n"));
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
await this.runServicesCommand(order, "down", { order }, { force });
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
private printUpHelp(): void {
|
|
1997
|
+
console.log();
|
|
1998
|
+
console.log(chalk.bold(` ${product().cli} up [repo] [flags]`));
|
|
1999
|
+
console.log();
|
|
2000
|
+
console.log(
|
|
2001
|
+
" Bring up the current repo (or [repo]) and its derived dependency closure,",
|
|
2002
|
+
);
|
|
2003
|
+
console.log(
|
|
2004
|
+
" ordered by the dependency graph. By default the current/seed repo IS started.",
|
|
2005
|
+
);
|
|
2006
|
+
console.log();
|
|
2007
|
+
console.log(chalk.bold(" Flags:"));
|
|
2008
|
+
const row = (f: string, d: string) =>
|
|
2009
|
+
console.log(` ${chalk.cyan(f.padEnd(20))}${d}`);
|
|
2010
|
+
row(
|
|
2011
|
+
"-x, --exclude",
|
|
2012
|
+
"start only the dependencies, not the current/seed repo",
|
|
2013
|
+
);
|
|
2014
|
+
row(
|
|
2015
|
+
"-a, --all",
|
|
2016
|
+
"start EVERY workspace system in derived order (cwd-agnostic; skips auto-start-off)",
|
|
2017
|
+
);
|
|
2018
|
+
row(
|
|
2019
|
+
"-A, --app <app>",
|
|
2020
|
+
`start every repo in an application (\`${product().cli} apps\`), in derived order`,
|
|
2021
|
+
);
|
|
2022
|
+
row("-n, --dry-run", "print the start order without starting anything");
|
|
2023
|
+
row(
|
|
2024
|
+
"--plan",
|
|
2025
|
+
"show the classified plan (commands, optional/interactive) and stop",
|
|
2026
|
+
);
|
|
2027
|
+
row(
|
|
2028
|
+
"--pull",
|
|
2029
|
+
"fast-forward managed clones in the start set first (ff-only; dev-managed untouched)",
|
|
2030
|
+
);
|
|
2031
|
+
row(
|
|
2032
|
+
"--manifest",
|
|
2033
|
+
`use legacy ${product().manifestYml} depends_on instead of the derived graph`,
|
|
2034
|
+
);
|
|
2035
|
+
row("--skip-secrets", "skip the secrets-backend sign-in preflight");
|
|
2036
|
+
row("-h, --help", "show this help");
|
|
2037
|
+
console.log();
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
private printGraphHelp(): void {
|
|
2041
|
+
console.log();
|
|
2042
|
+
console.log(chalk.bold(` ${product().cli} graph [repo] [flags]`));
|
|
2043
|
+
console.log();
|
|
2044
|
+
console.log(
|
|
2045
|
+
" Derive & render the cross-service dependency graph from real repo signals",
|
|
2046
|
+
);
|
|
2047
|
+
console.log(
|
|
2048
|
+
" (SDK imports, *_URL env vars, IaC refs, EventBridge rules). Defaults to the",
|
|
2049
|
+
);
|
|
2050
|
+
console.log(" cwd repo's neighborhood; pass [repo] or --all for more.");
|
|
2051
|
+
console.log();
|
|
2052
|
+
console.log(chalk.bold(" Flags:"));
|
|
2053
|
+
const row = (f: string, d: string) =>
|
|
2054
|
+
console.log(` ${chalk.cyan(f.padEnd(22))}${d}`);
|
|
2055
|
+
row(
|
|
2056
|
+
"--all",
|
|
2057
|
+
"the full known-systems graph (not just the cwd neighborhood)",
|
|
2058
|
+
);
|
|
2059
|
+
row("-u, --up", "render nodes in start order (deps-first) — default");
|
|
2060
|
+
row("-d, --down", "render nodes in shutdown order (reverse)");
|
|
2061
|
+
row("--source=<s>", "derived | declared | union (default derived)");
|
|
2062
|
+
row("--kind=<k>", "all | hard | infra | http | event");
|
|
2063
|
+
row("--format=<f>", "text | json | dot (default text)");
|
|
2064
|
+
row(
|
|
2065
|
+
"--diff",
|
|
2066
|
+
`compare derived deps vs ${product().manifestYml} depends_on`,
|
|
2067
|
+
);
|
|
2068
|
+
row("--refresh", "ignore the per-repo cache and re-derive");
|
|
2069
|
+
row("-h, --help", "show this help");
|
|
2070
|
+
console.log();
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
private printUseModeHelp(mode: "dev-managed" | "managed"): void {
|
|
2074
|
+
const verb = mode === "dev-managed" ? "use-local" : `use-managed`;
|
|
2075
|
+
console.log(`\n ${product().cli} ${verb} [repo]\n`);
|
|
2076
|
+
console.log(
|
|
2077
|
+
mode === "dev-managed"
|
|
2078
|
+
? ` Point ${product().cli} at your LOCAL checkout (dev-managed) — defaults to the repo you're\n in, or a named repo's sibling under repos_root.`
|
|
2079
|
+
: ` Switch the repo back to a MANAGED clone (${product().servicesDir}) — defaults to\n the repo you're in, or a named repo.`,
|
|
2080
|
+
);
|
|
2081
|
+
console.log();
|
|
2082
|
+
console.log(chalk.bold(" Flags:"));
|
|
2083
|
+
const row = (f: string, d: string) =>
|
|
2084
|
+
console.log(` ${chalk.cyan(f.padEnd(18))}${d}`);
|
|
2085
|
+
if (mode === "managed")
|
|
2086
|
+
row("--branch=<name>", "branch for the managed clone (default main)");
|
|
2087
|
+
row("-h, --help", "show this help");
|
|
2088
|
+
console.log();
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
|
|
2092
|
+
private printCommandHelp(command: string): void {
|
|
2093
|
+
switch (command) {
|
|
2094
|
+
case "up":
|
|
2095
|
+
return this.printUpHelp();
|
|
2096
|
+
case "graph":
|
|
2097
|
+
return this.printGraphHelp();
|
|
2098
|
+
case "use-local":
|
|
2099
|
+
return this.printUseModeHelp("dev-managed");
|
|
2100
|
+
case `use-managed`:
|
|
2101
|
+
return this.printUseModeHelp("managed");
|
|
2102
|
+
case "web":
|
|
2103
|
+
return displayWebHelp(); // its own detailed actions/options help
|
|
2104
|
+
}
|
|
2105
|
+
const entry = COMMAND_HELP[command === "applications" ? "apps" : command];
|
|
2106
|
+
if (!entry) {
|
|
2107
|
+
console.log(
|
|
2108
|
+
chalk.yellow(
|
|
2109
|
+
`\n No detailed help for '${command}'. Run '${product().cli} --help' for the command list.\n`,
|
|
2110
|
+
),
|
|
2111
|
+
);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
console.log(`\n ${product().cli} ${entry.usage}\n`);
|
|
2115
|
+
for (const line of entry.lines) console.log(` ${line}`);
|
|
2116
|
+
console.log();
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/**
|
|
2120
|
+
* Seed (or reset) the default workspace from the committed blueprint
|
|
2121
|
+
* (config/den.yml `defaults.workspace`), all repos managed. Prints
|
|
2122
|
+
* clear guidance and, on a TTY, asks first. `reason: "auto"` is the one-time
|
|
2123
|
+
* offer on a fresh clone; `reason: "init"` is the explicit `den init`.
|
|
2124
|
+
* Returns true if the workspace was seeded.
|
|
2125
|
+
*/
|
|
2126
|
+
private async seedDefaultWorkspaceFlow(
|
|
2127
|
+
reason: "auto" | "init",
|
|
2128
|
+
): Promise<boolean> {
|
|
2129
|
+
const repos = defaultWorkspaceRepos();
|
|
2130
|
+
if (repos.length === 0) {
|
|
2131
|
+
if (reason === "init") {
|
|
2132
|
+
console.log(
|
|
2133
|
+
chalk.yellow(
|
|
2134
|
+
`\n No defaults.workspace.repos in ${product().configFile} — nothing to seed.\n`,
|
|
2135
|
+
),
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
return false;
|
|
2139
|
+
}
|
|
2140
|
+
const willReset = defaultWorkspaceHasServices();
|
|
2141
|
+
const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
2142
|
+
|
|
2143
|
+
console.log(
|
|
2144
|
+
chalk.cyan(`\n Default workspace — ${repos.length} repos, all managed:`),
|
|
2145
|
+
);
|
|
2146
|
+
console.log(chalk.gray(` ${repos.join(", ")}`));
|
|
2147
|
+
console.log(
|
|
2148
|
+
chalk.gray(
|
|
2149
|
+
` ${product().title} clones + tracks them on main under ${product().servicesDir}/. Switch any`,
|
|
2150
|
+
),
|
|
2151
|
+
);
|
|
2152
|
+
console.log(
|
|
2153
|
+
chalk.gray(
|
|
2154
|
+
` to your own checkout anytime: \`${product().cli} use-local <repo>\` (revert: use-managed).\n`,
|
|
2155
|
+
),
|
|
2156
|
+
);
|
|
2157
|
+
|
|
2158
|
+
if (interactive) {
|
|
2159
|
+
const question = willReset
|
|
2160
|
+
? " Reset the default workspace to this shipped set?"
|
|
2161
|
+
: " Seed the default workspace now?";
|
|
2162
|
+
const ok = await cliPrompts.confirm(question, !willReset);
|
|
2163
|
+
if (!ok) {
|
|
2164
|
+
if (reason === "auto") {
|
|
2165
|
+
markDefaultWorkspaceDeclined(); // don't ask again
|
|
2166
|
+
console.log(
|
|
2167
|
+
chalk.gray(
|
|
2168
|
+
` Skipped — run \`${product().cli} init\` to set it up later.\n`,
|
|
2169
|
+
),
|
|
2170
|
+
);
|
|
2171
|
+
} else {
|
|
2172
|
+
console.log(chalk.gray(" Aborted.\n"));
|
|
2173
|
+
}
|
|
2174
|
+
return false;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
const seeded = seedDefaultWorkspace();
|
|
2179
|
+
console.log(
|
|
2180
|
+
chalk.green(
|
|
2181
|
+
` ✓ Default workspace ready — ${seeded.length} repos (managed). ` +
|
|
2182
|
+
`Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
|
|
2183
|
+
),
|
|
2184
|
+
);
|
|
2185
|
+
return true;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* One-time offer to seed the default workspace on a fresh clone; afterwards
|
|
2190
|
+
* keep it converged on the blueprint (repos added to defaults later — e.g.
|
|
2191
|
+
* tasks-api — join the existing workspace managed).
|
|
2192
|
+
*/
|
|
2193
|
+
private async ensureWorkspaceSeeded(): Promise<void> {
|
|
2194
|
+
if (defaultWorkspaceNeedsSeeding()) {
|
|
2195
|
+
await this.seedDefaultWorkspaceFlow("auto");
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
const added = topUpDefaultWorkspace();
|
|
2199
|
+
if (added.length > 0) {
|
|
2200
|
+
console.log(
|
|
2201
|
+
chalk.gray(
|
|
2202
|
+
` default workspace: added standard repo(s) ${added.join(", ")} (managed)`,
|
|
2203
|
+
),
|
|
2204
|
+
);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
private async cmdServiceUp(args: string[]): Promise<void> {
|
|
2209
|
+
await this.ensureWorkspaceSeeded();
|
|
2210
|
+
// Tokens after a `--` terminator belong to a child command — they must
|
|
2211
|
+
// never be read as up's own flags (`den up -- -a` is NOT `up -a`).
|
|
2212
|
+
const terminator = args.indexOf("--");
|
|
2213
|
+
const { app, rest: ownArgs } = takeAppFlag(
|
|
2214
|
+
terminator === -1 ? args : args.slice(0, terminator),
|
|
2215
|
+
);
|
|
2216
|
+
const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
|
|
2217
|
+
const plan = ownArgs.includes("--plan");
|
|
2218
|
+
const pull = ownArgs.includes("--pull");
|
|
2219
|
+
// `-A <app>`: every repo in that application, in derived order (like -a, scoped).
|
|
2220
|
+
if (app) {
|
|
2221
|
+
await this.runAllUp(dryRun, plan, pull, focusedAppRepos(app));
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
const seedArg = ownArgs.find((a) => !a.startsWith("-"));
|
|
2225
|
+
|
|
2226
|
+
// `-a`/`--all`: the whole active workspace in derived order, regardless of
|
|
2227
|
+
// cwd — the CLI twin of the web UI's master ▶ Up button.
|
|
2228
|
+
if (ownArgs.includes("-a") || ownArgs.includes("--all")) {
|
|
2229
|
+
const conflicting = [
|
|
2230
|
+
"-x",
|
|
2231
|
+
"--exclude",
|
|
2232
|
+
"--manifest",
|
|
2233
|
+
"--no-derive",
|
|
2234
|
+
].filter((f) => ownArgs.includes(f));
|
|
2235
|
+
if (conflicting.length > 0) {
|
|
2236
|
+
console.log(
|
|
2237
|
+
chalk.red(
|
|
2238
|
+
`\n -a/--all cannot be combined with ${conflicting.join(", ")}.\n`,
|
|
2239
|
+
),
|
|
2240
|
+
);
|
|
2241
|
+
process.exit(1);
|
|
2242
|
+
}
|
|
2243
|
+
if (seedArg) {
|
|
2244
|
+
console.log(
|
|
2245
|
+
chalk.red(
|
|
2246
|
+
`\n -a/--all starts the whole workspace — drop the repo arg (${seedArg}) or the flag.\n`,
|
|
2247
|
+
),
|
|
2248
|
+
);
|
|
2249
|
+
process.exit(1);
|
|
2250
|
+
}
|
|
2251
|
+
await this.runAllUp(dryRun, plan, pull);
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// Default: derive the dependency closure from the cwd repo (or an explicit
|
|
2256
|
+
// known-repo arg) and bring it up. Falls back to the legacy manifest path
|
|
2257
|
+
// when there is no known seed (explicit service address, or run outside a
|
|
2258
|
+
// known repo) or when --manifest/--no-derive is passed.
|
|
2259
|
+
const forceManifest =
|
|
2260
|
+
ownArgs.includes("--manifest") || ownArgs.includes("--no-derive");
|
|
2261
|
+
const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
|
|
2262
|
+
|
|
2263
|
+
if (seed) {
|
|
2264
|
+
// Default now INCLUDES the current/seed repo; `-x`/`--exclude` starts only
|
|
2265
|
+
// its dependencies (the previous default).
|
|
2266
|
+
const include = !(
|
|
2267
|
+
ownArgs.includes("-x") || ownArgs.includes("--exclude")
|
|
2268
|
+
);
|
|
2269
|
+
await this.runDerivedUp(seed, include, dryRun, plan, pull);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
await this.cmdServiceUpManifest(ownArgs.filter((a) => !a.startsWith("-")));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/** Legacy: start the given services via manifest depends_on. */
|
|
2276
|
+
private async cmdServiceUpManifest(addresses: string[]): Promise<void> {
|
|
2277
|
+
// Resolve targets BEFORE the secrets preflight — otherwise running
|
|
2278
|
+
// `den up` from a non-service dir (e.g. the den repo root) triggers
|
|
2279
|
+
// an interactive sign-in just to print "nothing to do".
|
|
2280
|
+
const targets =
|
|
2281
|
+
addresses.length > 0 ? addresses : this.resolveNoArgServiceTargets("up");
|
|
2282
|
+
if (targets.length === 0) {
|
|
2283
|
+
const here = this.cwdSeedRepo();
|
|
2284
|
+
if (here === `${product().cli}`) {
|
|
2285
|
+
console.log(
|
|
2286
|
+
chalk.yellow(
|
|
2287
|
+
`\n You're in the ${product().cli} repo, which isn't a service itself.\n`,
|
|
2288
|
+
) +
|
|
2289
|
+
chalk.gray(
|
|
2290
|
+
` Start a system by name (\`${product().cli} up shop\`), cd into a service repo,\n` +
|
|
2291
|
+
` or run \`${product().cli} graph\` to see the workspace.\n`,
|
|
2292
|
+
),
|
|
2293
|
+
);
|
|
2294
|
+
} else {
|
|
2295
|
+
console.log(
|
|
2296
|
+
chalk.yellow(
|
|
2297
|
+
"No services specified and no invocation target resolvable — run from a service repo or pass a service name.",
|
|
2298
|
+
),
|
|
2299
|
+
);
|
|
2300
|
+
}
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
preflightSecrets();
|
|
2304
|
+
await this.runServicesCommand(targets, "up");
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
private async cmdServiceDown(args: string[]): Promise<void> {
|
|
2308
|
+
// Tokens after a `--` terminator belong to a child command — they must
|
|
2309
|
+
// never be read as down's own flags (`den down -- -a` must NOT tear
|
|
2310
|
+
// down the whole workspace).
|
|
2311
|
+
const terminator = args.indexOf("--");
|
|
2312
|
+
const { app, rest: ownArgs } = takeAppFlag(
|
|
2313
|
+
terminator === -1 ? args : args.slice(0, terminator),
|
|
2314
|
+
);
|
|
2315
|
+
const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
|
|
2316
|
+
const force = ownArgs.includes("--force") || ownArgs.includes("-f");
|
|
2317
|
+
if (app) {
|
|
2318
|
+
await this.runAllDown(dryRun, force, focusedAppRepos(app));
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// `-a`/`--all`: stop every workspace system in reverse derived order,
|
|
2323
|
+
// regardless of cwd — the CLI twin of the web UI's master ⏹ Down button
|
|
2324
|
+
// (minus its already-stopped skip: every down script runs).
|
|
2325
|
+
if (ownArgs.includes("-a") || ownArgs.includes("--all")) {
|
|
2326
|
+
const conflicting = ["--manifest", "--no-derive"].filter((f) =>
|
|
2327
|
+
ownArgs.includes(f),
|
|
2328
|
+
);
|
|
2329
|
+
if (conflicting.length > 0) {
|
|
2330
|
+
console.log(
|
|
2331
|
+
chalk.red(
|
|
2332
|
+
`\n -a/--all cannot be combined with ${conflicting.join(", ")}.\n`,
|
|
2333
|
+
),
|
|
2334
|
+
);
|
|
2335
|
+
process.exit(1);
|
|
2336
|
+
}
|
|
2337
|
+
const seedArg = ownArgs.find((a) => !a.startsWith("-"));
|
|
2338
|
+
if (seedArg) {
|
|
2339
|
+
console.log(
|
|
2340
|
+
chalk.red(
|
|
2341
|
+
`\n -a/--all stops the whole workspace — drop the repo arg (${seedArg}) or the flag.\n`,
|
|
2342
|
+
),
|
|
2343
|
+
);
|
|
2344
|
+
process.exit(1);
|
|
2345
|
+
}
|
|
2346
|
+
await this.runAllDown(dryRun, force);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const forceManifest =
|
|
2351
|
+
ownArgs.includes("--manifest") || ownArgs.includes("--no-derive");
|
|
2352
|
+
const seedArg = ownArgs.find((a) => !a.startsWith("-"));
|
|
2353
|
+
const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
|
|
2354
|
+
|
|
2355
|
+
if (seed) {
|
|
2356
|
+
await this.runDerivedDown(seed, dryRun, force);
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
const addresses = ownArgs.filter((a) => !a.startsWith("-"));
|
|
2360
|
+
const targets =
|
|
2361
|
+
addresses.length > 0
|
|
2362
|
+
? addresses
|
|
2363
|
+
: this.resolveNoArgServiceTargets("down");
|
|
2364
|
+
await this.runServicesCommand(targets, "down", undefined, { force });
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
private async cmdServiceRestart(argv: string[]): Promise<void> {
|
|
2368
|
+
const { app, rest: args } = takeAppFlag(argv);
|
|
2369
|
+
preflightSecrets();
|
|
2370
|
+
if (app) {
|
|
2371
|
+
const order =
|
|
2372
|
+
this.allWorkspaceOrder(focusedAppRepos(app))?.rawOrder ?? [];
|
|
2373
|
+
await this.runServicesCommand(order, "restart");
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
// Drop flag tokens so a bare `den restart --foo` doesn't treat the flag
|
|
2377
|
+
// as a service address (matches cmdServiceUp/cmdServiceDown).
|
|
2378
|
+
const filtered = args.filter((a) => !a.startsWith("-"));
|
|
2379
|
+
const addresses =
|
|
2380
|
+
filtered.length > 0
|
|
2381
|
+
? filtered
|
|
2382
|
+
: this.resolveNoArgServiceTargets("restart");
|
|
2383
|
+
await this.runServicesCommand(addresses, "restart");
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** Service address with a ⏸ marker when the repo is auto-start off. */
|
|
2387
|
+
private labelWithAutostart(
|
|
2388
|
+
address: string,
|
|
2389
|
+
noAutostart: Set<string>,
|
|
2390
|
+
): string {
|
|
2391
|
+
const base = chalk.bold(address);
|
|
2392
|
+
return noAutostart.has(parseAddress(address).repoName)
|
|
2393
|
+
? `${base} ${chalk.yellow("⏸")}`
|
|
2394
|
+
: base;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
/** Repos with auto-start toggled off in the active workspace. */
|
|
2398
|
+
private noAutostartSet(): Set<string> {
|
|
2399
|
+
const ws = getActiveWorkspace();
|
|
2400
|
+
if (!ws) return new Set();
|
|
2401
|
+
return noAutostartRepos(ws.config, readCatalog());
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
private async cmdServiceStatus(argv: string[] = []): Promise<void> {
|
|
2405
|
+
await this.ensureWorkspaceSeeded();
|
|
2406
|
+
const { app, rest: args } = takeAppFlag(argv);
|
|
2407
|
+
const only = app ? focusedAppRepos(app) : null;
|
|
2408
|
+
const verbose = args.includes("--verbose") || args.includes("--all");
|
|
2409
|
+
const spinner = ora("Checking service health...").start();
|
|
2410
|
+
const raw = (await checkCatalogHealth()).filter(
|
|
2411
|
+
(h) => !only || only.has(h.repoName),
|
|
2412
|
+
);
|
|
2413
|
+
// Roll multi-service repos up to their entrypoint so shared-infra sidecars
|
|
2414
|
+
// (which read healthy off infra-stack) don't make a down app look up.
|
|
2415
|
+
const rolled = verbose ? raw : rollupRepoHealth(raw);
|
|
2416
|
+
// Order top-down like the graph: entry repo first (shop-web) … foundation
|
|
2417
|
+
// last (infra-stack), subsystems grouped under their repo.
|
|
2418
|
+
const order = orderForDown(deriveGraph({ scope: "all" }));
|
|
2419
|
+
const results = sortByRepoOrder(rolled, order);
|
|
2420
|
+
spinner.stop();
|
|
2421
|
+
const noAutostart = this.noAutostartSet();
|
|
2422
|
+
|
|
2423
|
+
if (results.length === 0) {
|
|
2424
|
+
console.log(
|
|
2425
|
+
chalk.yellow(
|
|
2426
|
+
`\n No services registered. Run: ${product().cli} register\n`,
|
|
2427
|
+
),
|
|
2428
|
+
);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const ws = getActiveWorkspace();
|
|
2433
|
+
console.log(
|
|
2434
|
+
chalk.cyan(`\n Workspace: ${chalk.bold(ws?.name ?? "none")}\n`),
|
|
2435
|
+
);
|
|
2436
|
+
|
|
2437
|
+
const table = new Table({
|
|
2438
|
+
head: ["Service", "Mode", "Branch", "Health", "Reason"],
|
|
2439
|
+
style: { head: ["cyan"] },
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
for (const r of results) {
|
|
2443
|
+
const healthStr = formatHealthLabel(r.health);
|
|
2444
|
+
const reasonStr = formatHealthReason(r);
|
|
2445
|
+
|
|
2446
|
+
table.push([
|
|
2447
|
+
this.labelWithAutostart(r.address, noAutostart),
|
|
2448
|
+
r.mode === "dev-managed"
|
|
2449
|
+
? chalk.blue("dev-managed")
|
|
2450
|
+
: chalk.magenta("managed"),
|
|
2451
|
+
r.branch ?? chalk.gray("(dev-managed)"),
|
|
2452
|
+
healthStr,
|
|
2453
|
+
reasonStr,
|
|
2454
|
+
]);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
console.log(table.toString());
|
|
2458
|
+
if (
|
|
2459
|
+
results.some((r) => noAutostart.has(parseAddress(r.address).repoName))
|
|
2460
|
+
) {
|
|
2461
|
+
console.log(
|
|
2462
|
+
chalk.gray(" ⏸ auto-start off — not started by workspace up"),
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
if (!verbose && raw.length !== results.length) {
|
|
2466
|
+
console.log(
|
|
2467
|
+
chalk.gray(
|
|
2468
|
+
` ▪ multi-service repos show entrypoint health — \`${product().cli} status --verbose\` for all subsystems`,
|
|
2469
|
+
),
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
const contributors = loadApplications()
|
|
2473
|
+
.filter((a) => !app || a.name === app)
|
|
2474
|
+
.flatMap((a) => a.contributors);
|
|
2475
|
+
if (contributors.length > 0) {
|
|
2476
|
+
console.log(chalk.cyan("\n Contributes (no runtime of its own):"));
|
|
2477
|
+
for (const c of contributors)
|
|
2478
|
+
console.log(` ${describeContributor(c)}`);
|
|
2479
|
+
}
|
|
2480
|
+
console.log();
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
private async cmdLogs(args: string[]): Promise<void> {
|
|
2484
|
+
await this.ensureWorkspaceSeeded();
|
|
2485
|
+
const alias = loadAliasResolver();
|
|
2486
|
+
const target = resolveLogsTarget(args, alias);
|
|
2487
|
+
if (target.kind === "unknown-repo") {
|
|
2488
|
+
console.log(chalk.red(`\n Unknown repo: ${target.arg}\n`));
|
|
2489
|
+
process.exit(1);
|
|
2490
|
+
}
|
|
2491
|
+
if (target.kind === "no-cwd-repo") {
|
|
2492
|
+
console.log(
|
|
2493
|
+
chalk.red(
|
|
2494
|
+
"\n Not inside a known repo — run from a repo folder or pass a repo name.\n",
|
|
2495
|
+
),
|
|
2496
|
+
);
|
|
2497
|
+
process.exit(1);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
let resolved = resolveService(target.target);
|
|
2501
|
+
if (!resolved) {
|
|
2502
|
+
console.log(
|
|
2503
|
+
chalk.red(
|
|
2504
|
+
`\n Service "${target.target}" not found in catalog or active workspace.\n`,
|
|
2505
|
+
),
|
|
2506
|
+
);
|
|
2507
|
+
process.exit(1);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// The canonical run-state test (same as the web down-skip and log search):
|
|
2511
|
+
// container check first (cheap, host-truth), health probe as the fallback
|
|
2512
|
+
// for host-run processes with no container.
|
|
2513
|
+
const running =
|
|
2514
|
+
hasRunningOpctlContainers(resolved) ||
|
|
2515
|
+
(await checkServiceHealth(resolved, "local")).health === "ok";
|
|
2516
|
+
|
|
2517
|
+
let justStarted = false;
|
|
2518
|
+
if (!running) {
|
|
2519
|
+
const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
2520
|
+
if (!interactive) {
|
|
2521
|
+
console.error(
|
|
2522
|
+
chalk.red(`${resolved.address} is not running.`) +
|
|
2523
|
+
chalk.gray(
|
|
2524
|
+
` Start it with '${product().cli} up ${target.target}', then re-run '${product().cli} logs'.`,
|
|
2525
|
+
),
|
|
2526
|
+
);
|
|
2527
|
+
process.exit(1);
|
|
2528
|
+
}
|
|
2529
|
+
console.log(
|
|
2530
|
+
chalk.yellow(
|
|
2531
|
+
`\n ${resolved.address} is not running (no container or healthy endpoint).`,
|
|
2532
|
+
),
|
|
2533
|
+
);
|
|
2534
|
+
const start = await cliPrompts.confirm(
|
|
2535
|
+
`Start it now with '${product().cli} up ${target.target}'?`,
|
|
2536
|
+
true,
|
|
2537
|
+
);
|
|
2538
|
+
if (!start) {
|
|
2539
|
+
console.log(chalk.gray("\n Nothing to tail.\n"));
|
|
2540
|
+
// inquirer holds stdin open after a prompt — exit explicitly.
|
|
2541
|
+
process.exit(0);
|
|
2542
|
+
}
|
|
2543
|
+
await this.cmdServiceUp([target.target]);
|
|
2544
|
+
justStarted = true;
|
|
2545
|
+
|
|
2546
|
+
// `up` detaches after ~4s with no success signal. Re-resolve (a first
|
|
2547
|
+
// clone can correct a bare repo's subsystem to the manifest entrypoint)
|
|
2548
|
+
// and wait for readiness so we tail what actually started — fail-open
|
|
2549
|
+
// when the service has no health check.
|
|
2550
|
+
resolved = resolveService(target.target) ?? resolved;
|
|
2551
|
+
const addr = resolved.address;
|
|
2552
|
+
const spinner = ora(`Waiting for ${addr} to become ready...`).start();
|
|
2553
|
+
const ready = await waitForReady(resolved, "local", {
|
|
2554
|
+
onTick: (detail) => {
|
|
2555
|
+
spinner.text = `Waiting for ${addr}: ${detail}`;
|
|
2556
|
+
},
|
|
2557
|
+
});
|
|
2558
|
+
if (!ready.ok) {
|
|
2559
|
+
spinner.warn(
|
|
2560
|
+
chalk.yellow(
|
|
2561
|
+
`${resolved.address} is still not healthy (${ready.detail}) — tailing its startup log anyway.`,
|
|
2562
|
+
),
|
|
2563
|
+
);
|
|
2564
|
+
} else if (ready.skipped) {
|
|
2565
|
+
spinner.stop();
|
|
2566
|
+
} else {
|
|
2567
|
+
spinner.succeed(`${resolved.address} is ready`);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
const code = await tailServiceLogs(
|
|
2572
|
+
resolved,
|
|
2573
|
+
justStarted ? { waitForSourceMs: 15_000 } : {},
|
|
2574
|
+
);
|
|
2575
|
+
process.exit(code === 0 ? 0 : 1);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
private async cmdListServices(): Promise<void> {
|
|
2579
|
+
await this.ensureWorkspaceSeeded();
|
|
2580
|
+
const spinner = ora("Loading services...").start();
|
|
2581
|
+
const catalog = readCatalog();
|
|
2582
|
+
const results = await checkCatalogHealth(catalog);
|
|
2583
|
+
spinner.stop();
|
|
2584
|
+
const noAutostart = this.noAutostartSet();
|
|
2585
|
+
|
|
2586
|
+
if (results.length === 0) {
|
|
2587
|
+
console.log(chalk.yellow("\n No services registered.\n"));
|
|
2588
|
+
console.log(
|
|
2589
|
+
chalk.gray(` Register a repo: ${product().cli} register <path>\n`),
|
|
2590
|
+
);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
const table = new Table({
|
|
2595
|
+
head: ["Service", "Mode", "Health", "Commands"],
|
|
2596
|
+
style: { head: ["cyan"] },
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
for (const r of results) {
|
|
2600
|
+
const healthStr = formatHealthDot(r.health);
|
|
2601
|
+
|
|
2602
|
+
let cmds: string[] = [];
|
|
2603
|
+
try {
|
|
2604
|
+
cmds = Object.keys(localCommandsFor(r, this.focusedWs()));
|
|
2605
|
+
} catch {
|
|
2606
|
+
/* ignore */
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
table.push([
|
|
2610
|
+
this.labelWithAutostart(r.address, noAutostart),
|
|
2611
|
+
r.mode === "dev-managed"
|
|
2612
|
+
? chalk.blue("dev-managed")
|
|
2613
|
+
: chalk.magenta("managed"),
|
|
2614
|
+
healthStr,
|
|
2615
|
+
cmds.join(", "),
|
|
2616
|
+
]);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
console.log(table.toString());
|
|
2620
|
+
if (
|
|
2621
|
+
results.some((r) => noAutostart.has(parseAddress(r.address).repoName))
|
|
2622
|
+
) {
|
|
2623
|
+
console.log(
|
|
2624
|
+
chalk.gray(" ⏸ auto-start off — not started by workspace up"),
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
console.log();
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
private async cmdRegister(args: string[]): Promise<void> {
|
|
2631
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2632
|
+
console.log(
|
|
2633
|
+
`\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>]\n\n` +
|
|
2634
|
+
" With a path: add that checkout to the catalog and the focused\n" +
|
|
2635
|
+
" workspace as your own (dev-managed) copy, without prompts. The name\n" +
|
|
2636
|
+
" defaults to the manifest's name, then the folder name; the GitHub\n" +
|
|
2637
|
+
" slug to the git remote.\n" +
|
|
2638
|
+
" Without a path: choose a local checkout or a managed clone interactively.\n",
|
|
2639
|
+
);
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
const ws = getActiveWorkspace();
|
|
2643
|
+
if (!ws) {
|
|
2644
|
+
console.log(
|
|
2645
|
+
chalk.red(
|
|
2646
|
+
`\n No active workspace. Create one first: ${product().cli} workspace create\n`,
|
|
2647
|
+
),
|
|
2648
|
+
);
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
const repoPathArg = args.find(
|
|
2653
|
+
(a, i) => !a.startsWith("-") && !/^--(name|gh)$/.test(args[i - 1] ?? ""),
|
|
2654
|
+
);
|
|
2655
|
+
if (repoPathArg) {
|
|
2656
|
+
const flag = (n: string) => {
|
|
2657
|
+
const i = args.indexOf(n);
|
|
2658
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
2659
|
+
};
|
|
2660
|
+
await this.registerPath(ws, repoPathArg, flag("--name"), flag("--gh"));
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
const source = await cliPrompts.promptForRegistrationSource();
|
|
2665
|
+
|
|
2666
|
+
if (source === "local") {
|
|
2667
|
+
await this.registerLinked(ws);
|
|
2668
|
+
} else {
|
|
2669
|
+
await this.registerManaged(ws);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/** Non-interactive `register <path>`: a dev-managed entry in the focused workspace. */
|
|
2674
|
+
private async registerPath(
|
|
2675
|
+
ws: {
|
|
2676
|
+
name: string;
|
|
2677
|
+
config: import("../../shared/types/index.js").WorkspaceConfig;
|
|
2678
|
+
},
|
|
2679
|
+
rawPath: string,
|
|
2680
|
+
nameArg: string | undefined,
|
|
2681
|
+
ghArg: string | undefined,
|
|
2682
|
+
): Promise<void> {
|
|
2683
|
+
const { existsSync } = await import("fs");
|
|
2684
|
+
const repoPath = nodePath.resolve(rawPath);
|
|
2685
|
+
if (!existsSync(repoPath)) {
|
|
2686
|
+
console.log(chalk.red(`\n ❌ Path does not exist: ${repoPath}\n`));
|
|
2687
|
+
process.exitCode = 1;
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
const manifest = readManifest(repoPath);
|
|
2691
|
+
const repoName = nameArg ?? manifest?.name ?? nodePath.basename(repoPath);
|
|
2692
|
+
const coords = getGitHubCoords(repoPath);
|
|
2693
|
+
const gh = ghArg ?? (coords ? `${coords.owner}/${coords.repo}` : undefined);
|
|
2694
|
+
upsertCatalogEntry(repoName, { gh: gh ?? "" });
|
|
2695
|
+
await this.writeWorkspaceEntries(
|
|
2696
|
+
ws,
|
|
2697
|
+
repoName,
|
|
2698
|
+
manifest,
|
|
2699
|
+
"dev-managed",
|
|
2700
|
+
repoPath,
|
|
2701
|
+
undefined,
|
|
2702
|
+
true,
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/** Register a local checkout as a linked service in the active workspace. */
|
|
2707
|
+
private async registerLinked(ws: {
|
|
2708
|
+
name: string;
|
|
2709
|
+
config: import("../../shared/types/index.js").WorkspaceConfig;
|
|
2710
|
+
}): Promise<void> {
|
|
2711
|
+
const catalog = readCatalog();
|
|
2712
|
+
const repoPath = await cliPrompts.promptForRepoPath(Object.keys(catalog));
|
|
2713
|
+
|
|
2714
|
+
const { existsSync } = await import("fs");
|
|
2715
|
+
if (!existsSync(repoPath)) {
|
|
2716
|
+
console.log(chalk.red(`\n ❌ Path does not exist: ${repoPath}\n`));
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
const manifest = readManifest(repoPath);
|
|
2721
|
+
// Use the path relative to repos root as the catalog key so that repos in
|
|
2722
|
+
// project subdirectories get unique, path-based keys (e.g. "projectA/serviceA").
|
|
2723
|
+
const repoName = nodePath.relative(getReposRoot(), repoPath);
|
|
2724
|
+
|
|
2725
|
+
// Auto-detect GH slug from git remote; fall back to asking
|
|
2726
|
+
let ghSlug: string;
|
|
2727
|
+
const coords = getGitHubCoords(repoPath);
|
|
2728
|
+
if (coords) {
|
|
2729
|
+
ghSlug = `${coords.owner}/${coords.repo}`;
|
|
2730
|
+
} else {
|
|
2731
|
+
ghSlug = await cliPrompts.promptForGhSlug();
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
upsertCatalogEntry(repoName, { gh: ghSlug });
|
|
2735
|
+
|
|
2736
|
+
const linkedPath = await cliPrompts.promptForLinkedPath(repoPath);
|
|
2737
|
+
|
|
2738
|
+
await this.writeWorkspaceEntries(
|
|
2739
|
+
ws,
|
|
2740
|
+
repoName,
|
|
2741
|
+
manifest,
|
|
2742
|
+
"dev-managed",
|
|
2743
|
+
linkedPath,
|
|
2744
|
+
undefined,
|
|
2745
|
+
);
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/** List org repos via `gh`, let user pick one + branch → managed service. */
|
|
2749
|
+
private async registerManaged(ws: {
|
|
2750
|
+
name: string;
|
|
2751
|
+
config: import("../../shared/types/index.js").WorkspaceConfig;
|
|
2752
|
+
}): Promise<void> {
|
|
2753
|
+
// Derive org from den's own git remote
|
|
2754
|
+
const { fileURLToPath: ftu } = await import("url");
|
|
2755
|
+
const { default: pathMod } = await import("path");
|
|
2756
|
+
const distRoot = pathMod.resolve(
|
|
2757
|
+
pathMod.dirname(ftu(import.meta.url)),
|
|
2758
|
+
"../../..",
|
|
2759
|
+
);
|
|
2760
|
+
const denCoords = getGitHubCoords(distRoot);
|
|
2761
|
+
// The instance's config names its org; the checkout's own remote is only a
|
|
2762
|
+
// fallback (inside a package it would name the engine's repo, not the org's).
|
|
2763
|
+
const org = readDenConfig().github_org ?? denCoords?.owner;
|
|
2764
|
+
if (!org || !/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(org)) {
|
|
2765
|
+
console.log(
|
|
2766
|
+
chalk.red(
|
|
2767
|
+
`\n ❌ No GitHub org to list: set github_org in ${product().configFile}\n`,
|
|
2768
|
+
),
|
|
2769
|
+
);
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
console.log(chalk.gray(`\n Fetching repos from ${org}…`));
|
|
2774
|
+
let repos: { name: string; nameWithOwner: string; defaultBranch: string }[];
|
|
2775
|
+
try {
|
|
2776
|
+
const raw = execSync(
|
|
2777
|
+
`gh repo list ${org} --json name,nameWithOwner,defaultBranchRef --limit 100`,
|
|
2778
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
|
|
2779
|
+
);
|
|
2780
|
+
const parsed = JSON.parse(raw) as {
|
|
2781
|
+
name: string;
|
|
2782
|
+
nameWithOwner: string;
|
|
2783
|
+
defaultBranchRef: { name: string };
|
|
2784
|
+
}[];
|
|
2785
|
+
repos = parsed.map((r) => ({
|
|
2786
|
+
name: r.name,
|
|
2787
|
+
nameWithOwner: r.nameWithOwner,
|
|
2788
|
+
defaultBranch: r.defaultBranchRef?.name ?? "main",
|
|
2789
|
+
}));
|
|
2790
|
+
} catch (err) {
|
|
2791
|
+
console.log(
|
|
2792
|
+
chalk.red(
|
|
2793
|
+
`\n ❌ Could not list repos via gh CLI: ${(err as Error).message}\n`,
|
|
2794
|
+
),
|
|
2795
|
+
);
|
|
2796
|
+
console.log(
|
|
2797
|
+
chalk.gray(
|
|
2798
|
+
" Make sure `gh` is installed and authenticated (`gh auth status`).\n",
|
|
2799
|
+
),
|
|
2800
|
+
);
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
if (repos.length === 0) {
|
|
2805
|
+
console.log(chalk.yellow(`\n No repos found under ${org}.\n`));
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
const selected = await cliPrompts.promptForGhOrgRepo(repos);
|
|
2810
|
+
const branch = await cliPrompts.promptForBranch(selected.defaultBranch);
|
|
2811
|
+
|
|
2812
|
+
const repoName = selected.name;
|
|
2813
|
+
upsertCatalogEntry(repoName, { gh: selected.nameWithOwner });
|
|
2814
|
+
console.log(
|
|
2815
|
+
chalk.gray(
|
|
2816
|
+
`\n ✓ Added ${repoName} to catalog (gh: ${selected.nameWithOwner})`,
|
|
2817
|
+
),
|
|
2818
|
+
);
|
|
2819
|
+
|
|
2820
|
+
// For managed repos we may not have the manifest yet (not cloned);
|
|
2821
|
+
// register the repo-level address and let expand handle subsystems later
|
|
2822
|
+
await this.writeWorkspaceEntries(
|
|
2823
|
+
ws,
|
|
2824
|
+
repoName,
|
|
2825
|
+
null,
|
|
2826
|
+
"managed",
|
|
2827
|
+
undefined,
|
|
2828
|
+
branch,
|
|
2829
|
+
);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
/** Write workspace entries for all services in a repo's manifest. */
|
|
2833
|
+
private async writeWorkspaceEntries(
|
|
2834
|
+
ws: {
|
|
2835
|
+
name: string;
|
|
2836
|
+
config: import("../../shared/types/index.js").WorkspaceConfig;
|
|
2837
|
+
},
|
|
2838
|
+
repoName: string,
|
|
2839
|
+
manifest: ReturnType<typeof readManifest>,
|
|
2840
|
+
mode: "dev-managed" | "managed",
|
|
2841
|
+
linkedPath: string | undefined,
|
|
2842
|
+
branch: string | undefined,
|
|
2843
|
+
allServices = false,
|
|
2844
|
+
): Promise<void> {
|
|
2845
|
+
const serviceKeys = Object.keys(manifest?.services ?? {});
|
|
2846
|
+
let addresses: string[];
|
|
2847
|
+
if (allServices) {
|
|
2848
|
+
// The bare repo name expands to every service in the manifest.
|
|
2849
|
+
addresses = [repoName];
|
|
2850
|
+
} else if (serviceKeys.length === 0) {
|
|
2851
|
+
addresses = [repoName];
|
|
2852
|
+
} else if (serviceKeys.length === 1) {
|
|
2853
|
+
// Single-service repo: always use the bare catalog key as the workspace address.
|
|
2854
|
+
// expandMonoRepoEntry corrects the subsystem during resolution.
|
|
2855
|
+
addresses = [repoName];
|
|
2856
|
+
} else {
|
|
2857
|
+
const all = serviceKeys.map((s) =>
|
|
2858
|
+
s === repoName ? repoName : `${repoName}/${s}`,
|
|
2859
|
+
);
|
|
2860
|
+
addresses = await cliPrompts.promptSelectServices(
|
|
2861
|
+
all,
|
|
2862
|
+
`Add which services to workspace "${ws.name}"?`,
|
|
2863
|
+
true,
|
|
2864
|
+
);
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
if (addresses.length === 0) {
|
|
2868
|
+
console.log(chalk.yellow("\n No services selected.\n"));
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
const file = readWorkspacesFile();
|
|
2873
|
+
const svcEntries = file.workspaces[ws.name].services;
|
|
2874
|
+
// Replace ALL existing entries for this repo (bare name + any "repo/subsystem"
|
|
2875
|
+
// addresses) before writing the new ones. Otherwise switching mode (e.g.
|
|
2876
|
+
// managed → dev-managed) on a multi-service repo leaves a stale entry
|
|
2877
|
+
// that silently shadows the new one during resolution. Preserve the per-entry
|
|
2878
|
+
// auto-start toggle across the rewrite.
|
|
2879
|
+
let hadAutostartOff = false;
|
|
2880
|
+
for (const key of Object.keys(svcEntries)) {
|
|
2881
|
+
if (key === repoName || key.startsWith(`${repoName}/`)) {
|
|
2882
|
+
if (svcEntries[key].autostart === false) hadAutostartOff = true;
|
|
2883
|
+
delete svcEntries[key];
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
for (const addr of addresses) {
|
|
2887
|
+
svcEntries[addr] = {
|
|
2888
|
+
mode,
|
|
2889
|
+
...(mode === "dev-managed" && linkedPath ? { path: linkedPath } : {}),
|
|
2890
|
+
...(mode === "managed" && branch ? { branch } : {}),
|
|
2891
|
+
...(hadAutostartOff ? { autostart: false } : {}),
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
writeWorkspacesFile(file);
|
|
2895
|
+
|
|
2896
|
+
console.log(chalk.green(`\n ✓ Added to workspace "${ws.name}":\n`));
|
|
2897
|
+
for (const addr of addresses) {
|
|
2898
|
+
const detail =
|
|
2899
|
+
mode === "managed"
|
|
2900
|
+
? chalk.gray(`managed branch: ${branch ?? "main"}`)
|
|
2901
|
+
: chalk.gray(`dev-managed path: ${linkedPath ?? ""}`);
|
|
2902
|
+
console.log(` ${chalk.bold(addr)} ${detail}`);
|
|
2903
|
+
}
|
|
2904
|
+
console.log();
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
/**
|
|
2908
|
+
* `den use-local [repo]` / `den use-managed [repo]` — switch a repo's
|
|
2909
|
+
* boot source between your local checkout (dev-managed) and a managed
|
|
2910
|
+
* clone, in the active workspace. Defaults to the cwd repo; accepts a name.
|
|
2911
|
+
*/
|
|
2912
|
+
private async cmdUseMode(
|
|
2913
|
+
args: string[],
|
|
2914
|
+
mode: "dev-managed" | "managed",
|
|
2915
|
+
): Promise<void> {
|
|
2916
|
+
const repoArg = args.find((a) => !a.startsWith("-"));
|
|
2917
|
+
const alias = loadAliasResolver();
|
|
2918
|
+
let repo: string | undefined;
|
|
2919
|
+
let cwdRoot: string | undefined;
|
|
2920
|
+
if (repoArg) {
|
|
2921
|
+
const resolved = alias.resolve(repoArg);
|
|
2922
|
+
repo =
|
|
2923
|
+
resolved && alias.knownRepos.has(resolved)
|
|
2924
|
+
? resolved
|
|
2925
|
+
: alias.knownRepos.has(repoArg)
|
|
2926
|
+
? repoArg
|
|
2927
|
+
: undefined;
|
|
2928
|
+
if (!repo) {
|
|
2929
|
+
console.log(chalk.red(`\n Unknown repo: ${repoArg}\n`));
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
} else {
|
|
2933
|
+
const cwd = resolveCwdRepo(alias);
|
|
2934
|
+
if (!cwd) {
|
|
2935
|
+
console.log(
|
|
2936
|
+
chalk.red(
|
|
2937
|
+
"\n Not inside a known repo — run from a repo folder or pass a repo name.\n",
|
|
2938
|
+
),
|
|
2939
|
+
);
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
repo = cwd.repo;
|
|
2943
|
+
cwdRoot = cwd.root;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
if (mode === "dev-managed") {
|
|
2947
|
+
const localPath = cwdRoot ?? nodePath.join(getReposRoot(), repo);
|
|
2948
|
+
if (!fs.existsSync(localPath)) {
|
|
2949
|
+
console.log(
|
|
2950
|
+
chalk.red(`\n No local checkout found at ${localPath}.`) +
|
|
2951
|
+
chalk.gray(
|
|
2952
|
+
`\n Clone it there first, or use '${product().cli} register' to set a custom path.\n`,
|
|
2953
|
+
),
|
|
2954
|
+
);
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
this.switchRepoMode(repo, "dev-managed", localPath, undefined);
|
|
2958
|
+
} else {
|
|
2959
|
+
const branch =
|
|
2960
|
+
args.find((a) => a.startsWith("--branch="))?.split("=")[1] ?? "main";
|
|
2961
|
+
this.switchRepoMode(repo, "managed", undefined, branch);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
/** Rewrite the active workspace's existing entries for a repo with a new mode. */
|
|
2966
|
+
private switchRepoMode(
|
|
2967
|
+
repoName: string,
|
|
2968
|
+
mode: "dev-managed" | "managed",
|
|
2969
|
+
linkedPath: string | undefined,
|
|
2970
|
+
branch: string | undefined,
|
|
2971
|
+
): void {
|
|
2972
|
+
const ws = getActiveWorkspace();
|
|
2973
|
+
if (!ws) {
|
|
2974
|
+
console.log(chalk.red("\n No active workspace.\n"));
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
const file = readWorkspacesFile();
|
|
2978
|
+
const wsFile = file.workspaces[ws.name];
|
|
2979
|
+
if (!wsFile) {
|
|
2980
|
+
console.log(chalk.red(`\n Workspace "${ws.name}" not found.\n`));
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
if (!wsFile.services) wsFile.services = {};
|
|
2984
|
+
const svcEntries = wsFile.services;
|
|
2985
|
+
// Preserve the repo's existing subsystem addresses — just flip mode/path/branch.
|
|
2986
|
+
let keys = Object.keys(svcEntries).filter(
|
|
2987
|
+
(k) => k === repoName || k.startsWith(`${repoName}/`),
|
|
2988
|
+
);
|
|
2989
|
+
const added = keys.length === 0;
|
|
2990
|
+
if (added) keys = [repoName];
|
|
2991
|
+
for (const k of keys) {
|
|
2992
|
+
// Preserve the per-entry auto-start toggle across the mode rewrite.
|
|
2993
|
+
const keepOff = svcEntries[k]?.autostart === false;
|
|
2994
|
+
svcEntries[k] = {
|
|
2995
|
+
mode,
|
|
2996
|
+
...(mode === "dev-managed" && linkedPath ? { path: linkedPath } : {}),
|
|
2997
|
+
...(mode === "managed" ? { branch: branch ?? "main" } : {}),
|
|
2998
|
+
...(keepOff ? { autostart: false } : {}),
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
writeWorkspacesFile(file);
|
|
3002
|
+
|
|
3003
|
+
const detail =
|
|
3004
|
+
mode === "dev-managed"
|
|
3005
|
+
? `dev-managed → ${linkedPath}`
|
|
3006
|
+
: `managed (branch: ${branch ?? "main"})`;
|
|
3007
|
+
console.log(chalk.green(`\n ✓ ${chalk.bold(repoName)} is now ${detail}`));
|
|
3008
|
+
console.log(
|
|
3009
|
+
chalk.gray(
|
|
3010
|
+
` services: ${keys.join(", ")}${added ? " (added to workspace)" : ""}`,
|
|
3011
|
+
),
|
|
3012
|
+
);
|
|
3013
|
+
const revert =
|
|
3014
|
+
mode === "dev-managed"
|
|
3015
|
+
? `${product().cli} use-managed ${repoName}`
|
|
3016
|
+
: `${product().cli} use-local ${repoName}`;
|
|
3017
|
+
console.log(chalk.gray(` revert: ${revert}\n`));
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
private async cmdSetup(args: string[]): Promise<void> {
|
|
3021
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
3022
|
+
const names = args.filter((a) => !a.startsWith("-"));
|
|
3023
|
+
|
|
3024
|
+
// Clone any managed repos that aren't on disk yet before reading manifests.
|
|
3025
|
+
this.cloneMissingManagedRepos();
|
|
3026
|
+
|
|
3027
|
+
const services = getWorkspaceServices();
|
|
3028
|
+
if (services.length === 0) {
|
|
3029
|
+
console.log(
|
|
3030
|
+
chalk.yellow(
|
|
3031
|
+
`\n No services in active workspace. Run: ${product().cli} register\n`,
|
|
3032
|
+
),
|
|
3033
|
+
);
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
let targets =
|
|
3038
|
+
names.length > 0
|
|
3039
|
+
? services.filter(
|
|
3040
|
+
(s) => names.includes(s.address) || names.includes(s.repoName),
|
|
3041
|
+
)
|
|
3042
|
+
: services;
|
|
3043
|
+
|
|
3044
|
+
// Workspace-wide default: MANAGED clones only. `make setup` runs
|
|
3045
|
+
// codegen/dependency installs that dirty a checkout — fine for clones
|
|
3046
|
+
// den owns, uninvited in an engineer's own dev-managed repo. `--force`
|
|
3047
|
+
// (-f) includes dev-managed; naming a repo explicitly always works.
|
|
3048
|
+
if (names.length === 0 && !force) {
|
|
3049
|
+
const skipped = [
|
|
3050
|
+
...new Set(
|
|
3051
|
+
targets
|
|
3052
|
+
.filter((s) => s.mode === "dev-managed")
|
|
3053
|
+
.map((s) => s.repoName),
|
|
3054
|
+
),
|
|
3055
|
+
];
|
|
3056
|
+
targets = targets.filter((s) => s.mode === "managed");
|
|
3057
|
+
if (skipped.length > 0) {
|
|
3058
|
+
console.log(
|
|
3059
|
+
chalk.gray(
|
|
3060
|
+
`\n Skipping dev-managed (your own checkouts): ${skipped.join(", ")}` +
|
|
3061
|
+
`\n Include them with \`${product().cli} setup --force\`, or name one explicitly.`,
|
|
3062
|
+
),
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// One setup per SYSTEM: run against the bare repo (resolves to the
|
|
3068
|
+
// entrypoint), not once per subsystem — several manifests declare `setup`
|
|
3069
|
+
// on multiple services and each run re-dirties the clone with codegen.
|
|
3070
|
+
// An explicitly named "repo/subsystem" address is honored as-is.
|
|
3071
|
+
const explicitAddrs = new Set(
|
|
3072
|
+
names.filter(
|
|
3073
|
+
(n) => n.includes("/") && targets.some((s) => s.address === n),
|
|
3074
|
+
),
|
|
3075
|
+
);
|
|
3076
|
+
const seenRepos = new Set<string>();
|
|
3077
|
+
const runList: Array<{ label: string; target: string; path: string }> = [];
|
|
3078
|
+
for (const svc of targets) {
|
|
3079
|
+
if (explicitAddrs.has(svc.address)) {
|
|
3080
|
+
runList.push({
|
|
3081
|
+
label: svc.address,
|
|
3082
|
+
target: svc.address,
|
|
3083
|
+
path: svc.repoPath,
|
|
3084
|
+
});
|
|
3085
|
+
} else if (!seenRepos.has(svc.repoName)) {
|
|
3086
|
+
seenRepos.add(svc.repoName);
|
|
3087
|
+
runList.push({
|
|
3088
|
+
label: svc.repoName,
|
|
3089
|
+
target: svc.repoName,
|
|
3090
|
+
path: svc.repoPath,
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
for (const item of runList) {
|
|
3096
|
+
console.log(
|
|
3097
|
+
chalk.cyan(`\n ▶ Setup: ${chalk.bold(item.label)} (${item.path})`),
|
|
3098
|
+
);
|
|
3099
|
+
await this.runServicesCommand([item.target], "setup");
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
private async cmdWorkspace(args: string[]): Promise<void> {
|
|
3104
|
+
const sub = args[0];
|
|
3105
|
+
const file = readWorkspacesFile();
|
|
3106
|
+
const activeSet = new Set(file.active);
|
|
3107
|
+
const focus = file.focus ?? file.active[0];
|
|
3108
|
+
|
|
3109
|
+
switch (sub) {
|
|
3110
|
+
case "list": {
|
|
3111
|
+
const table = new Table({
|
|
3112
|
+
head: ["Workspace", "Active", "Focus", "Services", "Type"],
|
|
3113
|
+
style: { head: ["cyan"] },
|
|
3114
|
+
});
|
|
3115
|
+
for (const [name, cfg] of Object.entries(file.workspaces)) {
|
|
3116
|
+
table.push([
|
|
3117
|
+
chalk.bold(name),
|
|
3118
|
+
activeSet.has(name) ? chalk.green("✓") : "",
|
|
3119
|
+
name === focus ? chalk.cyan("●") : "",
|
|
3120
|
+
Object.keys(cfg.services).join(", ") || chalk.gray("none"),
|
|
3121
|
+
cfg.directory ? chalk.magenta("feature") : chalk.blue("default"),
|
|
3122
|
+
]);
|
|
3123
|
+
}
|
|
3124
|
+
console.log(table.toString());
|
|
3125
|
+
break;
|
|
3126
|
+
}
|
|
3127
|
+
case "activate": {
|
|
3128
|
+
const name = args[1];
|
|
3129
|
+
if (!name) {
|
|
3130
|
+
console.log(
|
|
3131
|
+
chalk.red(`Usage: ${product().cli} workspace activate <name>`),
|
|
3132
|
+
);
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
activateWorkspace(name);
|
|
3136
|
+
console.log(
|
|
3137
|
+
chalk.green(`\n ✓ Activated workspace: ${chalk.bold(name)}\n`),
|
|
3138
|
+
);
|
|
3139
|
+
break;
|
|
3140
|
+
}
|
|
3141
|
+
case "deactivate": {
|
|
3142
|
+
const name = args[1];
|
|
3143
|
+
if (!name) {
|
|
3144
|
+
console.log(
|
|
3145
|
+
chalk.red(`Usage: ${product().cli} workspace deactivate <name>`),
|
|
3146
|
+
);
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
deactivateWorkspace(name);
|
|
3150
|
+
// Release AFTER the workspace has left active[] so shared services see
|
|
3151
|
+
// the post-deactivation owner set (design decision 5).
|
|
3152
|
+
const releases = await releaseWorkspaceDaemons(name, (line) =>
|
|
3153
|
+
console.log(chalk.gray(` ${line}`)),
|
|
3154
|
+
);
|
|
3155
|
+
console.log(
|
|
3156
|
+
chalk.green(`\n ✓ Deactivated workspace: ${chalk.bold(name)}`),
|
|
3157
|
+
);
|
|
3158
|
+
for (const release of releases) {
|
|
3159
|
+
switch (release.outcome) {
|
|
3160
|
+
case "detached":
|
|
3161
|
+
console.log(
|
|
3162
|
+
chalk.gray(
|
|
3163
|
+
` ${release.address} — still running for [${release.remaining.join(", ")}]`,
|
|
3164
|
+
),
|
|
3165
|
+
);
|
|
3166
|
+
break;
|
|
3167
|
+
case "stopped":
|
|
3168
|
+
console.log(chalk.gray(` ${release.address} — stopped`));
|
|
3169
|
+
break;
|
|
3170
|
+
case "stop-failed":
|
|
3171
|
+
console.log(
|
|
3172
|
+
chalk.yellow(
|
|
3173
|
+
` ${release.address} — stop failed; run \`${product().cli} down --force ${release.address}\``,
|
|
3174
|
+
),
|
|
3175
|
+
);
|
|
3176
|
+
break;
|
|
3177
|
+
default: {
|
|
3178
|
+
const _exhaustive: never = release.outcome;
|
|
3179
|
+
throw new Error(`unhandled release outcome: ${_exhaustive}`);
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
console.log("");
|
|
3184
|
+
break;
|
|
3185
|
+
}
|
|
3186
|
+
case "use": {
|
|
3187
|
+
const name = args[1];
|
|
3188
|
+
if (!name) {
|
|
3189
|
+
console.log(
|
|
3190
|
+
chalk.red(`Usage: ${product().cli} workspace use <name>`),
|
|
3191
|
+
);
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
setActiveWorkspace(name);
|
|
3195
|
+
console.log(
|
|
3196
|
+
chalk.green(
|
|
3197
|
+
`\n ✓ Activated and focused workspace: ${chalk.bold(name)}\n`,
|
|
3198
|
+
),
|
|
3199
|
+
);
|
|
3200
|
+
break;
|
|
3201
|
+
}
|
|
3202
|
+
case "create": {
|
|
3203
|
+
const name =
|
|
3204
|
+
args[1] ??
|
|
3205
|
+
(await cliPrompts.promptForWorkspaceName(
|
|
3206
|
+
Object.keys(file.workspaces),
|
|
3207
|
+
));
|
|
3208
|
+
if (file.workspaces[name]) {
|
|
3209
|
+
console.log(
|
|
3210
|
+
chalk.yellow(`\n Workspace "${name}" already exists.\n`),
|
|
3211
|
+
);
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
createWorkspace(name, { services: {} });
|
|
3215
|
+
console.log(
|
|
3216
|
+
chalk.green(`\n ✓ Created workspace: ${chalk.bold(name)}`),
|
|
3217
|
+
);
|
|
3218
|
+
console.log(
|
|
3219
|
+
chalk.gray(` Edit config/local/workspaces.yml to add services\n`),
|
|
3220
|
+
);
|
|
3221
|
+
break;
|
|
3222
|
+
}
|
|
3223
|
+
case "delete": {
|
|
3224
|
+
const name = args[1];
|
|
3225
|
+
if (!name) {
|
|
3226
|
+
console.log(
|
|
3227
|
+
chalk.red(`Usage: ${product().cli} workspace delete <name>`),
|
|
3228
|
+
);
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
3231
|
+
const confirmed =
|
|
3232
|
+
args.includes("--yes") ||
|
|
3233
|
+
args.includes("-y") ||
|
|
3234
|
+
(await cliPrompts.confirm(
|
|
3235
|
+
`Delete workspace "${name}"? This will remove ${product().workspacesDir}/${name}/`,
|
|
3236
|
+
false,
|
|
3237
|
+
));
|
|
3238
|
+
if (!confirmed) return;
|
|
3239
|
+
deleteWorkspaceDirectory(name);
|
|
3240
|
+
deleteWorkspaceConfig(name);
|
|
3241
|
+
console.log(chalk.green(`\n ✓ Deleted workspace: ${name}\n`));
|
|
3242
|
+
break;
|
|
3243
|
+
}
|
|
3244
|
+
case "code": {
|
|
3245
|
+
const name = args[1] ?? focus;
|
|
3246
|
+
const wsCfg = file.workspaces[name];
|
|
3247
|
+
if (!wsCfg) {
|
|
3248
|
+
console.log(chalk.red(` Workspace "${name}" not found\n`));
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
const editor = readUserConfig().editor ?? "";
|
|
3252
|
+
const vscodeLike = new Set(["vscode", "cursor"]);
|
|
3253
|
+
const jetbrains = new Set(["webstorm", "idea"]);
|
|
3254
|
+
const catalog = readCatalog();
|
|
3255
|
+
|
|
3256
|
+
if (vscodeLike.has(editor)) {
|
|
3257
|
+
const wsFilePath = generateVSCodeWorkspace(name, wsCfg, catalog);
|
|
3258
|
+
console.log(chalk.green(`\n ✓ ${wsFilePath}\n`));
|
|
3259
|
+
try {
|
|
3260
|
+
execSync(
|
|
3261
|
+
`${editor === "cursor" ? "cursor" : "code"} "${wsFilePath}"`,
|
|
3262
|
+
{ stdio: "ignore" },
|
|
3263
|
+
);
|
|
3264
|
+
} catch {
|
|
3265
|
+
console.log(chalk.gray(` Run: code "${wsFilePath}"\n`));
|
|
3266
|
+
}
|
|
3267
|
+
} else if (jetbrains.has(editor)) {
|
|
3268
|
+
syncJetBrainsWorkspaceSymlinks(file);
|
|
3269
|
+
// ws.directory is stored relative to the den root (see
|
|
3270
|
+
// workspace-routes.ts) — resolve against that anchor, never cwd.
|
|
3271
|
+
const wsDir = wsCfg.directory
|
|
3272
|
+
? nodePath.resolve(getDistRoot(), wsCfg.directory)
|
|
3273
|
+
: nodePath.join(DEN_WORKSPACES_DIR, name);
|
|
3274
|
+
console.log(chalk.green(`\n ✓ ${wsDir}\n`));
|
|
3275
|
+
try {
|
|
3276
|
+
execSync(`${editor} "${wsDir}"`, { stdio: "ignore" });
|
|
3277
|
+
} catch {
|
|
3278
|
+
console.log(chalk.gray(` Run: ${editor} "${wsDir}"\n`));
|
|
3279
|
+
}
|
|
3280
|
+
} else {
|
|
3281
|
+
console.log(
|
|
3282
|
+
chalk.yellow(
|
|
3283
|
+
`\n ⚠ No workspace-level open support for editor "${editor || "(none set)"}".\n` +
|
|
3284
|
+
` Open individual repos from the graph or: ${product().cli} settings → set editor\n`,
|
|
3285
|
+
),
|
|
3286
|
+
);
|
|
3287
|
+
}
|
|
3288
|
+
break;
|
|
3289
|
+
}
|
|
3290
|
+
default:
|
|
3291
|
+
console.log(
|
|
3292
|
+
chalk.gray(
|
|
3293
|
+
`Usage: ${product().cli} workspace <list|activate|deactivate|use|create|delete|code> [name]`,
|
|
3294
|
+
),
|
|
3295
|
+
);
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
private async cmdSync(args: string[]): Promise<void> {
|
|
3300
|
+
const allServices = getWorkspaceServices();
|
|
3301
|
+
if (allServices.length === 0) {
|
|
3302
|
+
console.log(
|
|
3303
|
+
chalk.yellow("\n No services in active workspace to sync.\n"),
|
|
3304
|
+
);
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
// Deduplicate by repoPath — one pull per unique checkout
|
|
3309
|
+
const seen = new Set<string>();
|
|
3310
|
+
const targets = allServices.filter((s) => {
|
|
3311
|
+
if (
|
|
3312
|
+
args.length > 0 &&
|
|
3313
|
+
!args.includes(s.address) &&
|
|
3314
|
+
!args.includes(s.repoName)
|
|
3315
|
+
)
|
|
3316
|
+
return false;
|
|
3317
|
+
if (seen.has(s.repoPath)) return false;
|
|
3318
|
+
seen.add(s.repoPath);
|
|
3319
|
+
return true;
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
// Bootstrap: a managed repo that was never cloned can't be checked,
|
|
3323
|
+
// let alone pulled — and syncing it IS the intent. Clone + set it up first
|
|
3324
|
+
// (marker-aware: a previously failed setup is repaired too). After this,
|
|
3325
|
+
// the normal check below reports it up to date.
|
|
3326
|
+
const missing = [
|
|
3327
|
+
...new Set(
|
|
3328
|
+
targets
|
|
3329
|
+
.filter((s) => s.mode === "managed" && !fs.existsSync(s.repoPath))
|
|
3330
|
+
.map((s) => s.repoName),
|
|
3331
|
+
),
|
|
3332
|
+
];
|
|
3333
|
+
if (missing.length > 0) {
|
|
3334
|
+
console.log(
|
|
3335
|
+
chalk.cyan(
|
|
3336
|
+
`\n Cloning ${missing.length} managed repo(s) not on disk yet: ${missing.join(", ")}`,
|
|
3337
|
+
),
|
|
3338
|
+
);
|
|
3339
|
+
await this.ensureManagedClonesFor(missing);
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
console.log();
|
|
3343
|
+
for (const svc of targets) {
|
|
3344
|
+
const spinner = ora(`Checking ${chalk.bold(svc.repoName)}...`).start();
|
|
3345
|
+
const behind = checkBehindCount(svc.repoPath);
|
|
3346
|
+
spinner.stop();
|
|
3347
|
+
|
|
3348
|
+
if (behind === -1) {
|
|
3349
|
+
console.log(
|
|
3350
|
+
chalk.gray(` ${svc.repoName}: unable to check (no upstream?)`),
|
|
3351
|
+
);
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
if (behind === 0) {
|
|
3355
|
+
console.log(chalk.green(` ✓ ${svc.repoName}: up to date`));
|
|
3356
|
+
continue;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
const label =
|
|
3360
|
+
svc.mode === "dev-managed"
|
|
3361
|
+
? chalk.blue("[dev-managed]")
|
|
3362
|
+
: chalk.magenta("[managed]");
|
|
3363
|
+
console.log(
|
|
3364
|
+
chalk.yellow(
|
|
3365
|
+
` ${svc.repoName} ${label}: ${behind} commit${behind === 1 ? "" : "s"} behind`,
|
|
3366
|
+
),
|
|
3367
|
+
);
|
|
3368
|
+
|
|
3369
|
+
if (svc.mode === "dev-managed") {
|
|
3370
|
+
console.log(chalk.gray(` Dev-managed — pull in your own checkout.`));
|
|
3371
|
+
continue;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// Managed checkouts are den's to keep fresh — pull without
|
|
3375
|
+
// prompting (dev-managed repos above are never touched).
|
|
3376
|
+
const pullSpinner = ora(`Pulling ${svc.repoName}...`).start();
|
|
3377
|
+
try {
|
|
3378
|
+
pullRepo(svc.repoPath);
|
|
3379
|
+
pullSpinner.succeed(`Pulled ${svc.repoName}`);
|
|
3380
|
+
} catch (e) {
|
|
3381
|
+
pullSpinner.fail(
|
|
3382
|
+
`Failed to pull ${svc.repoName}: ${(e as Error).message}`,
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
console.log();
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// ── den validate ────────────────────────────────────────────────────────
|
|
3390
|
+
|
|
3391
|
+
private async cmdValidate(args: string[]): Promise<void> {
|
|
3392
|
+
const target = args[0] ?? getInvokeCwd();
|
|
3393
|
+
const fs = await import("fs");
|
|
3394
|
+
const nodePath = await import("path");
|
|
3395
|
+
|
|
3396
|
+
let input: string;
|
|
3397
|
+
if (target.startsWith("http://") || target.startsWith("https://")) {
|
|
3398
|
+
const { default: https } = await import("https");
|
|
3399
|
+
const { default: http } = await import("http");
|
|
3400
|
+
const fetcher = target.startsWith("https://") ? https : http;
|
|
3401
|
+
input = await new Promise<string>((resolve, reject) => {
|
|
3402
|
+
const req = fetcher.get(target, (res) => {
|
|
3403
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
3404
|
+
res.resume();
|
|
3405
|
+
reject(new Error(`HTTP ${res.statusCode} fetching ${target}`));
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
let data = "";
|
|
3409
|
+
res.on("data", (c: Buffer) => (data += c));
|
|
3410
|
+
res.on("end", () => resolve(data));
|
|
3411
|
+
res.on("error", reject);
|
|
3412
|
+
});
|
|
3413
|
+
// Connection-level failures (DNS/ECONNREFUSED) fire on the request, not
|
|
3414
|
+
// the response — without this listener they crash the process.
|
|
3415
|
+
req.on("error", reject);
|
|
3416
|
+
});
|
|
3417
|
+
} else {
|
|
3418
|
+
const filePath = fs.existsSync(target)
|
|
3419
|
+
? fs.lstatSync(target).isDirectory()
|
|
3420
|
+
? nodePath.join(target, `${product().manifestYml}`)
|
|
3421
|
+
: target
|
|
3422
|
+
: target;
|
|
3423
|
+
|
|
3424
|
+
if (!fs.existsSync(filePath)) {
|
|
3425
|
+
console.error(chalk.red(`\n ✗ File not found: ${filePath}\n`));
|
|
3426
|
+
process.exit(1);
|
|
3427
|
+
}
|
|
3428
|
+
input = fs.readFileSync(filePath, "utf-8") as string;
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
const result = validateManifest(input);
|
|
3432
|
+
const label = chalk.bold(
|
|
3433
|
+
target.startsWith("http") ? target : nodePath.resolve(target),
|
|
3434
|
+
);
|
|
3435
|
+
|
|
3436
|
+
console.log();
|
|
3437
|
+
if (result.valid && result.warnings.length === 0) {
|
|
3438
|
+
console.log(
|
|
3439
|
+
chalk.green(` ✓ ${label}`) +
|
|
3440
|
+
chalk.gray(` (v${result.version}, no issues)`),
|
|
3441
|
+
);
|
|
3442
|
+
} else if (result.valid) {
|
|
3443
|
+
console.log(
|
|
3444
|
+
chalk.yellow(` ⚠ ${label}`) +
|
|
3445
|
+
chalk.gray(
|
|
3446
|
+
` (v${result.version}, ${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"})`,
|
|
3447
|
+
),
|
|
3448
|
+
);
|
|
3449
|
+
} else {
|
|
3450
|
+
console.log(
|
|
3451
|
+
chalk.red(` ✗ ${label}`) +
|
|
3452
|
+
chalk.gray(
|
|
3453
|
+
` (v${result.version}, ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}${result.warnings.length > 0 ? `, ${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"}` : ""})`,
|
|
3454
|
+
),
|
|
3455
|
+
);
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
for (const err of result.errors) {
|
|
3459
|
+
const path = err.path ? chalk.gray(err.path) + " " : "";
|
|
3460
|
+
console.log(` ${chalk.red("ERROR")} ${path}${err.message}`);
|
|
3461
|
+
if (err.suggestion) {
|
|
3462
|
+
console.log(` ${chalk.gray("→ " + err.suggestion)}`);
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
for (const warn of result.warnings) {
|
|
3466
|
+
const path = warn.path ? chalk.gray(warn.path) + " " : "";
|
|
3467
|
+
console.log(` ${chalk.yellow("WARNING")} ${path}${warn.message}`);
|
|
3468
|
+
if (warn.suggestion) {
|
|
3469
|
+
console.log(` ${chalk.gray("→ " + warn.suggestion)}`);
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
console.log();
|
|
3474
|
+
if (!result.valid) process.exit(1);
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
// ── den local-dev ───────────────────────────────────────────────────────
|
|
3478
|
+
|
|
3479
|
+
private async cmdLocalDev(arg?: string): Promise<void> {
|
|
3480
|
+
if (arg === "on" || arg === "off") {
|
|
3481
|
+
const enabled = arg === "on";
|
|
3482
|
+
mergeUserConfig({ local_dev: enabled });
|
|
3483
|
+
console.log(
|
|
3484
|
+
chalk.green(
|
|
3485
|
+
`\n ✓ Local dev experience ${enabled ? "enabled" : "disabled"}.\n`,
|
|
3486
|
+
) +
|
|
3487
|
+
chalk.gray(
|
|
3488
|
+
` Restart the web server for the change to take effect.\n`,
|
|
3489
|
+
),
|
|
3490
|
+
);
|
|
3491
|
+
return;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
// Interactive toggle
|
|
3495
|
+
const current = readUserConfig().local_dev ?? true;
|
|
3496
|
+
console.log(
|
|
3497
|
+
`\n Local dev experience is currently: ${current ? chalk.green("enabled") : chalk.red("disabled")}`,
|
|
3498
|
+
);
|
|
3499
|
+
const answer = await cliPrompts.confirm(
|
|
3500
|
+
` ${current ? "Disable" : "Enable"} it?`,
|
|
3501
|
+
!current,
|
|
3502
|
+
);
|
|
3503
|
+
if (answer !== current) {
|
|
3504
|
+
mergeUserConfig({ local_dev: answer });
|
|
3505
|
+
console.log(
|
|
3506
|
+
chalk.green(`\n ✓ Local dev ${answer ? "enabled" : "disabled"}.`) +
|
|
3507
|
+
chalk.gray(" Restart web server to apply.\n"),
|
|
3508
|
+
);
|
|
3509
|
+
} else {
|
|
3510
|
+
console.log(chalk.gray("\n No change.\n"));
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
/**
|
|
3515
|
+
* Display help
|
|
3516
|
+
*/
|
|
3517
|
+
private displayHelp(): void {
|
|
3518
|
+
this.displayHelpBanner();
|
|
3519
|
+
console.log("Usage:");
|
|
3520
|
+
console.log(` ${product().cli} [command] [options]`);
|
|
3521
|
+
console.log("\nEnvironment commands:");
|
|
3522
|
+
console.log(
|
|
3523
|
+
" up [repo] [-x] [-n] Start the cwd (or named) repo's derived dependency closure",
|
|
3524
|
+
);
|
|
3525
|
+
console.log(
|
|
3526
|
+
" (default incl self · -x deps-only · -a all systems · -n dry-run · --plan · --manifest)",
|
|
3527
|
+
);
|
|
3528
|
+
console.log(
|
|
3529
|
+
" down [repo] Stop the derived closure cleanly (-a all systems · --manifest for legacy)",
|
|
3530
|
+
);
|
|
3531
|
+
console.log(
|
|
3532
|
+
" restart [service...] Restart services (-A <app> for a whole application)",
|
|
3533
|
+
);
|
|
3534
|
+
console.log(
|
|
3535
|
+
" status Show service health for active workspace (-A <app>)",
|
|
3536
|
+
);
|
|
3537
|
+
console.log(
|
|
3538
|
+
" apps Org > Application > Repo > Services (--json)",
|
|
3539
|
+
);
|
|
3540
|
+
console.log(
|
|
3541
|
+
" logs [repo] Tail a repo's local logs (offers to start it if down)",
|
|
3542
|
+
);
|
|
3543
|
+
console.log(
|
|
3544
|
+
" graph [repo] Derive & show the system dependency graph (--all, --diff, --format=)",
|
|
3545
|
+
);
|
|
3546
|
+
console.log(" services List all registered services");
|
|
3547
|
+
console.log(
|
|
3548
|
+
" use-local [repo] Use your local checkout (dev-managed) — defaults to the cwd repo",
|
|
3549
|
+
);
|
|
3550
|
+
console.log(
|
|
3551
|
+
` use-managed [repo] Switch back to a managed clone — defaults to the cwd repo`,
|
|
3552
|
+
);
|
|
3553
|
+
console.log(
|
|
3554
|
+
" register [path] Register a repo in the service catalog",
|
|
3555
|
+
);
|
|
3556
|
+
console.log();
|
|
3557
|
+
console.log(" setup [service...] Run make setup for services");
|
|
3558
|
+
console.log(" sync [service...] Check for updates and offer to pull");
|
|
3559
|
+
console.log(
|
|
3560
|
+
" create <name> [dir] Scaffold a new distribution of your own (--title, --cli, --env-prefix)",
|
|
3561
|
+
);
|
|
3562
|
+
console.log();
|
|
3563
|
+
console.log(" workspace list List workspaces");
|
|
3564
|
+
console.log(
|
|
3565
|
+
" workspace list List workspaces (active set + focus)",
|
|
3566
|
+
);
|
|
3567
|
+
console.log(" workspace activate <n> Add workspace to the active set");
|
|
3568
|
+
console.log(
|
|
3569
|
+
" workspace deactivate <n> Remove workspace from the active set",
|
|
3570
|
+
);
|
|
3571
|
+
console.log(" workspace use <n> Activate and focus a workspace");
|
|
3572
|
+
console.log(" workspace create [n] Create a new workspace");
|
|
3573
|
+
console.log(" workspace delete <n> Delete a workspace");
|
|
3574
|
+
console.log(
|
|
3575
|
+
" workspace code [n] Open workspace in VS Code (generates .code-workspace)",
|
|
3576
|
+
);
|
|
3577
|
+
console.log("\nTest commands:");
|
|
3578
|
+
console.log(
|
|
3579
|
+
" menu Open the interactive menu (pillar/module/env picker)",
|
|
3580
|
+
);
|
|
3581
|
+
console.log(
|
|
3582
|
+
" run Run tests (interactive or with options)",
|
|
3583
|
+
);
|
|
3584
|
+
console.log(" view-results View test results");
|
|
3585
|
+
console.log(
|
|
3586
|
+
` web [up|down|restart] Start, stop, or restart ${product().title} web`,
|
|
3587
|
+
);
|
|
3588
|
+
console.log("\nOptions:");
|
|
3589
|
+
console.log(" --help, -h Show this help message");
|
|
3590
|
+
console.log(" --version, -v Show version");
|
|
3591
|
+
console.log(
|
|
3592
|
+
" -w, --workspace <name> Use a workspace for this command only",
|
|
3593
|
+
);
|
|
3594
|
+
console.log(
|
|
3595
|
+
" -t, --tabs Run up/restart commands in terminal tabs",
|
|
3596
|
+
);
|
|
3597
|
+
console.log(" --no-tabs Force daemon mode for up/restart");
|
|
3598
|
+
console.log(
|
|
3599
|
+
" --skip-secrets Skip local secrets preflight for up/restart/logs",
|
|
3600
|
+
);
|
|
3601
|
+
console.log("\nExamples:");
|
|
3602
|
+
console.log(` ${product().cli} --help`);
|
|
3603
|
+
console.log(` ${product().cli} web`);
|
|
3604
|
+
console.log(` ${product().cli} web restart --no-open`);
|
|
3605
|
+
console.log(` ${product().cli} web down`);
|
|
3606
|
+
console.log(` ${product().cli} web --port 8080`);
|
|
3607
|
+
console.log(` ${product().cli} up`);
|
|
3608
|
+
console.log(` ${product().cli} up --skip-secrets`);
|
|
3609
|
+
console.log(` ${product().cli} up -w feat-streaming shop/api`);
|
|
3610
|
+
console.log(` ${product().cli} up --tabs shop/api`);
|
|
3611
|
+
console.log(` ${product().cli} up shop/api tasks-runner`);
|
|
3612
|
+
console.log(` ${product().cli} status`);
|
|
3613
|
+
console.log(` ${product().cli} workspace use feat-streaming`);
|
|
3614
|
+
console.log();
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
/**
|
|
3618
|
+
* Display version
|
|
3619
|
+
*/
|
|
3620
|
+
private displayVersion(): void {
|
|
3621
|
+
// Den's own version: the engine's package.json, not the distribution's.
|
|
3622
|
+
const root = getEngineRoot();
|
|
3623
|
+
let packageVersion = "?";
|
|
3624
|
+
try {
|
|
3625
|
+
const pkg = JSON.parse(
|
|
3626
|
+
fs.readFileSync(nodePath.join(root, "package.json"), "utf-8"),
|
|
3627
|
+
) as { version?: unknown };
|
|
3628
|
+
if (typeof pkg.version === "string") packageVersion = pkg.version;
|
|
3629
|
+
} catch {
|
|
3630
|
+
/* package version unavailable */
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
let gitVersion = `v${packageVersion}`;
|
|
3634
|
+
try {
|
|
3635
|
+
// Only in a Den checkout: an installed package has no .git of its own,
|
|
3636
|
+
// and git would describe the distribution's repo instead.
|
|
3637
|
+
if (!fs.existsSync(nodePath.join(root, ".git"))) throw new Error();
|
|
3638
|
+
gitVersion = execSync("git describe --tags --dirty --always", {
|
|
3639
|
+
cwd: root,
|
|
3640
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
3641
|
+
encoding: "utf-8",
|
|
3642
|
+
}).trim();
|
|
3643
|
+
} catch {
|
|
3644
|
+
/* git metadata unavailable */
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
console.log(`${product().title} v${packageVersion} (${gitVersion})`);
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
// Main entry point
|
|
3652
|
+
const cli = new DenCLI();
|
|
3653
|
+
cli.run().catch((error) => {
|
|
3654
|
+
console.error(chalk.red(`\n❌ Fatal error: ${error.message}`));
|
|
3655
|
+
process.exit(1);
|
|
3656
|
+
});
|