@trail-cli/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/trail.js +5 -0
- package/package.json +34 -0
- package/src/build.ts +57 -0
- package/src/cli.ts +25 -0
- package/src/preview.ts +18 -0
- package/src/validate.ts +137 -0
package/bin/trail.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@trail-cli/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ndivhomakhuvha/trail.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"trail": "./bin/trail.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src/cli.ts",
|
|
18
|
+
"src/build.ts",
|
|
19
|
+
"src/preview.ts",
|
|
20
|
+
"src/validate.ts"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@trail-cli/guide-schema": "workspace:*",
|
|
27
|
+
"commander": "^12.1.0",
|
|
28
|
+
"tsx": "^4.19.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^20.14.15",
|
|
32
|
+
"typescript": "^5.5.4"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { REPO_ROOT, resolveDiscoveredGuides } from "./validate.js";
|
|
4
|
+
|
|
5
|
+
const OUTPUT_ROOT = path.join(REPO_ROOT, "dist", "releases");
|
|
6
|
+
|
|
7
|
+
export interface BuildOptions {
|
|
8
|
+
guidesRoot?: string;
|
|
9
|
+
outputRoot?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function runBuild(options: BuildOptions = {}): void {
|
|
13
|
+
const outputRoot = options.outputRoot ?? OUTPUT_ROOT;
|
|
14
|
+
const guidesRoot = options.guidesRoot;
|
|
15
|
+
const guides = resolveDiscoveredGuides(guidesRoot);
|
|
16
|
+
|
|
17
|
+
if (guides.length === 0) {
|
|
18
|
+
rmSync(outputRoot, { recursive: true, force: true });
|
|
19
|
+
console.log(`No guides found under ${guidesRoot ?? "guides/releases/"}.`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const failures = guides.filter((result) => !result.success);
|
|
24
|
+
|
|
25
|
+
if (failures.length > 0) {
|
|
26
|
+
for (const result of failures) {
|
|
27
|
+
console.error(`✗ ${result.source.release}/${result.source.file}`);
|
|
28
|
+
for (const issue of result.issues) {
|
|
29
|
+
console.error(` ${issue.path}: ${issue.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log("");
|
|
34
|
+
console.error("Build failed: one or more guides are invalid.");
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
mkdirSync(path.dirname(outputRoot), { recursive: true });
|
|
40
|
+
const stagingRoot = mkdtempSync(`${outputRoot}.tmp-`);
|
|
41
|
+
|
|
42
|
+
for (const result of guides) {
|
|
43
|
+
if (!result.success) continue;
|
|
44
|
+
|
|
45
|
+
const outDir = path.join(stagingRoot, result.source.release);
|
|
46
|
+
mkdirSync(outDir, { recursive: true });
|
|
47
|
+
const outFile = path.join(outDir, `${result.guide.id}.json`);
|
|
48
|
+
writeFileSync(outFile, JSON.stringify(result.guide, null, 2));
|
|
49
|
+
console.log(`✓ built ${result.source.release}/${path.basename(outFile)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
rmSync(outputRoot, { recursive: true, force: true });
|
|
53
|
+
renameSync(stagingRoot, outputRoot);
|
|
54
|
+
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log(`Built ${guides.length} guide(s) to ${outputRoot}.`);
|
|
57
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { runValidate } from "./validate.js";
|
|
3
|
+
import { runBuild } from "./build.js";
|
|
4
|
+
import { runPreview } from "./preview.js";
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program.name("trail").description("Trail CLI").version("0.1.0");
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.command("validate")
|
|
12
|
+
.description("Validate all YAML guides under guides/releases/")
|
|
13
|
+
.action(() => runValidate());
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command("build")
|
|
17
|
+
.description("Convert YAML guides into static JSON assets under dist/releases/")
|
|
18
|
+
.action(() => runBuild());
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.command("preview")
|
|
22
|
+
.description("Start a local preview server for the viewer")
|
|
23
|
+
.action(() => runPreview());
|
|
24
|
+
|
|
25
|
+
program.parse(process.argv);
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { REPO_ROOT } from "./validate.js";
|
|
3
|
+
|
|
4
|
+
export function runPreview(): void {
|
|
5
|
+
console.log("Starting local preview server...");
|
|
6
|
+
console.log("Example: http://localhost:5173/g/2026.08.26/PAY-1245");
|
|
7
|
+
console.log("");
|
|
8
|
+
|
|
9
|
+
const child = spawn("pnpm", ["--filter", "@trail-cli/viewer", "dev"], {
|
|
10
|
+
cwd: REPO_ROOT,
|
|
11
|
+
stdio: "inherit",
|
|
12
|
+
shell: process.platform === "win32",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
child.on("exit", (code) => {
|
|
16
|
+
process.exitCode = code ?? 0;
|
|
17
|
+
});
|
|
18
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
parseAuthorGuideYaml,
|
|
6
|
+
resolveGuide,
|
|
7
|
+
type AuthorGuide,
|
|
8
|
+
type Guide,
|
|
9
|
+
type GuideValidationIssue,
|
|
10
|
+
} from "@trail-cli/guide-schema";
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
export const REPO_ROOT = path.resolve(here, "../../../");
|
|
14
|
+
export const GUIDES_ROOT = path.join(REPO_ROOT, "guides", "releases");
|
|
15
|
+
|
|
16
|
+
export interface DiscoveredGuide {
|
|
17
|
+
release: string;
|
|
18
|
+
file: string;
|
|
19
|
+
filePath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Walks `guides/releases/<release>/*.yaml`. Returns an empty list if the directory doesn't exist. */
|
|
23
|
+
export function discoverGuides(guidesRoot = GUIDES_ROOT): DiscoveredGuide[] {
|
|
24
|
+
if (!existsSync(guidesRoot)) return [];
|
|
25
|
+
|
|
26
|
+
const guides: DiscoveredGuide[] = [];
|
|
27
|
+
const releases = readdirSync(guidesRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
28
|
+
|
|
29
|
+
for (const release of releases) {
|
|
30
|
+
const releaseDir = path.join(guidesRoot, release.name);
|
|
31
|
+
const files = readdirSync(releaseDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
|
|
32
|
+
for (const file of files.sort()) {
|
|
33
|
+
guides.push({ release: release.name, file, filePath: path.join(releaseDir, file) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return guides;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ValidationResult {
|
|
41
|
+
file: string;
|
|
42
|
+
success: boolean;
|
|
43
|
+
issues?: GuideValidationIssue[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ResolvedGuideResult =
|
|
47
|
+
| { source: DiscoveredGuide; success: true; guide: Guide }
|
|
48
|
+
| { source: DiscoveredGuide; success: false; issues: GuideValidationIssue[] };
|
|
49
|
+
|
|
50
|
+
/** Parses and resolves all guides per release, so extensions can only reference local bases. */
|
|
51
|
+
export function resolveDiscoveredGuides(guidesRoot = GUIDES_ROOT): ResolvedGuideResult[] {
|
|
52
|
+
const parsed = discoverGuides(guidesRoot).map((source) => ({
|
|
53
|
+
source,
|
|
54
|
+
result: parseAuthorGuideYaml(readFileSync(source.filePath, "utf-8")),
|
|
55
|
+
}));
|
|
56
|
+
const releases = new Map<string, typeof parsed>();
|
|
57
|
+
for (const entry of parsed) {
|
|
58
|
+
const release = releases.get(entry.source.release) ?? [];
|
|
59
|
+
release.push(entry);
|
|
60
|
+
releases.set(entry.source.release, release);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const results: ResolvedGuideResult[] = [];
|
|
64
|
+
for (const [release, entries] of releases) {
|
|
65
|
+
const guidesById = new Map<string, AuthorGuide>();
|
|
66
|
+
const duplicateIds = new Set<string>();
|
|
67
|
+
for (const { result } of entries) {
|
|
68
|
+
if (!result.success) continue;
|
|
69
|
+
if (guidesById.has(result.guide.id)) duplicateIds.add(result.guide.id);
|
|
70
|
+
else guidesById.set(result.guide.id, result.guide);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const { source, result } of entries) {
|
|
74
|
+
if (!result.success) {
|
|
75
|
+
results.push({ source, success: false, issues: result.issues });
|
|
76
|
+
} else if (duplicateIds.has(result.guide.id)) {
|
|
77
|
+
results.push({
|
|
78
|
+
source,
|
|
79
|
+
success: false,
|
|
80
|
+
issues: [{ path: "id", message: `Duplicate guide id in release ${release}: ${result.guide.id}.` }],
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
const resolved = resolveGuide(result.guide, guidesById);
|
|
84
|
+
results.push(
|
|
85
|
+
resolved.success
|
|
86
|
+
? { source, success: true, guide: resolved.guide }
|
|
87
|
+
: { source, success: false, issues: resolved.issues },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateGuides(): { results: ValidationResult[] } {
|
|
96
|
+
const results = resolveDiscoveredGuides().map((result): ValidationResult => {
|
|
97
|
+
const file = `${result.source.release}/${result.source.file}`;
|
|
98
|
+
return result.success ? { file, success: true } : { file, success: false, issues: result.issues };
|
|
99
|
+
});
|
|
100
|
+
return { results };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function runValidate(): void {
|
|
104
|
+
const { results } = validateGuides();
|
|
105
|
+
|
|
106
|
+
if (results.length === 0) {
|
|
107
|
+
console.log("No guides found under guides/releases/.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const failures = results.filter((r) => !r.success);
|
|
112
|
+
|
|
113
|
+
for (const result of results) {
|
|
114
|
+
if (result.success) {
|
|
115
|
+
console.log(`✓ ${result.file}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (failures.length > 0) {
|
|
120
|
+
for (const failure of failures) {
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log(`✗ ${failure.file}`);
|
|
123
|
+
console.log("");
|
|
124
|
+
for (const issue of failure.issues ?? []) {
|
|
125
|
+
console.log(`${issue.path}:`);
|
|
126
|
+
console.log(issue.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
console.log("");
|
|
130
|
+
console.error(`${failures.length} of ${results.length} guide(s) invalid.`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log("");
|
|
136
|
+
console.log(`${results.length} guides valid.`);
|
|
137
|
+
}
|