@decantr/cli 2.3.1 → 2.4.1
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 +31 -4
- package/dist/bin.js +2 -2
- package/dist/{chunk-2JWVKBNB.js → chunk-6BRD6DTB.js} +915 -327
- package/dist/chunk-AUQXYJ7T.js +316 -0
- package/dist/{chunk-3H3HWDJA.js → chunk-OD46PCR6.js} +354 -17
- package/dist/{chunk-WDA4SHIQ.js → chunk-P4NUDLWB.js} +109 -9
- 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-HSPWYROM.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
|
@@ -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
|
+
};
|