@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 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 in this wave
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 `decantr-health.json`, 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.
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
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import "./chunk-FSZ6OIAC.js";
3
- import "./chunk-WDA4SHIQ.js";
2
+ import "./chunk-H6TIXG5K.js";
3
+ import "./chunk-PEGMSXDJ.js";
4
4
  import "./chunk-IEW2QFYI.js";
@@ -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
+ };