@cms-lab/cli 1.0.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/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +8 -0
- package/dist/chunk-JHH7PD4C.js +1134 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +6 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Afaq Rashid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# cms-lab
|
|
2
|
+
|
|
3
|
+
CLI for catching CMS-driven Next.js failures before deploy.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @cms-lab/cli scan
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
cms-lab init
|
|
13
|
+
cms-lab doctor
|
|
14
|
+
cms-lab doctor --debug --verbose 2
|
|
15
|
+
cms-lab scan --report
|
|
16
|
+
cms-lab scan --markdown
|
|
17
|
+
cms-lab scan --junit
|
|
18
|
+
cms-lab scan --slack-webhook "$CMS_LAB_SLACK_WEBHOOK"
|
|
19
|
+
cms-lab scan --debug
|
|
20
|
+
cms-lab scan --json
|
|
21
|
+
cms-lab scan --json --include-sensitive-output
|
|
22
|
+
cms-lab scan --max-warnings 0
|
|
23
|
+
cms-lab scan --strict
|
|
24
|
+
cms-lab explain CMS-ROUTE-404
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Scope
|
|
28
|
+
|
|
29
|
+
cms-lab supports Next.js App Router projects using Prismic, Strapi, Directus,
|
|
30
|
+
and WordPress. It validates configured CMS route mappings, route reachability,
|
|
31
|
+
UID gaps, SEO metadata, image alt text, and project-specific required fields.
|
|
32
|
+
|
|
33
|
+
Debug logs use stderr and support `--debug` plus `--verbose <0|1|2|3>`, so JSON
|
|
34
|
+
scan output remains clean on stdout. Raw CMS document `data` is redacted from
|
|
35
|
+
`--json` output, along with document URLs, UIDs, and absolute project paths,
|
|
36
|
+
unless `--include-sensitive-output` is passed explicitly.
|
|
37
|
+
|
|
38
|
+
CI strictness can be raised with `--fail-on warning`, `--max-warnings <count>`,
|
|
39
|
+
`--max-info <count>`, or `--strict`.
|
|
40
|
+
|
|
41
|
+
Exports include local HTML (`--report`), Markdown (`--markdown`), JUnit XML
|
|
42
|
+
(`--junit`), and redacted Slack incoming webhook summaries
|
|
43
|
+
(`--slack-webhook <url>`). Slack notifications send counts and diagnostic codes
|
|
44
|
+
only, never raw CMS payloads, local paths, or webhook URLs.
|
|
45
|
+
|
|
46
|
+
## Config
|
|
47
|
+
|
|
48
|
+
Create `cms-lab.config.ts` in the target Next.js project. Import
|
|
49
|
+
`defineConfig` from `@cms-lab/core`.
|
|
50
|
+
|
|
51
|
+
See the repository README for full setup, CI, and live Prismic testing.
|
|
52
|
+
|
|
53
|
+
## Open Source
|
|
54
|
+
|
|
55
|
+
MIT licensed. See the repository [license](https://github.com/i-afaqrashid/cms-lab/blob/main/LICENSE), [contributing guide](https://github.com/i-afaqrashid/cms-lab/blob/main/CONTRIBUTING.md), and [support guide](https://github.com/i-afaqrashid/cms-lab/blob/main/SUPPORT.md).
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Command, CommanderError } from "commander";
|
|
3
|
+
import { access, mkdir, writeFile } from "fs/promises";
|
|
4
|
+
import { dirname, resolve } from "path";
|
|
5
|
+
import { performance } from "perf_hooks";
|
|
6
|
+
import {
|
|
7
|
+
CmsFetchError,
|
|
8
|
+
ConfigLoadError,
|
|
9
|
+
SiteUnreachableError,
|
|
10
|
+
explainDiagnostic,
|
|
11
|
+
loadCmsLabConfig,
|
|
12
|
+
scanDocuments
|
|
13
|
+
} from "@cms-lab/core";
|
|
14
|
+
import { fetchDirectusDocuments as defaultFetchDirectusDocuments } from "@cms-lab/directus";
|
|
15
|
+
import { detectNextProject } from "@cms-lab/next";
|
|
16
|
+
import { fetchPrismicDocuments as defaultFetchPrismicDocuments } from "@cms-lab/prismic";
|
|
17
|
+
import { renderHtmlReport } from "@cms-lab/reporter";
|
|
18
|
+
import { fetchStrapiDocuments as defaultFetchStrapiDocuments } from "@cms-lab/strapi";
|
|
19
|
+
import { fetchWordPressDocuments as defaultFetchWordPressDocuments } from "@cms-lab/wordpress";
|
|
20
|
+
|
|
21
|
+
// src/exporters.ts
|
|
22
|
+
function renderMarkdownSummary(result, status) {
|
|
23
|
+
const lines = [
|
|
24
|
+
`# cms-lab scan ${status}`,
|
|
25
|
+
"",
|
|
26
|
+
"| Errors | Warnings | Info | Documents |",
|
|
27
|
+
"| ---: | ---: | ---: | ---: |",
|
|
28
|
+
`| ${result.summary.errors} | ${result.summary.warnings} | ${result.summary.info} | ${result.documents.length} |`,
|
|
29
|
+
""
|
|
30
|
+
];
|
|
31
|
+
if (result.diagnostics.length === 0) {
|
|
32
|
+
lines.push("No diagnostics found.", "");
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
lines.push("## Diagnostics", "");
|
|
36
|
+
lines.push("| Severity | Code | Message | Source |");
|
|
37
|
+
lines.push("| --- | --- | --- | --- |");
|
|
38
|
+
for (const diagnostic of result.diagnostics) {
|
|
39
|
+
lines.push(
|
|
40
|
+
`| ${escapeMarkdown(diagnostic.severity)} | ${escapeMarkdown(diagnostic.code)} | ${escapeMarkdown(diagnostic.message)} | ${escapeMarkdown(diagnostic.source ?? "")} |`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
lines.push("");
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
function renderJUnitReport(result) {
|
|
47
|
+
const diagnostics = result.diagnostics.length > 0 ? result.diagnostics : [
|
|
48
|
+
{
|
|
49
|
+
severity: "info",
|
|
50
|
+
code: "CMS-LAB-PASS",
|
|
51
|
+
message: "cms-lab scan passed with no diagnostics"
|
|
52
|
+
}
|
|
53
|
+
];
|
|
54
|
+
const failures = result.diagnostics.filter(
|
|
55
|
+
(diagnostic) => diagnostic.severity === "error"
|
|
56
|
+
);
|
|
57
|
+
return [
|
|
58
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
59
|
+
`<testsuite name="cms-lab" tests="${diagnostics.length}" failures="${failures.length}" errors="0">`,
|
|
60
|
+
...diagnostics.map((diagnostic) => renderJUnitTestcase(diagnostic)),
|
|
61
|
+
"</testsuite>",
|
|
62
|
+
""
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
function renderSlackPayload(result, status) {
|
|
66
|
+
const summary = `${result.summary.errors} ${plural(result.summary.errors, "error")}, ${result.summary.warnings} ${plural(result.summary.warnings, "warning")}, ${result.summary.info} ${plural(result.summary.info, "info item")}, ${result.documents.length} ${plural(result.documents.length, "document")}`;
|
|
67
|
+
const codes = [
|
|
68
|
+
...new Set(result.diagnostics.map((diagnostic) => diagnostic.code))
|
|
69
|
+
].slice(0, 5).join(", ");
|
|
70
|
+
const suffix = codes ? ` Top codes: ${codes}.` : "";
|
|
71
|
+
return {
|
|
72
|
+
text: `cms-lab scan ${status}: ${summary}.${suffix}`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function postSlackPayload(options) {
|
|
76
|
+
const response = await (options.fetchImpl ?? fetch)(options.webhookUrl, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "content-type": "application/json" },
|
|
79
|
+
body: JSON.stringify(options.payload)
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`Slack webhook returned HTTP ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function renderJUnitTestcase(diagnostic) {
|
|
86
|
+
const classname = `cms-lab.${groupForDiagnostic(diagnostic)}`;
|
|
87
|
+
const name = diagnostic.code;
|
|
88
|
+
if (diagnostic.severity === "error") {
|
|
89
|
+
return ` <testcase classname="${escapeXml(classname)}" name="${escapeXml(name)}"><failure message="${escapeXml(diagnostic.message)}">${escapeXml(diagnostic.message)}</failure></testcase>`;
|
|
90
|
+
}
|
|
91
|
+
return ` <testcase classname="${escapeXml(classname)}" name="${escapeXml(name)}"><system-out>${escapeXml(diagnostic.message)}</system-out></testcase>`;
|
|
92
|
+
}
|
|
93
|
+
function groupForDiagnostic(diagnostic) {
|
|
94
|
+
if (diagnostic.code.startsWith("CMS-ROUTE") || diagnostic.code === "CMS-UID-MISSING") {
|
|
95
|
+
return "routes";
|
|
96
|
+
}
|
|
97
|
+
if (diagnostic.code.startsWith("CMS-FIELD")) {
|
|
98
|
+
return "fields";
|
|
99
|
+
}
|
|
100
|
+
if (diagnostic.code.startsWith("SEO-")) {
|
|
101
|
+
return "seo";
|
|
102
|
+
}
|
|
103
|
+
if (diagnostic.code.startsWith("A11Y-")) {
|
|
104
|
+
return "a11y";
|
|
105
|
+
}
|
|
106
|
+
return "other";
|
|
107
|
+
}
|
|
108
|
+
function escapeMarkdown(value) {
|
|
109
|
+
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
110
|
+
}
|
|
111
|
+
function escapeXml(value) {
|
|
112
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
113
|
+
}
|
|
114
|
+
function plural(value, singular) {
|
|
115
|
+
return value === 1 ? singular : `${singular}s`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/output.ts
|
|
119
|
+
import pc from "picocolors";
|
|
120
|
+
function formatPrettyResult(result, options = {}) {
|
|
121
|
+
const color = options.color ?? false;
|
|
122
|
+
const paint = color ? pc : noColor;
|
|
123
|
+
const failOn = options.failOn ?? "error";
|
|
124
|
+
const thresholdFailure = thresholdFailureMessage(result, options);
|
|
125
|
+
const lines = [];
|
|
126
|
+
lines.push(paint.bold("cms-lab"));
|
|
127
|
+
lines.push(`project ${result.project.framework} ${result.project.router}`);
|
|
128
|
+
lines.push(`documents ${result.documents.length}`);
|
|
129
|
+
lines.push("");
|
|
130
|
+
appendGroup(
|
|
131
|
+
lines,
|
|
132
|
+
"errors",
|
|
133
|
+
result.diagnostics.filter((diagnostic) => diagnostic.severity === "error"),
|
|
134
|
+
paint.red
|
|
135
|
+
);
|
|
136
|
+
appendGroup(
|
|
137
|
+
lines,
|
|
138
|
+
"warnings",
|
|
139
|
+
result.diagnostics.filter(
|
|
140
|
+
(diagnostic) => diagnostic.severity === "warning"
|
|
141
|
+
),
|
|
142
|
+
paint.yellow
|
|
143
|
+
);
|
|
144
|
+
appendGroup(
|
|
145
|
+
lines,
|
|
146
|
+
"info",
|
|
147
|
+
result.diagnostics.filter((diagnostic) => diagnostic.severity === "info"),
|
|
148
|
+
paint.cyan
|
|
149
|
+
);
|
|
150
|
+
lines.push("");
|
|
151
|
+
lines.push("summary");
|
|
152
|
+
lines.push(` errors ${result.summary.errors}`);
|
|
153
|
+
lines.push(` warnings ${result.summary.warnings}`);
|
|
154
|
+
lines.push(` info ${result.summary.info}`);
|
|
155
|
+
lines.push("");
|
|
156
|
+
if (result.summary.errors > 0 && failOn !== "never") {
|
|
157
|
+
lines.push(
|
|
158
|
+
paint.red(
|
|
159
|
+
`scan failed - ${result.summary.errors} ${plural2(result.summary.errors, "error")}`
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
} else if (failOn === "warning" && result.summary.warnings > 0) {
|
|
163
|
+
lines.push(
|
|
164
|
+
paint.yellow(
|
|
165
|
+
`scan failed - ${result.summary.warnings} ${plural2(result.summary.warnings, "warning")}`
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
} else if (thresholdFailure) {
|
|
169
|
+
lines.push(paint.red(thresholdFailure));
|
|
170
|
+
} else if (failOn === "never" && (result.summary.errors > 0 || result.summary.warnings > 0)) {
|
|
171
|
+
lines.push(
|
|
172
|
+
paint.cyan("scan completed - diagnostics ignored by --fail-on never")
|
|
173
|
+
);
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(paint.green("scan passed"));
|
|
176
|
+
}
|
|
177
|
+
if (options.hints && result.diagnostics.length > 0) {
|
|
178
|
+
lines.push("");
|
|
179
|
+
lines.push("next");
|
|
180
|
+
lines.push(` cms-lab explain ${result.diagnostics[0].code}`);
|
|
181
|
+
}
|
|
182
|
+
return `${lines.join("\n")}
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
function thresholdFailureMessage(result, options) {
|
|
186
|
+
if (options.maxWarnings !== void 0 && result.summary.warnings > options.maxWarnings) {
|
|
187
|
+
return `scan failed - ${result.summary.warnings} ${plural2(result.summary.warnings, "warning")} exceed --max-warnings ${options.maxWarnings}`;
|
|
188
|
+
}
|
|
189
|
+
if (options.maxInfo !== void 0 && result.summary.info > options.maxInfo) {
|
|
190
|
+
return `scan failed - ${result.summary.info} ${plural2(result.summary.info, "info item")} exceed --max-info ${options.maxInfo}`;
|
|
191
|
+
}
|
|
192
|
+
return void 0;
|
|
193
|
+
}
|
|
194
|
+
function appendGroup(lines, title, diagnostics, color) {
|
|
195
|
+
if (diagnostics.length === 0) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
lines.push(title);
|
|
199
|
+
for (const diagnostic of diagnostics) {
|
|
200
|
+
const location = diagnostic.path ? ` ${diagnostic.path}` : "";
|
|
201
|
+
const source = diagnostic.source ? ` (${diagnostic.source})` : "";
|
|
202
|
+
lines.push(
|
|
203
|
+
` ${color(diagnostic.code)}${location} - ${diagnostic.message}${source}`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
lines.push("");
|
|
207
|
+
}
|
|
208
|
+
function plural2(value, singular) {
|
|
209
|
+
return value === 1 ? singular : `${singular}s`;
|
|
210
|
+
}
|
|
211
|
+
var noColor = {
|
|
212
|
+
bold: (value) => value,
|
|
213
|
+
red: (value) => value,
|
|
214
|
+
yellow: (value) => value,
|
|
215
|
+
cyan: (value) => value,
|
|
216
|
+
green: (value) => value
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// src/index.ts
|
|
220
|
+
async function runCli(argv, dependencies = {}) {
|
|
221
|
+
let exitCode = 0;
|
|
222
|
+
const program = new Command().name("cms-lab").description("Catch CMS bugs before deploy.").version("1.0.1").exitOverride().configureOutput({
|
|
223
|
+
writeOut: (text) => writeStdout(dependencies, text),
|
|
224
|
+
writeErr: (text) => writeStderr(dependencies, text)
|
|
225
|
+
});
|
|
226
|
+
program.addHelpText(
|
|
227
|
+
"after",
|
|
228
|
+
`
|
|
229
|
+
Examples:
|
|
230
|
+
cms-lab init
|
|
231
|
+
cms-lab doctor --config cms-lab.config.ts
|
|
232
|
+
cms-lab scan --ci --report
|
|
233
|
+
cms-lab explain CMS-ROUTE-404
|
|
234
|
+
`
|
|
235
|
+
);
|
|
236
|
+
program.command("scan").description("Scan CMS content against configured site routes.").option("--url <url>", "Override config.site.url").option("--config <path>", "Path to cms-lab config file").option("--json", "Print ScanResult JSON").option("--ci", "Use stable CI-friendly terminal output").option(
|
|
237
|
+
"--fail-on <level>",
|
|
238
|
+
"Exit with code 1 on error, warning, or never",
|
|
239
|
+
"error"
|
|
240
|
+
).option(
|
|
241
|
+
"--max-warnings <count>",
|
|
242
|
+
"Exit with code 1 when warnings exceed this count"
|
|
243
|
+
).option(
|
|
244
|
+
"--max-info <count>",
|
|
245
|
+
"Exit with code 1 when info diagnostics exceed this count"
|
|
246
|
+
).option(
|
|
247
|
+
"--strict",
|
|
248
|
+
"Fail on warnings and info diagnostics. Equivalent to --fail-on warning --max-info 0."
|
|
249
|
+
).option(
|
|
250
|
+
"--report [path]",
|
|
251
|
+
"Write an HTML report. Defaults to .cms-lab/report.html"
|
|
252
|
+
).option(
|
|
253
|
+
"--markdown [path]",
|
|
254
|
+
"Write a Markdown summary. Defaults to .cms-lab/summary.md"
|
|
255
|
+
).option(
|
|
256
|
+
"--junit [path]",
|
|
257
|
+
"Write a JUnit XML report. Defaults to .cms-lab/junit.xml"
|
|
258
|
+
).option(
|
|
259
|
+
"--slack-webhook <url>",
|
|
260
|
+
"Post a redacted scan summary to a Slack incoming webhook"
|
|
261
|
+
).option(
|
|
262
|
+
"--notify-on <mode>",
|
|
263
|
+
"When to notify Slack: always, failure, or diagnostics",
|
|
264
|
+
"failure"
|
|
265
|
+
).option(
|
|
266
|
+
"--include-sensitive-output",
|
|
267
|
+
"Include raw CMS document data and local project paths in --json output"
|
|
268
|
+
).option(
|
|
269
|
+
"--type <type>",
|
|
270
|
+
"Limit scan to a CMS content type. Repeatable; comma-separated values are allowed.",
|
|
271
|
+
collectOption,
|
|
272
|
+
[]
|
|
273
|
+
).option(
|
|
274
|
+
"--only <check>",
|
|
275
|
+
"Run only a check group. Repeatable; comma-separated values are allowed.",
|
|
276
|
+
collectOption,
|
|
277
|
+
[]
|
|
278
|
+
).option(
|
|
279
|
+
"--skip <check>",
|
|
280
|
+
"Skip a check group. Repeatable; comma-separated values are allowed.",
|
|
281
|
+
collectOption,
|
|
282
|
+
[]
|
|
283
|
+
).option("--timeout <ms>", "Per-route HTTP timeout in milliseconds").option("--concurrency <count>", "Maximum concurrent route probes").option("--retries <count>", "Retry transient route probe failures", "1").option("--debug", "Write debug logs to stderr").option("--verbose <level>", "Debug verbosity level: 0, 1, 2, or 3").option("--no-color", "Disable ANSI color in terminal output").addHelpText(
|
|
284
|
+
"after",
|
|
285
|
+
`
|
|
286
|
+
Examples:
|
|
287
|
+
cms-lab scan
|
|
288
|
+
cms-lab scan --url https://staging.example.com
|
|
289
|
+
cms-lab scan --ci --report
|
|
290
|
+
cms-lab scan --json --include-sensitive-output
|
|
291
|
+
cms-lab scan --only routes,fields --fail-on warning
|
|
292
|
+
`
|
|
293
|
+
).action(async (options) => {
|
|
294
|
+
exitCode = await runScan(options, dependencies);
|
|
295
|
+
});
|
|
296
|
+
program.command("doctor").description(
|
|
297
|
+
"Validate cms-lab config, project, site, and CMS connectivity."
|
|
298
|
+
).option("--url <url>", "Override config.site.url").option("--config <path>", "Path to cms-lab config file").option("--timeout <ms>", "HTTP timeout in milliseconds").option("--retries <count>", "Retry transient connectivity failures", "1").option("--debug", "Write debug logs to stderr").option("--verbose <level>", "Debug verbosity level: 0, 1, 2, or 3").addHelpText(
|
|
299
|
+
"after",
|
|
300
|
+
`
|
|
301
|
+
Examples:
|
|
302
|
+
cms-lab doctor
|
|
303
|
+
cms-lab doctor --config cms-lab.config.ts
|
|
304
|
+
cms-lab doctor --url https://staging.example.com --debug
|
|
305
|
+
`
|
|
306
|
+
).action(async (options) => {
|
|
307
|
+
exitCode = await runDoctor(options, dependencies);
|
|
308
|
+
});
|
|
309
|
+
program.command("explain").argument("<code>", "Diagnostic code to explain").description("Explain a cms-lab diagnostic code.").addHelpText(
|
|
310
|
+
"after",
|
|
311
|
+
`
|
|
312
|
+
Examples:
|
|
313
|
+
cms-lab explain CMS-ROUTE-404
|
|
314
|
+
cms-lab explain SEO-META-MISSING
|
|
315
|
+
`
|
|
316
|
+
).action((code) => {
|
|
317
|
+
exitCode = runExplain(code, dependencies);
|
|
318
|
+
});
|
|
319
|
+
program.command("init").description("Create a starter cms-lab.config.ts file.").option("--config <path>", "Config file path", "cms-lab.config.ts").option("--force", "Overwrite an existing config file").option("--repository <name>", "Prismic repository name", "my-repo").option("--url <url>", "Site URL", "http://localhost:3000").addHelpText(
|
|
320
|
+
"after",
|
|
321
|
+
`
|
|
322
|
+
Examples:
|
|
323
|
+
cms-lab init
|
|
324
|
+
cms-lab init --repository my-prismic-repo --url http://localhost:3000
|
|
325
|
+
cms-lab init --config cms-lab.config.ts --force
|
|
326
|
+
`
|
|
327
|
+
).action(async (options) => {
|
|
328
|
+
exitCode = await runInit(options, dependencies);
|
|
329
|
+
});
|
|
330
|
+
try {
|
|
331
|
+
await program.parseAsync(argv, { from: "user" });
|
|
332
|
+
return exitCode;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (error instanceof CommanderError) {
|
|
335
|
+
return error.exitCode ?? 2;
|
|
336
|
+
}
|
|
337
|
+
writeStderr(dependencies, `Unexpected error: ${messageFrom(error)}
|
|
338
|
+
`);
|
|
339
|
+
return 2;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async function runScan(options, dependencies) {
|
|
343
|
+
const cwd = dependencies.cwd ?? process.cwd();
|
|
344
|
+
let debug = createNoopDebugLogger();
|
|
345
|
+
let endTotal = () => {
|
|
346
|
+
};
|
|
347
|
+
try {
|
|
348
|
+
debug = createDebugLogger(
|
|
349
|
+
"scan",
|
|
350
|
+
options.debug,
|
|
351
|
+
options.verbose,
|
|
352
|
+
dependencies
|
|
353
|
+
);
|
|
354
|
+
endTotal = debug.time("total", 2);
|
|
355
|
+
const timeoutMs = parseTimeout(options.timeout);
|
|
356
|
+
const concurrency = parseConcurrency(options.concurrency);
|
|
357
|
+
const retries = parseRetries(options.retries);
|
|
358
|
+
const strict = Boolean(options.strict);
|
|
359
|
+
const failOn = strict ? "warning" : parseFailOn(options.failOn);
|
|
360
|
+
const maxWarnings = parseOptionalThreshold(
|
|
361
|
+
options.maxWarnings,
|
|
362
|
+
"--max-warnings"
|
|
363
|
+
);
|
|
364
|
+
const maxInfo = parseOptionalThreshold(
|
|
365
|
+
options.maxInfo ?? (strict ? "0" : void 0),
|
|
366
|
+
"--max-info"
|
|
367
|
+
);
|
|
368
|
+
const notifyOn = parseNotifyOn(options.notifyOn);
|
|
369
|
+
const filters = {
|
|
370
|
+
types: splitList(options.type),
|
|
371
|
+
only: parseCheckGroups(options.only),
|
|
372
|
+
skip: parseCheckGroups(options.skip)
|
|
373
|
+
};
|
|
374
|
+
debug.log(1, `cwd ${cwd}`);
|
|
375
|
+
debug.log(
|
|
376
|
+
2,
|
|
377
|
+
`options ${describeScanOptions(
|
|
378
|
+
{ ...options, failOn, maxWarnings, maxInfo },
|
|
379
|
+
filters
|
|
380
|
+
)}`
|
|
381
|
+
);
|
|
382
|
+
const endConfig = debug.time("config", 2);
|
|
383
|
+
const loaded = await loadCmsLabConfig({ cwd, configPath: options.config });
|
|
384
|
+
endConfig();
|
|
385
|
+
const config = {
|
|
386
|
+
...loaded.config,
|
|
387
|
+
site: {
|
|
388
|
+
...loaded.config.site,
|
|
389
|
+
url: options.url ?? loaded.config.site.url
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
debug.log(1, `config ${loaded.configFile ?? "inline"}`);
|
|
393
|
+
debug.log(1, `site ${config.site.url}`);
|
|
394
|
+
debug.log(1, `cms ${describeCms(config.cms)}`);
|
|
395
|
+
if (config.framework.router !== "app") {
|
|
396
|
+
throw new ConfigLoadError(
|
|
397
|
+
"cms-lab only supports Next.js App Router projects"
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const endProject = debug.time("project detection", 2);
|
|
401
|
+
const project = await detectNextProject(cwd);
|
|
402
|
+
endProject();
|
|
403
|
+
if (project.router !== "app") {
|
|
404
|
+
throw new ConfigLoadError(
|
|
405
|
+
"cms-lab only supports Next.js App Router projects"
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
debug.log(
|
|
409
|
+
1,
|
|
410
|
+
`project next ${project.router} appDir=${project.appDir ?? "none"}`
|
|
411
|
+
);
|
|
412
|
+
const endCms = debug.time("cms fetch", 2);
|
|
413
|
+
const documents = await fetchCmsDocuments(config.cms, dependencies);
|
|
414
|
+
endCms();
|
|
415
|
+
debug.log(1, `documents ${documents.length}`);
|
|
416
|
+
debug.log(3, `document types ${describeDocumentTypes(documents)}`);
|
|
417
|
+
assertTypeFilterMatched(documents, filters.types);
|
|
418
|
+
const endScan = debug.time("scan", 2);
|
|
419
|
+
const result = await scanDocuments({
|
|
420
|
+
config,
|
|
421
|
+
project,
|
|
422
|
+
documents,
|
|
423
|
+
fetch: dependencies.fetch,
|
|
424
|
+
timeoutMs,
|
|
425
|
+
concurrency,
|
|
426
|
+
retries,
|
|
427
|
+
filters
|
|
428
|
+
});
|
|
429
|
+
endScan();
|
|
430
|
+
debug.log(
|
|
431
|
+
1,
|
|
432
|
+
`summary errors=${result.summary.errors} warnings=${result.summary.warnings} info=${result.summary.info}`
|
|
433
|
+
);
|
|
434
|
+
const exitCode = exitCodeForResult(result, {
|
|
435
|
+
failOn,
|
|
436
|
+
maxWarnings,
|
|
437
|
+
maxInfo
|
|
438
|
+
});
|
|
439
|
+
const status = exitCode === 0 ? "passed" : "failed";
|
|
440
|
+
const endReport = debug.time("exports", 2);
|
|
441
|
+
await maybeWriteReport(options.report, result, cwd);
|
|
442
|
+
await maybeWriteMarkdown(options.markdown, result, status, cwd);
|
|
443
|
+
await maybeWriteJUnit(options.junit, result, cwd);
|
|
444
|
+
const slackSent = await maybePostSlack({
|
|
445
|
+
webhookUrl: options.slackWebhook,
|
|
446
|
+
notifyOn,
|
|
447
|
+
result,
|
|
448
|
+
status,
|
|
449
|
+
fetchImpl: dependencies.fetch
|
|
450
|
+
});
|
|
451
|
+
endReport();
|
|
452
|
+
if (options.report) {
|
|
453
|
+
debug.log(1, `report ${reportPathFromOption(options.report, cwd)}`);
|
|
454
|
+
}
|
|
455
|
+
if (options.markdown) {
|
|
456
|
+
debug.log(1, `markdown ${markdownPathFromOption(options.markdown, cwd)}`);
|
|
457
|
+
}
|
|
458
|
+
if (options.junit) {
|
|
459
|
+
debug.log(1, `junit ${junitPathFromOption(options.junit, cwd)}`);
|
|
460
|
+
}
|
|
461
|
+
if (slackSent) {
|
|
462
|
+
debug.log(1, "slack webhook sent");
|
|
463
|
+
} else if (options.slackWebhook) {
|
|
464
|
+
debug.log(1, "slack webhook skipped");
|
|
465
|
+
}
|
|
466
|
+
if (options.json) {
|
|
467
|
+
writeStdout(
|
|
468
|
+
dependencies,
|
|
469
|
+
`${JSON.stringify(jsonOutputResult(result, options), null, 2)}
|
|
470
|
+
`
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
writeStdout(
|
|
474
|
+
dependencies,
|
|
475
|
+
formatPrettyResult(result, {
|
|
476
|
+
color: shouldUseColor(options, dependencies),
|
|
477
|
+
failOn,
|
|
478
|
+
maxWarnings,
|
|
479
|
+
maxInfo,
|
|
480
|
+
hints: !options.ci
|
|
481
|
+
})
|
|
482
|
+
);
|
|
483
|
+
if (options.report) {
|
|
484
|
+
writeStdout(
|
|
485
|
+
dependencies,
|
|
486
|
+
`report ${reportPathFromOption(options.report, cwd)}
|
|
487
|
+
`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (options.markdown) {
|
|
491
|
+
writeStdout(
|
|
492
|
+
dependencies,
|
|
493
|
+
`markdown ${markdownPathFromOption(options.markdown, cwd)}
|
|
494
|
+
`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (options.junit) {
|
|
498
|
+
writeStdout(
|
|
499
|
+
dependencies,
|
|
500
|
+
`junit ${junitPathFromOption(options.junit, cwd)}
|
|
501
|
+
`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
endTotal();
|
|
506
|
+
return exitCode;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
endTotal();
|
|
509
|
+
debug.log(1, `error ${safeMessageFrom(error)}`);
|
|
510
|
+
if (error instanceof ConfigLoadError) {
|
|
511
|
+
writeStderr(
|
|
512
|
+
dependencies,
|
|
513
|
+
`Config error: ${redactSensitive(error.message)}
|
|
514
|
+
`
|
|
515
|
+
);
|
|
516
|
+
return 2;
|
|
517
|
+
}
|
|
518
|
+
if (error instanceof CmsFetchError) {
|
|
519
|
+
writeStderr(
|
|
520
|
+
dependencies,
|
|
521
|
+
`CMS error: ${redactSensitive(error.message)}
|
|
522
|
+
`
|
|
523
|
+
);
|
|
524
|
+
return 3;
|
|
525
|
+
}
|
|
526
|
+
if (error instanceof SiteUnreachableError) {
|
|
527
|
+
writeStderr(
|
|
528
|
+
dependencies,
|
|
529
|
+
`Site error: ${redactSensitive(error.message)}
|
|
530
|
+
`
|
|
531
|
+
);
|
|
532
|
+
return 4;
|
|
533
|
+
}
|
|
534
|
+
writeStderr(dependencies, `Unexpected error: ${safeMessageFrom(error)}
|
|
535
|
+
`);
|
|
536
|
+
return 2;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async function runDoctor(options, dependencies) {
|
|
540
|
+
const cwd = dependencies.cwd ?? process.cwd();
|
|
541
|
+
let debug = createNoopDebugLogger();
|
|
542
|
+
let endTotal = () => {
|
|
543
|
+
};
|
|
544
|
+
try {
|
|
545
|
+
debug = createDebugLogger(
|
|
546
|
+
"doctor",
|
|
547
|
+
options.debug,
|
|
548
|
+
options.verbose,
|
|
549
|
+
dependencies
|
|
550
|
+
);
|
|
551
|
+
endTotal = debug.time("total", 2);
|
|
552
|
+
const timeoutMs = parseTimeout(options.timeout) ?? 5e3;
|
|
553
|
+
const retries = parseRetries(options.retries) ?? 1;
|
|
554
|
+
debug.log(1, `cwd ${cwd}`);
|
|
555
|
+
debug.log(
|
|
556
|
+
2,
|
|
557
|
+
`options timeout=${timeoutMs} retries=${retries} url=${options.url ?? "config"}`
|
|
558
|
+
);
|
|
559
|
+
const endConfig = debug.time("config", 2);
|
|
560
|
+
const loaded = await loadCmsLabConfig({ cwd, configPath: options.config });
|
|
561
|
+
endConfig();
|
|
562
|
+
const config = {
|
|
563
|
+
...loaded.config,
|
|
564
|
+
site: {
|
|
565
|
+
...loaded.config.site,
|
|
566
|
+
url: options.url ?? loaded.config.site.url
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
debug.log(1, `config ${loaded.configFile ?? "inline"}`);
|
|
570
|
+
debug.log(1, `site ${config.site.url}`);
|
|
571
|
+
debug.log(1, `cms ${describeCms(config.cms)}`);
|
|
572
|
+
writeStdout(
|
|
573
|
+
dependencies,
|
|
574
|
+
`config ok${loaded.configFile ? ` - ${loaded.configFile}` : ""}
|
|
575
|
+
`
|
|
576
|
+
);
|
|
577
|
+
if (config.framework.router !== "app") {
|
|
578
|
+
throw new ConfigLoadError(
|
|
579
|
+
"cms-lab only supports Next.js App Router projects"
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const endProject = debug.time("project detection", 2);
|
|
583
|
+
const project = await detectNextProject(cwd);
|
|
584
|
+
endProject();
|
|
585
|
+
if (project.router !== "app") {
|
|
586
|
+
throw new ConfigLoadError(
|
|
587
|
+
"cms-lab only supports Next.js App Router projects"
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
debug.log(
|
|
591
|
+
1,
|
|
592
|
+
`project next ${project.router} appDir=${project.appDir ?? "none"}`
|
|
593
|
+
);
|
|
594
|
+
writeStdout(
|
|
595
|
+
dependencies,
|
|
596
|
+
`next app ok - ${project.appDir ?? project.rootDir}
|
|
597
|
+
`
|
|
598
|
+
);
|
|
599
|
+
const endSite = debug.time("site probe", 2);
|
|
600
|
+
await fetchSite(config.site.url, dependencies.fetch, timeoutMs, retries);
|
|
601
|
+
endSite();
|
|
602
|
+
writeStdout(dependencies, `site ok - ${config.site.url}
|
|
603
|
+
`);
|
|
604
|
+
const endCms = debug.time("cms fetch", 2);
|
|
605
|
+
const documents = await fetchCmsDocuments(config.cms, dependencies);
|
|
606
|
+
endCms();
|
|
607
|
+
debug.log(1, `documents ${documents.length}`);
|
|
608
|
+
debug.log(3, `document types ${describeDocumentTypes(documents)}`);
|
|
609
|
+
writeStdout(
|
|
610
|
+
dependencies,
|
|
611
|
+
`cms ok - ${documents.length} ${plural3(documents.length, "document")}
|
|
612
|
+
`
|
|
613
|
+
);
|
|
614
|
+
endTotal();
|
|
615
|
+
return 0;
|
|
616
|
+
} catch (error) {
|
|
617
|
+
endTotal();
|
|
618
|
+
debug.log(1, `error ${safeMessageFrom(error)}`);
|
|
619
|
+
if (error instanceof ConfigLoadError) {
|
|
620
|
+
writeStderr(
|
|
621
|
+
dependencies,
|
|
622
|
+
`Config error: ${redactSensitive(error.message)}
|
|
623
|
+
`
|
|
624
|
+
);
|
|
625
|
+
return 2;
|
|
626
|
+
}
|
|
627
|
+
if (error instanceof CmsFetchError) {
|
|
628
|
+
writeStderr(
|
|
629
|
+
dependencies,
|
|
630
|
+
`CMS error: ${redactSensitive(error.message)}
|
|
631
|
+
`
|
|
632
|
+
);
|
|
633
|
+
return 3;
|
|
634
|
+
}
|
|
635
|
+
if (error instanceof SiteUnreachableError) {
|
|
636
|
+
writeStderr(
|
|
637
|
+
dependencies,
|
|
638
|
+
`Site error: ${redactSensitive(error.message)}
|
|
639
|
+
`
|
|
640
|
+
);
|
|
641
|
+
return 4;
|
|
642
|
+
}
|
|
643
|
+
writeStderr(dependencies, `Unexpected error: ${safeMessageFrom(error)}
|
|
644
|
+
`);
|
|
645
|
+
return 2;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function runExplain(code, dependencies) {
|
|
649
|
+
const explanation = explainDiagnostic(code);
|
|
650
|
+
if (!explanation) {
|
|
651
|
+
writeStderr(dependencies, `Unknown diagnostic code: ${code}
|
|
652
|
+
`);
|
|
653
|
+
return 2;
|
|
654
|
+
}
|
|
655
|
+
writeStdout(
|
|
656
|
+
dependencies,
|
|
657
|
+
[
|
|
658
|
+
`${explanation.code} (${explanation.severity})`,
|
|
659
|
+
explanation.title,
|
|
660
|
+
"",
|
|
661
|
+
`Meaning: ${explanation.meaning}`,
|
|
662
|
+
`Fix: ${explanation.fix}`,
|
|
663
|
+
""
|
|
664
|
+
].join("\n")
|
|
665
|
+
);
|
|
666
|
+
return 0;
|
|
667
|
+
}
|
|
668
|
+
async function runInit(options, dependencies) {
|
|
669
|
+
const cwd = dependencies.cwd ?? process.cwd();
|
|
670
|
+
const target = resolve(cwd, options.config ?? "cms-lab.config.ts");
|
|
671
|
+
try {
|
|
672
|
+
if (!options.force && await fileExists(target)) {
|
|
673
|
+
writeStderr(
|
|
674
|
+
dependencies,
|
|
675
|
+
`Config error: ${target} already exists. Use --force to overwrite it.
|
|
676
|
+
`
|
|
677
|
+
);
|
|
678
|
+
return 2;
|
|
679
|
+
}
|
|
680
|
+
await mkdir(dirname(target), { recursive: true });
|
|
681
|
+
await writeFile(
|
|
682
|
+
target,
|
|
683
|
+
starterConfig({
|
|
684
|
+
repository: options.repository ?? "my-repo",
|
|
685
|
+
url: options.url ?? "http://localhost:3000"
|
|
686
|
+
})
|
|
687
|
+
);
|
|
688
|
+
writeStdout(dependencies, `created ${target}
|
|
689
|
+
`);
|
|
690
|
+
return 0;
|
|
691
|
+
} catch (error) {
|
|
692
|
+
writeStderr(dependencies, `Config error: ${messageFrom(error)}
|
|
693
|
+
`);
|
|
694
|
+
return 2;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function writeStdout(dependencies, text) {
|
|
698
|
+
if (dependencies.stdout) {
|
|
699
|
+
dependencies.stdout(text);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
process.stdout.write(text);
|
|
703
|
+
}
|
|
704
|
+
function writeStderr(dependencies, text) {
|
|
705
|
+
if (dependencies.stderr) {
|
|
706
|
+
dependencies.stderr(text);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
process.stderr.write(text);
|
|
710
|
+
}
|
|
711
|
+
function messageFrom(error) {
|
|
712
|
+
return error instanceof Error ? error.message : String(error);
|
|
713
|
+
}
|
|
714
|
+
function safeMessageFrom(error) {
|
|
715
|
+
return redactSensitive(messageFrom(error));
|
|
716
|
+
}
|
|
717
|
+
function redactSensitive(value) {
|
|
718
|
+
return value.replaceAll(/(access_token=)[^&\s]+/gi, "$1[redacted]").replaceAll(/([?&](?:token|password|secret)=)[^&\s]+/gi, "$1[redacted]").replaceAll(/\bBearer\s+[-._~+/=a-z0-9]+/gi, "Bearer [redacted]").replaceAll(/(https?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1[redacted]@");
|
|
719
|
+
}
|
|
720
|
+
function jsonOutputResult(result, options) {
|
|
721
|
+
if (options.includeSensitiveOutput) {
|
|
722
|
+
return result;
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
...result,
|
|
726
|
+
project: {
|
|
727
|
+
framework: result.project.framework,
|
|
728
|
+
router: result.project.router,
|
|
729
|
+
rootDir: "[redacted: pass --include-sensitive-output to emit raw project paths]",
|
|
730
|
+
...result.project.appDir ? {
|
|
731
|
+
appDir: "[redacted: pass --include-sensitive-output to emit raw project paths]"
|
|
732
|
+
} : {},
|
|
733
|
+
...result.project.pagesDir ? {
|
|
734
|
+
pagesDir: "[redacted: pass --include-sensitive-output to emit raw project paths]"
|
|
735
|
+
} : {}
|
|
736
|
+
},
|
|
737
|
+
documents: result.documents.map((document) => ({
|
|
738
|
+
id: document.id,
|
|
739
|
+
type: document.type,
|
|
740
|
+
status: document.status,
|
|
741
|
+
data: "[redacted: pass --include-sensitive-output to emit raw CMS data]"
|
|
742
|
+
}))
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
function collectOption(value, previous) {
|
|
746
|
+
return [...previous, value];
|
|
747
|
+
}
|
|
748
|
+
function splitList(values) {
|
|
749
|
+
return [
|
|
750
|
+
...new Set(
|
|
751
|
+
(values ?? []).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean)
|
|
752
|
+
)
|
|
753
|
+
];
|
|
754
|
+
}
|
|
755
|
+
function createNoopDebugLogger() {
|
|
756
|
+
return {
|
|
757
|
+
log: () => {
|
|
758
|
+
},
|
|
759
|
+
time: () => () => {
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function createDebugLogger(command, debug, verbose, dependencies) {
|
|
764
|
+
const level = parseVerbosity(debug, verbose);
|
|
765
|
+
return {
|
|
766
|
+
log: (messageLevel, message) => {
|
|
767
|
+
if (level < messageLevel) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
writeStderr(dependencies, `[cms-lab:debug] ${command} ${message}
|
|
771
|
+
`);
|
|
772
|
+
},
|
|
773
|
+
time: (label, messageLevel = 2) => {
|
|
774
|
+
if (level < messageLevel) {
|
|
775
|
+
return () => {
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
const startedAt = performance.now();
|
|
779
|
+
let ended = false;
|
|
780
|
+
return () => {
|
|
781
|
+
if (ended) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
ended = true;
|
|
785
|
+
writeStderr(
|
|
786
|
+
dependencies,
|
|
787
|
+
`[cms-lab:debug] ${command} timing ${label} ${formatDuration(
|
|
788
|
+
performance.now() - startedAt
|
|
789
|
+
)}
|
|
790
|
+
`
|
|
791
|
+
);
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
function parseVerbosity(debug, verbose) {
|
|
797
|
+
if (verbose === void 0) {
|
|
798
|
+
return debug ? 1 : 0;
|
|
799
|
+
}
|
|
800
|
+
if (verbose === "0" || verbose === "1" || verbose === "2" || verbose === "3") {
|
|
801
|
+
return Number(verbose);
|
|
802
|
+
}
|
|
803
|
+
throw new ConfigLoadError("--verbose must be one of: 0, 1, 2, 3");
|
|
804
|
+
}
|
|
805
|
+
function shouldUseColor(options, dependencies) {
|
|
806
|
+
if (options.ci || options.color === false) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
const env = dependencies.env ?? process.env;
|
|
810
|
+
if (env.NO_COLOR || env.TERM === "dumb") {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
return dependencies.isStdoutTTY ?? Boolean(process.stdout.isTTY);
|
|
814
|
+
}
|
|
815
|
+
function describeScanOptions(options, filters) {
|
|
816
|
+
return [
|
|
817
|
+
`json=${Boolean(options.json)}`,
|
|
818
|
+
`ci=${Boolean(options.ci)}`,
|
|
819
|
+
`report=${Boolean(options.report)}`,
|
|
820
|
+
`markdown=${Boolean(options.markdown)}`,
|
|
821
|
+
`junit=${Boolean(options.junit)}`,
|
|
822
|
+
`slackWebhook=${Boolean(options.slackWebhook)}`,
|
|
823
|
+
`notifyOn=${options.notifyOn ?? "failure"}`,
|
|
824
|
+
`failOn=${options.failOn}`,
|
|
825
|
+
`maxWarnings=${options.maxWarnings ?? "none"}`,
|
|
826
|
+
`maxInfo=${options.maxInfo ?? "none"}`,
|
|
827
|
+
`timeout=${options.timeout ?? "default"}`,
|
|
828
|
+
`concurrency=${options.concurrency ?? "default"}`,
|
|
829
|
+
`retries=${options.retries ?? "1"}`,
|
|
830
|
+
`types=${formatList(filters.types)}`,
|
|
831
|
+
`only=${formatList(filters.only)}`,
|
|
832
|
+
`skip=${formatList(filters.skip)}`
|
|
833
|
+
].join(" ");
|
|
834
|
+
}
|
|
835
|
+
function describeCms(config) {
|
|
836
|
+
if (config.provider === "prismic") {
|
|
837
|
+
return `prismic repository=${config.repositoryName}`;
|
|
838
|
+
}
|
|
839
|
+
if (config.provider === "strapi") {
|
|
840
|
+
return `strapi url=${safeUrl(config.url)} collections=${formatList(
|
|
841
|
+
config.collections.map((collection) => collection.endpoint)
|
|
842
|
+
)}`;
|
|
843
|
+
}
|
|
844
|
+
if (config.provider === "directus") {
|
|
845
|
+
return `directus url=${safeUrl(config.url)} collections=${formatList(
|
|
846
|
+
config.collections.map((collection) => collection.collection)
|
|
847
|
+
)}`;
|
|
848
|
+
}
|
|
849
|
+
return `wordpress url=${safeUrl(config.url)} contentTypes=${formatList(
|
|
850
|
+
config.contentTypes?.map((contentType) => contentType.endpoint) ?? [
|
|
851
|
+
"pages",
|
|
852
|
+
"posts"
|
|
853
|
+
]
|
|
854
|
+
)}`;
|
|
855
|
+
}
|
|
856
|
+
function describeDocumentTypes(documents) {
|
|
857
|
+
const counts = /* @__PURE__ */ new Map();
|
|
858
|
+
for (const document of documents) {
|
|
859
|
+
counts.set(document.type, (counts.get(document.type) ?? 0) + 1);
|
|
860
|
+
}
|
|
861
|
+
return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([type, count]) => `${type}=${count}`).join(", ") || "none";
|
|
862
|
+
}
|
|
863
|
+
function safeUrl(value) {
|
|
864
|
+
try {
|
|
865
|
+
const url = new URL(value);
|
|
866
|
+
url.username = "";
|
|
867
|
+
url.password = "";
|
|
868
|
+
url.search = "";
|
|
869
|
+
url.hash = "";
|
|
870
|
+
return url.toString().replace(/\/$/, "");
|
|
871
|
+
} catch {
|
|
872
|
+
return "<invalid-url>";
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function formatList(values) {
|
|
876
|
+
return values.length > 0 ? values.join(",") : "none";
|
|
877
|
+
}
|
|
878
|
+
function formatDuration(ms) {
|
|
879
|
+
return `${ms.toFixed(1)}ms`;
|
|
880
|
+
}
|
|
881
|
+
async function fetchCmsDocuments(config, dependencies) {
|
|
882
|
+
if (dependencies.fetchCmsDocuments) {
|
|
883
|
+
return dependencies.fetchCmsDocuments(config);
|
|
884
|
+
}
|
|
885
|
+
if (config.provider === "prismic") {
|
|
886
|
+
const loadDocuments = dependencies.fetchPrismicDocuments ?? ((cmsConfig) => {
|
|
887
|
+
if (cmsConfig.provider !== "prismic") {
|
|
888
|
+
throw new ConfigLoadError(
|
|
889
|
+
`fetchPrismicDocuments cannot load ${cmsConfig.provider}`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
return defaultFetchPrismicDocuments(cmsConfig, {
|
|
893
|
+
fetch: dependencies.fetch
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
return loadDocuments(config);
|
|
897
|
+
}
|
|
898
|
+
if (config.provider === "strapi") {
|
|
899
|
+
return defaultFetchStrapiDocuments(config, { fetch: dependencies.fetch });
|
|
900
|
+
}
|
|
901
|
+
if (config.provider === "directus") {
|
|
902
|
+
return defaultFetchDirectusDocuments(config, { fetch: dependencies.fetch });
|
|
903
|
+
}
|
|
904
|
+
return defaultFetchWordPressDocuments(config, { fetch: dependencies.fetch });
|
|
905
|
+
}
|
|
906
|
+
function assertTypeFilterMatched(documents, types) {
|
|
907
|
+
if (types.length === 0) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (documents.some((document) => types.includes(document.type))) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const availableTypes = [
|
|
914
|
+
...new Set(documents.map((document) => document.type))
|
|
915
|
+
].sort().join(", ");
|
|
916
|
+
throw new ConfigLoadError(
|
|
917
|
+
`No CMS documents matched --type ${types.join(", ")}. Available types: ${availableTypes || "none"}`
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
function parseCheckGroups(values) {
|
|
921
|
+
const allowed = /* @__PURE__ */ new Set(["routes", "seo", "a11y", "images", "fields"]);
|
|
922
|
+
const groups = splitList(values);
|
|
923
|
+
for (const group of groups) {
|
|
924
|
+
if (!allowed.has(group)) {
|
|
925
|
+
throw new ConfigLoadError(
|
|
926
|
+
`Unknown check group "${group}". Expected one of: ${[...allowed].join(", ")}`
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return groups;
|
|
931
|
+
}
|
|
932
|
+
function parseTimeout(value) {
|
|
933
|
+
if (!value) {
|
|
934
|
+
return void 0;
|
|
935
|
+
}
|
|
936
|
+
const timeout = Number(value);
|
|
937
|
+
if (!Number.isInteger(timeout) || timeout <= 0) {
|
|
938
|
+
throw new ConfigLoadError("--timeout must be a positive integer");
|
|
939
|
+
}
|
|
940
|
+
return timeout;
|
|
941
|
+
}
|
|
942
|
+
function parseConcurrency(value) {
|
|
943
|
+
if (!value) {
|
|
944
|
+
return void 0;
|
|
945
|
+
}
|
|
946
|
+
const concurrency = Number(value);
|
|
947
|
+
if (!Number.isInteger(concurrency) || concurrency <= 0) {
|
|
948
|
+
throw new ConfigLoadError("--concurrency must be a positive integer");
|
|
949
|
+
}
|
|
950
|
+
return concurrency;
|
|
951
|
+
}
|
|
952
|
+
function parseRetries(value) {
|
|
953
|
+
if (!value) {
|
|
954
|
+
return void 0;
|
|
955
|
+
}
|
|
956
|
+
const retries = Number(value);
|
|
957
|
+
if (!Number.isInteger(retries) || retries < 0) {
|
|
958
|
+
throw new ConfigLoadError("--retries must be a non-negative integer");
|
|
959
|
+
}
|
|
960
|
+
return retries;
|
|
961
|
+
}
|
|
962
|
+
function parseOptionalThreshold(value, flag) {
|
|
963
|
+
if (value === void 0) {
|
|
964
|
+
return void 0;
|
|
965
|
+
}
|
|
966
|
+
const threshold = Number(value);
|
|
967
|
+
if (!Number.isInteger(threshold) || threshold < 0) {
|
|
968
|
+
throw new ConfigLoadError(`${flag} must be a non-negative integer`);
|
|
969
|
+
}
|
|
970
|
+
return threshold;
|
|
971
|
+
}
|
|
972
|
+
async function fetchSite(url, fetchImpl, timeoutMs, retries) {
|
|
973
|
+
let lastError;
|
|
974
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
975
|
+
const controller = new AbortController();
|
|
976
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
977
|
+
try {
|
|
978
|
+
const response = await (fetchImpl ?? fetch)(url, {
|
|
979
|
+
method: "GET",
|
|
980
|
+
signal: controller.signal
|
|
981
|
+
});
|
|
982
|
+
if (!response.ok) {
|
|
983
|
+
lastError = new Error(`Site ${url} returned HTTP ${response.status}`);
|
|
984
|
+
if (attempt >= retries) {
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
return;
|
|
990
|
+
} catch (error) {
|
|
991
|
+
lastError = error;
|
|
992
|
+
if (attempt >= retries) {
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
} finally {
|
|
996
|
+
clearTimeout(timeout);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
throw new SiteUnreachableError(messageFrom(lastError));
|
|
1000
|
+
}
|
|
1001
|
+
function parseFailOn(value) {
|
|
1002
|
+
const level = value ?? "error";
|
|
1003
|
+
if (level === "error" || level === "warning" || level === "never") {
|
|
1004
|
+
return level;
|
|
1005
|
+
}
|
|
1006
|
+
throw new ConfigLoadError("--fail-on must be one of: error, warning, never");
|
|
1007
|
+
}
|
|
1008
|
+
function exitCodeForResult(result, options) {
|
|
1009
|
+
const { failOn, maxWarnings, maxInfo } = options;
|
|
1010
|
+
if (failOn === "never") {
|
|
1011
|
+
return thresholdExceeded(result, maxWarnings, maxInfo) ? 1 : 0;
|
|
1012
|
+
}
|
|
1013
|
+
if (result.summary.errors > 0) {
|
|
1014
|
+
return 1;
|
|
1015
|
+
}
|
|
1016
|
+
if (failOn === "warning" && result.summary.warnings > 0) {
|
|
1017
|
+
return 1;
|
|
1018
|
+
}
|
|
1019
|
+
if (thresholdExceeded(result, maxWarnings, maxInfo)) {
|
|
1020
|
+
return 1;
|
|
1021
|
+
}
|
|
1022
|
+
return 0;
|
|
1023
|
+
}
|
|
1024
|
+
function thresholdExceeded(result, maxWarnings, maxInfo) {
|
|
1025
|
+
return maxWarnings !== void 0 && result.summary.warnings > maxWarnings || maxInfo !== void 0 && result.summary.info > maxInfo;
|
|
1026
|
+
}
|
|
1027
|
+
async function maybeWriteReport(report, result, cwd) {
|
|
1028
|
+
if (!report) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const path = reportPathFromOption(report, cwd);
|
|
1032
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1033
|
+
await writeFile(path, renderHtmlReport(result), "utf8");
|
|
1034
|
+
}
|
|
1035
|
+
function reportPathFromOption(report, cwd) {
|
|
1036
|
+
return resolve(
|
|
1037
|
+
cwd,
|
|
1038
|
+
typeof report === "string" ? report : ".cms-lab/report.html"
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
async function maybeWriteMarkdown(markdown, result, status, cwd) {
|
|
1042
|
+
if (!markdown) {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const path = markdownPathFromOption(markdown, cwd);
|
|
1046
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1047
|
+
await writeFile(path, renderMarkdownSummary(result, status), "utf8");
|
|
1048
|
+
}
|
|
1049
|
+
function markdownPathFromOption(markdown, cwd) {
|
|
1050
|
+
return resolve(
|
|
1051
|
+
cwd,
|
|
1052
|
+
typeof markdown === "string" ? markdown : ".cms-lab/summary.md"
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
async function maybeWriteJUnit(junit, result, cwd) {
|
|
1056
|
+
if (!junit) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const path = junitPathFromOption(junit, cwd);
|
|
1060
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1061
|
+
await writeFile(path, renderJUnitReport(result), "utf8");
|
|
1062
|
+
}
|
|
1063
|
+
function junitPathFromOption(junit, cwd) {
|
|
1064
|
+
return resolve(cwd, typeof junit === "string" ? junit : ".cms-lab/junit.xml");
|
|
1065
|
+
}
|
|
1066
|
+
async function maybePostSlack(options) {
|
|
1067
|
+
if (!options.webhookUrl) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
if (!shouldNotify(options.notifyOn, options.result, options.status)) {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
await postSlackPayload({
|
|
1074
|
+
webhookUrl: options.webhookUrl,
|
|
1075
|
+
fetchImpl: options.fetchImpl,
|
|
1076
|
+
payload: renderSlackPayload(options.result, options.status)
|
|
1077
|
+
});
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
function shouldNotify(notifyOn, result, status) {
|
|
1081
|
+
if (notifyOn === "always") {
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
if (notifyOn === "failure") {
|
|
1085
|
+
return status === "failed";
|
|
1086
|
+
}
|
|
1087
|
+
return result.diagnostics.length > 0;
|
|
1088
|
+
}
|
|
1089
|
+
function parseNotifyOn(value) {
|
|
1090
|
+
const notifyOn = value ?? "failure";
|
|
1091
|
+
if (notifyOn === "always" || notifyOn === "failure" || notifyOn === "diagnostics") {
|
|
1092
|
+
return notifyOn;
|
|
1093
|
+
}
|
|
1094
|
+
throw new ConfigLoadError(
|
|
1095
|
+
"--notify-on must be one of: always, failure, diagnostics"
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
async function fileExists(path) {
|
|
1099
|
+
try {
|
|
1100
|
+
await access(path);
|
|
1101
|
+
return true;
|
|
1102
|
+
} catch {
|
|
1103
|
+
return false;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function plural3(value, singular) {
|
|
1107
|
+
return value === 1 ? singular : `${singular}s`;
|
|
1108
|
+
}
|
|
1109
|
+
function starterConfig(options) {
|
|
1110
|
+
return `import { defineConfig } from "@cms-lab/core";
|
|
1111
|
+
|
|
1112
|
+
export default defineConfig({
|
|
1113
|
+
site: { url: ${JSON.stringify(options.url)} },
|
|
1114
|
+
framework: { type: "next", router: "app" },
|
|
1115
|
+
cms: {
|
|
1116
|
+
provider: "prismic",
|
|
1117
|
+
repositoryName: ${JSON.stringify(options.repository)},
|
|
1118
|
+
accessToken: process.env.PRISMIC_ACCESS_TOKEN,
|
|
1119
|
+
},
|
|
1120
|
+
routes: [
|
|
1121
|
+
{ type: "page", pattern: "/:uid", getPath: (doc) => \`/\${doc.uid}\` },
|
|
1122
|
+
{
|
|
1123
|
+
type: "blog_post",
|
|
1124
|
+
pattern: "/blog/:uid",
|
|
1125
|
+
getPath: (doc) => \`/blog/\${doc.uid}\`,
|
|
1126
|
+
},
|
|
1127
|
+
],
|
|
1128
|
+
});
|
|
1129
|
+
`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export {
|
|
1133
|
+
runCli
|
|
1134
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FetchLike, CmsProviderConfig, CMSDocument } from '@cms-lab/core';
|
|
2
|
+
|
|
3
|
+
type CliDependencies = {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
stdout?: (text: string) => void;
|
|
6
|
+
stderr?: (text: string) => void;
|
|
7
|
+
env?: Record<string, string | undefined>;
|
|
8
|
+
isStdoutTTY?: boolean;
|
|
9
|
+
fetch?: FetchLike;
|
|
10
|
+
fetchCmsDocuments?: (config: CmsProviderConfig) => Promise<CMSDocument[]>;
|
|
11
|
+
fetchPrismicDocuments?: (config: CmsProviderConfig) => Promise<CMSDocument[]>;
|
|
12
|
+
};
|
|
13
|
+
declare function runCli(argv: string[], dependencies?: CliDependencies): Promise<number>;
|
|
14
|
+
|
|
15
|
+
export { type CliDependencies, runCli };
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cms-lab/cli",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Catch CMS bugs before deploy.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/i-afaqrashid/cms-lab.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://cms-lab.dev",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/i-afaqrashid/cms-lab/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cms",
|
|
18
|
+
"nextjs",
|
|
19
|
+
"prismic",
|
|
20
|
+
"strapi",
|
|
21
|
+
"directus",
|
|
22
|
+
"wordpress",
|
|
23
|
+
"cli",
|
|
24
|
+
"testing"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"cms-lab": "./dist/bin.js"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20.10"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"commander": "^14.0.2",
|
|
47
|
+
"picocolors": "^1.1.1",
|
|
48
|
+
"@cms-lab/core": "1.0.1",
|
|
49
|
+
"@cms-lab/directus": "1.0.1",
|
|
50
|
+
"@cms-lab/next": "1.0.1",
|
|
51
|
+
"@cms-lab/reporter": "1.0.1",
|
|
52
|
+
"@cms-lab/strapi": "1.0.1",
|
|
53
|
+
"@cms-lab/wordpress": "1.0.1",
|
|
54
|
+
"@cms-lab/prismic": "1.0.1"
|
|
55
|
+
},
|
|
56
|
+
"author": "Afaq Rashid",
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsup src/index.ts src/bin.ts --format esm --dts --clean --tsconfig ../../tsconfig.base.json"
|
|
59
|
+
}
|
|
60
|
+
}
|