@decantr/cli 2.3.0 → 2.4.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/README.md +36 -4
- package/dist/bin.js +2 -2
- package/dist/chunk-AUQXYJ7T.js +316 -0
- package/dist/{chunk-FSZ6OIAC.js → chunk-H6TIXG5K.js} +896 -272
- package/dist/{chunk-3H3HWDJA.js → chunk-OD46PCR6.js} +354 -17
- package/dist/{chunk-WDA4SHIQ.js → chunk-PEGMSXDJ.js} +66 -7
- package/dist/{health-EENY3BFS.js → health-ZXOPGNBZ.js} +5 -1
- package/dist/index.js +2 -2
- package/dist/{studio-TBJPZZHA.js → studio-LHQXHBE7.js} +63 -1
- package/dist/{upgrade-PL755AF7.js → upgrade-XTTGHFG7.js} +1 -1
- package/dist/workspace-MOLAGT2B.js +21 -0
- package/package.json +22 -5
- package/src/templates/decantr-health.workflow.yml.template +2 -2
package/README.md
CHANGED
|
@@ -23,8 +23,13 @@ Use `decantr init` to attach Decantr contract/context files to an existing proje
|
|
|
23
23
|
|
|
24
24
|
Current starter adapter availability:
|
|
25
25
|
|
|
26
|
-
- `react-vite` is the runnable bootstrap adapter
|
|
26
|
+
- `react-vite` is the React + Vite runnable bootstrap adapter
|
|
27
27
|
- `next-app` is the runnable Next.js App Router adapter
|
|
28
|
+
- `vanilla-vite` is the plain HTML/CSS/JS runnable bootstrap adapter
|
|
29
|
+
- `vue-vite` is the Vue 3 + Vite runnable bootstrap adapter
|
|
30
|
+
- `sveltekit` is the SvelteKit runnable bootstrap adapter
|
|
31
|
+
- `angular` is the Angular standalone runnable bootstrap adapter
|
|
32
|
+
- `solid-vite` is the Solid + Vite runnable bootstrap adapter
|
|
28
33
|
- other contract targets use the `generic-web` contract-only adapter until their runnable adapters land
|
|
29
34
|
|
|
30
35
|
Explicit workflow/adoption flags:
|
|
@@ -60,9 +65,10 @@ Brownfield analysis also writes `.decantr/doctrine-map.json`, a ranked source-pr
|
|
|
60
65
|
- supports explicit workflow lanes: greenfield blueprint, greenfield contract-only, brownfield adoption, and hybrid composition
|
|
61
66
|
- generates execution-pack context files for AI coding assistants
|
|
62
67
|
- audits projects against Decantr contracts
|
|
63
|
-
- produces local Project Health reports and a localhost Studio dashboard for end-user drift triage
|
|
68
|
+
- produces local Project Health reports, Evidence Bundles, workspace health, and a localhost Studio dashboard for end-user drift triage
|
|
64
69
|
- audits local registry content repositories with Content Health reports for schema, reference, and quality coverage
|
|
65
70
|
- searches the registry and showcase benchmark corpus
|
|
71
|
+
- syncs paginated hosted registry content into a full slug-keyed local cache for offline guards and context generation
|
|
66
72
|
- validates, refreshes, and maintains `decantr.essence.json`
|
|
67
73
|
|
|
68
74
|
## Common Commands
|
|
@@ -82,6 +88,7 @@ decantr check
|
|
|
82
88
|
decantr health --ci --fail-on error
|
|
83
89
|
decantr studio --port 4319 --host 127.0.0.1
|
|
84
90
|
decantr telemetry status
|
|
91
|
+
decantr telemetry explain
|
|
85
92
|
decantr telemetry link --enable --org <org-slug>
|
|
86
93
|
decantr content-health --ci --fail-on error
|
|
87
94
|
decantr registry summary --namespace @official --json
|
|
@@ -99,15 +106,31 @@ decantr health --markdown --output health.md
|
|
|
99
106
|
decantr health --ci --fail-on error
|
|
100
107
|
decantr health --ci --fail-on warn
|
|
101
108
|
decantr health --prompt <finding-id>
|
|
109
|
+
decantr health --evidence --output .decantr/evidence/latest.json
|
|
110
|
+
decantr health --browser --base-url http://localhost:3000 --evidence
|
|
111
|
+
decantr health --design-tokens .decantr/design/figma-tokens.json
|
|
102
112
|
decantr health --json --output decantr-health.json
|
|
103
113
|
decantr health init-ci
|
|
104
114
|
decantr health init-ci --fail-on warn --cli-version latest --force
|
|
105
115
|
decantr health init-ci --project apps/registry
|
|
116
|
+
decantr health init-ci --workspace
|
|
117
|
+
decantr workspace list
|
|
118
|
+
decantr workspace health --changed --since origin/main
|
|
119
|
+
decantr export --to figma-tokens
|
|
106
120
|
```
|
|
107
121
|
|
|
108
|
-
Use `--json` for machines and schema validation, `--markdown` for CI summaries, and `--prompt <finding-id>` when you want a scoped remediation prompt for an AI assistant. The prompt command prints instructions only; it does not modify source files. `--ci --fail-on error` fails only when blocking errors exist; `--ci --fail-on warn` also fails on warnings.
|
|
122
|
+
Use `--json` for machines and schema validation, `--markdown` for CI summaries, `--evidence` for the privacy-redacted Evidence Bundle, and `--prompt <finding-id>` when you want a scoped remediation prompt for an AI assistant. The prompt command prints instructions only; it does not modify source files. `--browser` uses a project-local Playwright install and a supplied base URL to capture local route screenshots under `.decantr/evidence/screenshots/`; missing Playwright becomes a setup finding, not a crash. `--design-tokens <path>` compares a Tokens Studio/Figma token JSON export against Decantr CSS token names. `--ci --fail-on error` fails only when blocking errors exist; `--ci --fail-on warn` also fails on warnings.
|
|
109
123
|
|
|
110
|
-
`decantr health init-ci` installs `.github/workflows/decantr-health.yml` for GitHub Actions. The generated workflow installs project dependencies, writes
|
|
124
|
+
`decantr health init-ci` installs `.github/workflows/decantr-health.yml` for GitHub Actions. The generated workflow installs project dependencies, writes JSON/markdown health artifacts, gates with `decantr health --ci --fail-on error --markdown --output decantr-health.md`, appends the markdown report to the GitHub step summary, and uploads both files as artifacts. Use `--force` to replace an existing workflow, `--fail-on warn` for stricter repositories, or `--cli-version <version|latest>` to pin the package used by CI. In monorepos, add `--project <path>` from the repository root; dependency install stays at the root while health runs inside the app contract and uploads artifacts from that project path. Use `--workspace` to generate an aggregate gate that runs `decantr workspace health` from the repository root and uploads `.decantr/workspace-health.json` plus `.decantr/workspace-health.md`.
|
|
125
|
+
|
|
126
|
+
`decantr workspace` is the monorepo reliability namespace. It discovers Decantr projects from `.decantr/workspace.json` or by finding `decantr.essence.json` files, runs projects with deterministic ordering, concurrency, per-project timeout, failure isolation, and aggregate JSON, and can limit a run to changed projects:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
decantr workspace list
|
|
130
|
+
decantr workspace health
|
|
131
|
+
decantr workspace health --json --output .decantr/workspace-health.json
|
|
132
|
+
decantr workspace health --changed --since origin/main
|
|
133
|
+
```
|
|
111
134
|
|
|
112
135
|
`decantr studio` starts a local-only dashboard powered by the same report. It uses Node built-ins only and serves `GET /`, `GET /api/health`, and `POST /api/refresh`.
|
|
113
136
|
|
|
@@ -115,10 +138,13 @@ Use `--json` for machines and schema validation, `--markdown` for CI summaries,
|
|
|
115
138
|
decantr studio
|
|
116
139
|
decantr studio --port 4319 --host 127.0.0.1
|
|
117
140
|
decantr studio --report decantr-health.json
|
|
141
|
+
decantr studio --workspace
|
|
118
142
|
```
|
|
119
143
|
|
|
120
144
|
Studio is for local triage, not Decantr admin telemetry. The Overview keeps the first decision simple: pick the issue to fix first, review the full AI repair prompt before copying it, switch to manual guidance or commands, and expand project details when route/runtime/pack evidence matters. The tabs cover Overview, Routes, Drift, Findings, Remediation, CI, and Packs without uploading source code, prompts, file paths, or project data.
|
|
121
145
|
|
|
146
|
+
Workspace Studio uses `decantr workspace health` behind `GET /api/workspace` and `POST /api/workspace/refresh` so large monorepos can triage many Decantr projects from one local dashboard.
|
|
147
|
+
|
|
122
148
|
Use report mode for customer-controlled reporting from CI artifacts:
|
|
123
149
|
|
|
124
150
|
```bash
|
|
@@ -135,12 +161,16 @@ If the project has explicitly enabled Decantr CLI telemetry, `new --telemetry`,
|
|
|
135
161
|
```bash
|
|
136
162
|
decantr telemetry status
|
|
137
163
|
decantr telemetry status --json
|
|
164
|
+
decantr telemetry explain
|
|
165
|
+
decantr telemetry explain --json
|
|
138
166
|
decantr login --api-key=<key>
|
|
139
167
|
decantr telemetry link --enable --org <org-slug>
|
|
140
168
|
```
|
|
141
169
|
|
|
142
170
|
`telemetry link` calls the hosted `/v1/me/telemetry-link` endpoint with only opaque ids, optional org slug, and optional label. The API verifies org membership, writes `telemetry_identity_aliases`, clears the actor-resolution cache, audit logs the change, and emits `telemetry.identity_linked`.
|
|
143
171
|
|
|
172
|
+
`telemetry explain` prints the CLI event catalog subset, aggregate field categories, current opaque ids if they already exist, and the explicit never-collected list. It is designed for security review and customer trust conversations before a team opts in.
|
|
173
|
+
|
|
144
174
|
## Content Health
|
|
145
175
|
|
|
146
176
|
`decantr content-health` is the local supply-chain observability command for registry content repositories such as `decantr-content`. It is separate from Project Health: Project Health checks an end-user app against its Decantr contract, while Content Health checks published content inputs before they flow into the hosted registry.
|
|
@@ -185,6 +215,8 @@ DECANTR_CONTENT_DIR=/path/to/decantr-content decantr new my-app --blueprint=agen
|
|
|
185
215
|
|
|
186
216
|
If a requested offline blueprint, archetype, or theme cannot be resolved from local cache/custom content or `DECANTR_CONTENT_DIR`, the CLI now stops explicitly instead of silently falling back to the default scaffold.
|
|
187
217
|
|
|
218
|
+
Run `decantr sync` before offline-heavy or CI-heavy workflows that depend on hosted registry content. Sync paginates the official registry list endpoints, then fetches and stores each item by slug as a full content record under `.decantr/cache/@official/`. That keeps guard checks, Project Health, and context generation aligned with the canonical registry contract instead of abbreviated public list summaries.
|
|
219
|
+
|
|
188
220
|
## Workflow Certification
|
|
189
221
|
|
|
190
222
|
The broader workflow matrix now has its own certification entrypoint:
|
package/dist/bin.js
CHANGED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createProjectHealthReport
|
|
3
|
+
} from "./chunk-OD46PCR6.js";
|
|
4
|
+
|
|
5
|
+
// src/commands/workspace.ts
|
|
6
|
+
import { execFileSync } from "child_process";
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { dirname, join, relative, resolve } from "path";
|
|
9
|
+
var BOLD = "\x1B[1m";
|
|
10
|
+
var DIM = "\x1B[2m";
|
|
11
|
+
var GREEN = "\x1B[32m";
|
|
12
|
+
var RED = "\x1B[31m";
|
|
13
|
+
var YELLOW = "\x1B[33m";
|
|
14
|
+
var RESET = "\x1B[0m";
|
|
15
|
+
var WORKSPACE_HEALTH_SCHEMA_URL = "https://decantr.ai/schemas/workspace-health-report.v1.json";
|
|
16
|
+
var DEFAULT_IGNORES = /* @__PURE__ */ new Set([
|
|
17
|
+
".git",
|
|
18
|
+
".next",
|
|
19
|
+
".turbo",
|
|
20
|
+
".vercel",
|
|
21
|
+
"coverage",
|
|
22
|
+
"dist",
|
|
23
|
+
"node_modules",
|
|
24
|
+
"playwright-report"
|
|
25
|
+
]);
|
|
26
|
+
function workspaceConfigPath(root) {
|
|
27
|
+
return join(root, ".decantr", "workspace.json");
|
|
28
|
+
}
|
|
29
|
+
function readWorkspaceConfig(root) {
|
|
30
|
+
const path = workspaceConfigPath(root);
|
|
31
|
+
if (!existsSync(path)) return null;
|
|
32
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
33
|
+
}
|
|
34
|
+
function normalizeProjectPath(raw) {
|
|
35
|
+
const normalized = raw.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
36
|
+
if (!normalized || normalized.startsWith("/") || normalized.includes("..") || normalized.includes("\\") || /\s/.test(normalized)) {
|
|
37
|
+
throw new Error(`Invalid workspace project path: ${raw}`);
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
function projectIdFromPath(path) {
|
|
42
|
+
return path.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
43
|
+
}
|
|
44
|
+
function discoverProjectPaths(root, config) {
|
|
45
|
+
const ignored = /* @__PURE__ */ new Set([...config?.ignore ?? [], ...DEFAULT_IGNORES]);
|
|
46
|
+
const results = /* @__PURE__ */ new Set();
|
|
47
|
+
function walk(dir, depth) {
|
|
48
|
+
if (depth > 6) return;
|
|
49
|
+
const rel = relative(root, dir).replace(/\\/g, "/");
|
|
50
|
+
if (rel && [...ignored].some((entry) => rel === entry || rel.startsWith(`${entry}/`))) return;
|
|
51
|
+
if (existsSync(join(dir, "decantr.essence.json"))) {
|
|
52
|
+
results.add(rel || ".");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
57
|
+
if (ignored.has(entry.name)) continue;
|
|
58
|
+
walk(join(dir, entry.name), depth + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
walk(root, 0);
|
|
62
|
+
return [...results].sort();
|
|
63
|
+
}
|
|
64
|
+
function listWorkspaceProjects(root = process.cwd()) {
|
|
65
|
+
const workspaceRoot = resolve(root);
|
|
66
|
+
const config = readWorkspaceConfig(workspaceRoot);
|
|
67
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const project of config?.projects ?? []) {
|
|
69
|
+
const path = normalizeProjectPath(project.path);
|
|
70
|
+
byPath.set(path, {
|
|
71
|
+
id: project.id ?? projectIdFromPath(path),
|
|
72
|
+
path,
|
|
73
|
+
absolutePath: resolve(workspaceRoot, path),
|
|
74
|
+
owner: project.owner ?? null,
|
|
75
|
+
tags: project.tags ?? [],
|
|
76
|
+
criticality: project.criticality ?? "normal",
|
|
77
|
+
browser: project.browser ?? config?.browser ?? false,
|
|
78
|
+
source: "manifest"
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
for (const path of discoverProjectPaths(workspaceRoot, config)) {
|
|
82
|
+
if (byPath.has(path)) continue;
|
|
83
|
+
byPath.set(path, {
|
|
84
|
+
id: projectIdFromPath(path),
|
|
85
|
+
path,
|
|
86
|
+
absolutePath: resolve(workspaceRoot, path),
|
|
87
|
+
owner: null,
|
|
88
|
+
tags: [],
|
|
89
|
+
criticality: "normal",
|
|
90
|
+
browser: config?.browser ?? false,
|
|
91
|
+
source: "auto"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
95
|
+
}
|
|
96
|
+
function changedPaths(root, since) {
|
|
97
|
+
try {
|
|
98
|
+
const output = execFileSync("git", ["diff", "--name-only", since, "--"], {
|
|
99
|
+
cwd: root,
|
|
100
|
+
encoding: "utf-8",
|
|
101
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
102
|
+
});
|
|
103
|
+
return new Set(output.split("\n").map((line) => line.trim()).filter(Boolean));
|
|
104
|
+
} catch {
|
|
105
|
+
return /* @__PURE__ */ new Set();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function projectChanged(project, changed) {
|
|
109
|
+
if (changed.size === 0) return false;
|
|
110
|
+
const prefix = project.path === "." ? "" : `${project.path}/`;
|
|
111
|
+
for (const path of changed) {
|
|
112
|
+
if (project.path === "." || path === project.path || path.startsWith(prefix)) return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
async function withTimeout(promise, timeoutMs, label) {
|
|
117
|
+
let timeout;
|
|
118
|
+
const timer = new Promise((_, reject) => {
|
|
119
|
+
timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
return await Promise.race([promise, timer]);
|
|
123
|
+
} finally {
|
|
124
|
+
if (timeout) clearTimeout(timeout);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function mapLimited(items, concurrency, fn) {
|
|
128
|
+
const results = new Array(items.length);
|
|
129
|
+
let next = 0;
|
|
130
|
+
async function worker() {
|
|
131
|
+
while (next < items.length) {
|
|
132
|
+
const index = next++;
|
|
133
|
+
results[index] = await fn(items[index]);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
async function createWorkspaceHealthReport(root = process.cwd(), options = {}) {
|
|
140
|
+
const workspaceRoot = resolve(root);
|
|
141
|
+
const config = readWorkspaceConfig(workspaceRoot);
|
|
142
|
+
const since = options.since ?? "origin/main";
|
|
143
|
+
const changed = options.changedOnly ? changedPaths(workspaceRoot, since) : /* @__PURE__ */ new Set();
|
|
144
|
+
const allProjects = listWorkspaceProjects(workspaceRoot);
|
|
145
|
+
const projects = options.changedOnly ? allProjects.filter((project) => projectChanged(project, changed)) : allProjects;
|
|
146
|
+
const concurrency = options.concurrency ?? config?.concurrency ?? 4;
|
|
147
|
+
const timeoutMs = options.timeoutMs ?? config?.timeoutMs ?? 12e4;
|
|
148
|
+
const checked = await mapLimited(projects, concurrency, async (project) => {
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
try {
|
|
151
|
+
const report = await withTimeout(
|
|
152
|
+
createProjectHealthReport(project.absolutePath, {
|
|
153
|
+
browser: options.browser ?? project.browser
|
|
154
|
+
}),
|
|
155
|
+
timeoutMs,
|
|
156
|
+
project.path
|
|
157
|
+
);
|
|
158
|
+
return {
|
|
159
|
+
id: project.id,
|
|
160
|
+
path: project.path,
|
|
161
|
+
status: report.status,
|
|
162
|
+
score: report.score,
|
|
163
|
+
errorCount: report.summary.errorCount,
|
|
164
|
+
warnCount: report.summary.warnCount,
|
|
165
|
+
infoCount: report.summary.infoCount,
|
|
166
|
+
findingCount: report.summary.findingCount,
|
|
167
|
+
durationMs: Date.now() - startedAt,
|
|
168
|
+
changed: options.changedOnly ? projectChanged(project, changed) : false,
|
|
169
|
+
source: project.source,
|
|
170
|
+
error: null
|
|
171
|
+
};
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
id: project.id,
|
|
175
|
+
path: project.path,
|
|
176
|
+
status: "failed",
|
|
177
|
+
score: 0,
|
|
178
|
+
errorCount: 1,
|
|
179
|
+
warnCount: 0,
|
|
180
|
+
infoCount: 0,
|
|
181
|
+
findingCount: 1,
|
|
182
|
+
durationMs: Date.now() - startedAt,
|
|
183
|
+
changed: options.changedOnly ? projectChanged(project, changed) : false,
|
|
184
|
+
source: project.source,
|
|
185
|
+
error: error.message
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
$schema: WORKSPACE_HEALTH_SCHEMA_URL,
|
|
191
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
192
|
+
workspaceRoot,
|
|
193
|
+
changedOnly: options.changedOnly ?? false,
|
|
194
|
+
since: options.changedOnly ? since : null,
|
|
195
|
+
summary: {
|
|
196
|
+
projectCount: allProjects.length,
|
|
197
|
+
checkedCount: checked.length,
|
|
198
|
+
healthyCount: checked.filter((project) => project.status === "healthy").length,
|
|
199
|
+
warningCount: checked.filter((project) => project.status === "warning").length,
|
|
200
|
+
errorCount: checked.filter((project) => project.status === "error").length,
|
|
201
|
+
failedCount: checked.filter((project) => project.status === "failed").length
|
|
202
|
+
},
|
|
203
|
+
projects: checked
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function formatWorkspaceHealthText(report) {
|
|
207
|
+
const lines = [
|
|
208
|
+
`${BOLD}Decantr Workspace Health${RESET}`,
|
|
209
|
+
"",
|
|
210
|
+
`Projects: ${report.summary.checkedCount}/${report.summary.projectCount}`,
|
|
211
|
+
`Healthy: ${report.summary.healthyCount} | Warnings: ${report.summary.warningCount} | Errors: ${report.summary.errorCount} | Failed: ${report.summary.failedCount}`,
|
|
212
|
+
""
|
|
213
|
+
];
|
|
214
|
+
for (const project of report.projects) {
|
|
215
|
+
const color = project.status === "healthy" ? GREEN : project.status === "warning" ? YELLOW : RED;
|
|
216
|
+
lines.push(
|
|
217
|
+
`${color}${String(project.status).toUpperCase()}${RESET} ${project.path} score ${project.score}/100 findings ${project.findingCount}`
|
|
218
|
+
);
|
|
219
|
+
if (project.error) lines.push(` ${DIM}${project.error}${RESET}`);
|
|
220
|
+
}
|
|
221
|
+
return `${lines.join("\n")}
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
function formatWorkspaceHealthMarkdown(report) {
|
|
225
|
+
const lines = [
|
|
226
|
+
"# Decantr Workspace Health",
|
|
227
|
+
"",
|
|
228
|
+
`- Projects checked: **${report.summary.checkedCount}/${report.summary.projectCount}**`,
|
|
229
|
+
`- Healthy: ${report.summary.healthyCount}`,
|
|
230
|
+
`- Warnings: ${report.summary.warningCount}`,
|
|
231
|
+
`- Errors: ${report.summary.errorCount}`,
|
|
232
|
+
`- Failed: ${report.summary.failedCount}`,
|
|
233
|
+
"",
|
|
234
|
+
"| Project | Status | Score | Findings | Source |",
|
|
235
|
+
"| --- | --- | ---: | ---: | --- |"
|
|
236
|
+
];
|
|
237
|
+
for (const project of report.projects) {
|
|
238
|
+
lines.push(
|
|
239
|
+
`| \`${project.path}\` | ${project.status} | ${project.score} | ${project.findingCount} | ${project.source} |`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return `${lines.join("\n")}
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
function shouldFailWorkspaceHealth(report, failOn = "error") {
|
|
246
|
+
if (failOn === "none") return false;
|
|
247
|
+
if (report.summary.failedCount > 0 || report.summary.errorCount > 0) return true;
|
|
248
|
+
return failOn === "warn" && report.summary.warningCount > 0;
|
|
249
|
+
}
|
|
250
|
+
function parseHealthFailOn(value) {
|
|
251
|
+
if (value === "warn" || value === "none") return value;
|
|
252
|
+
return "error";
|
|
253
|
+
}
|
|
254
|
+
function parseWorkspaceArgs(args) {
|
|
255
|
+
const subcommand = args[1] === "health" ? "health" : "list";
|
|
256
|
+
const options = { subcommand };
|
|
257
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
258
|
+
const arg = args[index];
|
|
259
|
+
if (arg === "--json") options.json = true;
|
|
260
|
+
else if (arg === "--markdown") options.markdown = true;
|
|
261
|
+
else if (arg === "--ci") options.ci = true;
|
|
262
|
+
else if (arg === "--browser") options.browser = true;
|
|
263
|
+
else if (arg === "--changed") options.changedOnly = true;
|
|
264
|
+
else if (arg === "--since" && args[index + 1]) options.since = args[++index];
|
|
265
|
+
else if (arg.startsWith("--since=")) options.since = arg.split("=")[1];
|
|
266
|
+
else if (arg === "--output" && args[index + 1]) options.output = args[++index];
|
|
267
|
+
else if (arg.startsWith("--output=")) options.output = arg.split("=")[1];
|
|
268
|
+
else if (arg === "--fail-on" && args[index + 1]) options.failOn = parseHealthFailOn(args[++index]);
|
|
269
|
+
else if (arg.startsWith("--fail-on=")) options.failOn = parseHealthFailOn(arg.split("=")[1]);
|
|
270
|
+
else if (arg === "--concurrency" && args[index + 1]) options.concurrency = Number(args[++index]);
|
|
271
|
+
else if (arg.startsWith("--concurrency=")) options.concurrency = Number(arg.split("=")[1]);
|
|
272
|
+
else if (arg === "--timeout-ms" && args[index + 1]) options.timeoutMs = Number(args[++index]);
|
|
273
|
+
else if (arg.startsWith("--timeout-ms=")) options.timeoutMs = Number(arg.split("=")[1]);
|
|
274
|
+
}
|
|
275
|
+
return options;
|
|
276
|
+
}
|
|
277
|
+
async function cmdWorkspace(workspaceRoot = process.cwd(), args = ["workspace"]) {
|
|
278
|
+
const options = parseWorkspaceArgs(args);
|
|
279
|
+
if (options.subcommand === "list") {
|
|
280
|
+
const projects = listWorkspaceProjects(workspaceRoot);
|
|
281
|
+
const payload2 = `${JSON.stringify({ projects }, null, 2)}
|
|
282
|
+
`;
|
|
283
|
+
if (options.json) {
|
|
284
|
+
process.stdout.write(payload2);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
console.log(`${BOLD}Decantr workspace projects${RESET}`);
|
|
288
|
+
for (const project of projects) {
|
|
289
|
+
console.log(`${project.path} ${DIM}${project.source}${RESET}`);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const report = await createWorkspaceHealthReport(workspaceRoot, options);
|
|
294
|
+
const payload = options.json ? `${JSON.stringify(report, null, 2)}
|
|
295
|
+
` : options.markdown ? formatWorkspaceHealthMarkdown(report) : formatWorkspaceHealthText(report);
|
|
296
|
+
if (options.output) {
|
|
297
|
+
mkdirSync(dirname(resolve(workspaceRoot, options.output)), { recursive: true });
|
|
298
|
+
writeFileSync(resolve(workspaceRoot, options.output), payload, "utf-8");
|
|
299
|
+
if (!options.ci) console.log(`${GREEN}Wrote Decantr workspace health:${RESET} ${options.output}`);
|
|
300
|
+
} else {
|
|
301
|
+
process.stdout.write(payload);
|
|
302
|
+
}
|
|
303
|
+
if (options.ci && shouldFailWorkspaceHealth(report, options.failOn ?? "error")) {
|
|
304
|
+
process.exitCode = 1;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export {
|
|
309
|
+
listWorkspaceProjects,
|
|
310
|
+
createWorkspaceHealthReport,
|
|
311
|
+
formatWorkspaceHealthText,
|
|
312
|
+
formatWorkspaceHealthMarkdown,
|
|
313
|
+
shouldFailWorkspaceHealth,
|
|
314
|
+
parseWorkspaceArgs,
|
|
315
|
+
cmdWorkspace
|
|
316
|
+
};
|