argsbarg 6.0.1 → 6.0.2
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/CHANGELOG.md +14 -2
- package/README.md +3 -3
- package/docs/config-schema.md +15 -13
- package/docs/output-schema.md +67 -66
- package/examples/full-example/README.md +7 -7
- package/examples/full-example/bun.lock +0 -29
- package/examples/full-example/justfile +6 -3
- package/examples/full-example/package.json +0 -2
- package/examples/full-example/src/commands/status/__generated__/index.ts +5 -0
- package/examples/full-example/src/commands/status/command.ts +2 -2
- package/examples/full-example/src/commands/status/types.ts +1 -1
- package/examples/full-example/src/config/__generated__/index.ts +5 -0
- package/examples/full-example/src/program.ts +4 -4
- package/package.json +8 -1
- package/src/cli-tool/full-example-capabilities.test.ts +2 -1
- package/src/cli-tool/post-create.ts +3 -3
- package/src/cli-tool/program.ts +35 -0
- package/src/cli-tool/run-schemagen.ts +23 -0
- package/src/cli-tool/schemagen/cleanup.ts +60 -0
- package/{examples/full-example/scripts → src/cli-tool}/schemagen/discover-schema-roots.ts +13 -58
- package/src/cli-tool/schemagen/index.ts +3 -0
- package/src/cli-tool/schemagen/names.ts +22 -0
- package/src/cli-tool/schemagen/run.ts +108 -0
- package/src/cli-tool/schemagen/schemagen.test.ts +125 -0
- package/examples/full-example/schemas/configSchemas.ts +0 -6
- package/examples/full-example/schemas/outputSchemas.ts +0 -6
- package/examples/full-example/scripts/schemagen/discover-schema-roots.test.ts +0 -25
- package/examples/full-example/scripts/schemagen/naming.ts +0 -99
- package/examples/full-example/scripts/schemagen.ts +0 -91
- /package/examples/full-example/{schemas/generated/status.json → src/commands/status/__generated__/outputSchema.json} +0 -0
- /package/examples/full-example/src/commands/status/{schema-types.ts → schema.ts} +0 -0
- /package/examples/full-example/{schemas/generated/app-config.json → src/config/__generated__/configSchema.json} +0 -0
- /package/examples/full-example/src/config/{schema-types.ts → schema.ts} +0 -0
package/src/cli-tool/program.ts
CHANGED
|
@@ -5,6 +5,7 @@ Argsbarg developer tools — bootstrap consumer CLIs via `create`.
|
|
|
5
5
|
import pkg from "../../package.json" with { type: "json" };
|
|
6
6
|
import { CliOptionKind, type CliProgram } from "../index.ts";
|
|
7
7
|
import { runCreate } from "./run-create.ts";
|
|
8
|
+
import { runSchemagenCli } from "./run-schemagen.ts";
|
|
8
9
|
|
|
9
10
|
export const program = {
|
|
10
11
|
key: "argsbarg",
|
|
@@ -92,5 +93,39 @@ export const program = {
|
|
|
92
93
|
process.exit(code);
|
|
93
94
|
},
|
|
94
95
|
},
|
|
96
|
+
{
|
|
97
|
+
key: "schemagen",
|
|
98
|
+
description: "Generate JSON Schema artifacts from src/**/schema.ts into colocated __generated__/ directories.",
|
|
99
|
+
options: [
|
|
100
|
+
{
|
|
101
|
+
name: "root",
|
|
102
|
+
description: "Project root (default: current working directory).",
|
|
103
|
+
kind: CliOptionKind.String,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "src-dir",
|
|
107
|
+
description: "Source directory relative to --root (default: src).",
|
|
108
|
+
kind: CliOptionKind.String,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "tsconfig",
|
|
112
|
+
description: "Path to tsconfig relative to --root (default: tsconfig.json).",
|
|
113
|
+
kind: CliOptionKind.String,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
handler: (ctx) => {
|
|
117
|
+
try {
|
|
118
|
+
runSchemagenCli({
|
|
119
|
+
root: ctx.stringOpt("root"),
|
|
120
|
+
srcDir: ctx.stringOpt("src-dir"),
|
|
121
|
+
tsconfig: ctx.stringOpt("tsconfig"),
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
125
|
+
process.stderr.write(`${message}\n`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
},
|
|
95
130
|
],
|
|
96
131
|
} satisfies CliProgram;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Run argsbarg schemagen in the current working directory (or `--root`). */
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { runSchemagen } from "./schemagen/run.ts";
|
|
5
|
+
|
|
6
|
+
export interface RunSchemagenCliOptions {
|
|
7
|
+
root?: string;
|
|
8
|
+
srcDir?: string;
|
|
9
|
+
tsconfig?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Exit 0 on success; throws on failure. */
|
|
13
|
+
export function runSchemagenCli(options: RunSchemagenCliOptions = {}): void {
|
|
14
|
+
const projectRoot = resolve(options.root ?? process.cwd());
|
|
15
|
+
const counts = runSchemagen({
|
|
16
|
+
projectRoot,
|
|
17
|
+
srcDir: options.srcDir,
|
|
18
|
+
tsconfig: options.tsconfig,
|
|
19
|
+
});
|
|
20
|
+
console.log(
|
|
21
|
+
`config roots: ${counts.configRoots}, input roots: ${counts.inputRoots}, output roots: ${counts.outputRoots}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Remove stale __generated__/ directories and files after schemagen runs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative } from "node:path";
|
|
7
|
+
import type { SchemaRoot } from "./discover-schema-roots.ts";
|
|
8
|
+
import { GENERATED_DIR, SCHEMA_FILE, schemaJsonBasename } from "./names.ts";
|
|
9
|
+
|
|
10
|
+
function listGeneratedDirs(srcDir: string, projectRoot: string, out: string[]): void {
|
|
11
|
+
for (const ent of readdirSync(srcDir)) {
|
|
12
|
+
const full = join(srcDir, ent);
|
|
13
|
+
const st = statSync(full);
|
|
14
|
+
if (!st.isDirectory()) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (ent === GENERATED_DIR) {
|
|
18
|
+
out.push(relative(projectRoot, full));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
listGeneratedDirs(full, projectRoot, out);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Drop orphan `__generated__/` trees and JSON files for removed schema kinds. */
|
|
26
|
+
export function cleanStaleGenerated(
|
|
27
|
+
projectRoot: string,
|
|
28
|
+
srcDir: string,
|
|
29
|
+
activeBySchemaFile: Map<string, SchemaRoot[]>,
|
|
30
|
+
): void {
|
|
31
|
+
const srcPath = join(projectRoot, srcDir);
|
|
32
|
+
if (!existsSync(srcPath)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const generatedDirs: string[] = [];
|
|
37
|
+
listGeneratedDirs(srcPath, projectRoot, generatedDirs);
|
|
38
|
+
|
|
39
|
+
for (const relGeneratedDir of generatedDirs) {
|
|
40
|
+
const generatedDir = join(projectRoot, relGeneratedDir);
|
|
41
|
+
const relSchemaPath = relative(projectRoot, join(dirname(generatedDir), SCHEMA_FILE));
|
|
42
|
+
const roots = existsSync(join(projectRoot, relSchemaPath)) ? (activeBySchemaFile.get(relSchemaPath) ?? []) : [];
|
|
43
|
+
|
|
44
|
+
if (roots.length === 0) {
|
|
45
|
+
rmSync(generatedDir, { recursive: true, force: true });
|
|
46
|
+
console.log(`removed ${relGeneratedDir}`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const keep = new Set(["index.ts", ...roots.map((root) => schemaJsonBasename(root.kind))]);
|
|
51
|
+
for (const ent of readdirSync(generatedDir)) {
|
|
52
|
+
if (keep.has(ent)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const stalePath = join(generatedDir, ent);
|
|
56
|
+
rmSync(stalePath, { recursive: true, force: true });
|
|
57
|
+
console.log(`removed ${relative(projectRoot, stalePath)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Discovers schema roots in schema
|
|
3
|
-
Copy per consumer repo — see docs/output-schema.md and docs/config-schema.md.
|
|
2
|
+
Discovers schema roots in schema.ts files via configType / inputType / outputType exports.
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
5
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
6
|
import { join, relative } from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
configSchemaExportName,
|
|
10
|
-
inputSchemaExportName,
|
|
11
|
-
outfileForConfigType,
|
|
12
|
-
outfileForInputType,
|
|
13
|
-
outfileForOutputType,
|
|
14
|
-
outputSchemaExportName,
|
|
15
|
-
} from "./naming.ts";
|
|
7
|
+
import { SCHEMA_FILE } from "./names.ts";
|
|
16
8
|
|
|
17
9
|
export type SchemaRootKind = "config" | "input" | "output";
|
|
18
10
|
|
|
@@ -21,14 +13,10 @@ export type SchemaRole = "configType" | "inputType" | "outputType";
|
|
|
21
13
|
export interface SchemaRoot {
|
|
22
14
|
kind: SchemaRootKind;
|
|
23
15
|
typeName: string;
|
|
24
|
-
/** Path relative to project root (e.g. src/config/schema
|
|
16
|
+
/** Path relative to project root (e.g. src/config/schema.ts). */
|
|
25
17
|
path: string;
|
|
26
|
-
outfile: string;
|
|
27
|
-
exportName: string;
|
|
28
18
|
}
|
|
29
19
|
|
|
30
|
-
const SCHEMA_TYPES_FILE = "schema-types.ts";
|
|
31
|
-
|
|
32
20
|
const ROLE_EXPORT_RE = /export\s+type\s+(configType|inputType|outputType)\s*=\s*(\w+)/g;
|
|
33
21
|
|
|
34
22
|
const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
|
|
@@ -37,15 +25,15 @@ const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
|
|
|
37
25
|
outputType: "output",
|
|
38
26
|
};
|
|
39
27
|
|
|
40
|
-
function
|
|
28
|
+
function listSchemaFiles(srcDir: string, baseDir: string, out: string[]): void {
|
|
41
29
|
for (const ent of readdirSync(srcDir)) {
|
|
42
30
|
const full = join(srcDir, ent);
|
|
43
31
|
const st = statSync(full);
|
|
44
32
|
if (st.isDirectory()) {
|
|
45
|
-
|
|
33
|
+
listSchemaFiles(full, baseDir, out);
|
|
46
34
|
continue;
|
|
47
35
|
}
|
|
48
|
-
if (ent ===
|
|
36
|
+
if (ent === SCHEMA_FILE) {
|
|
49
37
|
out.push(relative(baseDir, full));
|
|
50
38
|
}
|
|
51
39
|
}
|
|
@@ -62,35 +50,6 @@ function isTypeDefinedInFile(text: string, typeName: string): boolean {
|
|
|
62
50
|
return false;
|
|
63
51
|
}
|
|
64
52
|
|
|
65
|
-
function rootForRole(role: SchemaRole, typeName: string, path: string): SchemaRoot {
|
|
66
|
-
const kind = ROLE_TO_KIND[role];
|
|
67
|
-
if (kind === "config") {
|
|
68
|
-
return {
|
|
69
|
-
kind,
|
|
70
|
-
typeName,
|
|
71
|
-
path,
|
|
72
|
-
outfile: outfileForConfigType(typeName),
|
|
73
|
-
exportName: configSchemaExportName(typeName),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
if (kind === "input") {
|
|
77
|
-
return {
|
|
78
|
-
kind,
|
|
79
|
-
typeName,
|
|
80
|
-
path,
|
|
81
|
-
outfile: outfileForInputType(typeName),
|
|
82
|
-
exportName: inputSchemaExportName(typeName),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
return {
|
|
86
|
-
kind,
|
|
87
|
-
typeName,
|
|
88
|
-
path,
|
|
89
|
-
outfile: outfileForOutputType(typeName),
|
|
90
|
-
exportName: outputSchemaExportName(typeName),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
53
|
function discoverFromFile(path: string, text: string): SchemaRoot[] {
|
|
95
54
|
const rolesSeen = new Set<SchemaRole>();
|
|
96
55
|
const roots: SchemaRoot[] = [];
|
|
@@ -108,17 +67,17 @@ function discoverFromFile(path: string, text: string): SchemaRoot[] {
|
|
|
108
67
|
if (!isTypeDefinedInFile(text, typeName)) {
|
|
109
68
|
continue;
|
|
110
69
|
}
|
|
111
|
-
roots.push(
|
|
70
|
+
roots.push({ kind: ROLE_TO_KIND[role], typeName, path });
|
|
112
71
|
}
|
|
113
72
|
|
|
114
73
|
return roots;
|
|
115
74
|
}
|
|
116
75
|
|
|
117
|
-
/** Find all schema roots under `
|
|
118
|
-
export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
|
|
119
|
-
const
|
|
76
|
+
/** Find all schema roots under `srcDir` in files named `schema.ts`. */
|
|
77
|
+
export function discoverSchemaRoots(projectRoot: string, srcDir = "src"): SchemaRoot[] {
|
|
78
|
+
const srcPath = join(projectRoot, srcDir);
|
|
120
79
|
const files: string[] = [];
|
|
121
|
-
|
|
80
|
+
listSchemaFiles(srcPath, projectRoot, files);
|
|
122
81
|
|
|
123
82
|
const roots: SchemaRoot[] = [];
|
|
124
83
|
const typeOwners = new Map<string, string>();
|
|
@@ -128,9 +87,7 @@ export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
|
|
|
128
87
|
for (const root of discoverFromFile(relPath, text)) {
|
|
129
88
|
const prev = typeOwners.get(root.typeName);
|
|
130
89
|
if (prev) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`${relPath}: duplicate schema root type ${root.typeName} (already declared in ${prev})`,
|
|
133
|
-
);
|
|
90
|
+
throw new Error(`${relPath}: duplicate schema root type ${root.typeName} (already declared in ${prev})`);
|
|
134
91
|
}
|
|
135
92
|
typeOwners.set(root.typeName, relPath);
|
|
136
93
|
roots.push(root);
|
|
@@ -139,9 +96,7 @@ export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
|
|
|
139
96
|
|
|
140
97
|
const configRoots = roots.filter((r) => r.kind === "config");
|
|
141
98
|
if (configRoots.length > 1) {
|
|
142
|
-
throw new Error(
|
|
143
|
-
`multiple config schema roots: ${configRoots.map((r) => `${r.typeName} (${r.path})`).join(", ")}`,
|
|
144
|
-
);
|
|
99
|
+
throw new Error(`multiple config schema roots: ${configRoots.map((r) => `${r.typeName} (${r.path})`).join(", ")}`);
|
|
145
100
|
}
|
|
146
101
|
|
|
147
102
|
return roots;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { discoverSchemaRoots, type SchemaRoot, type SchemaRootKind } from "./discover-schema-roots.ts";
|
|
2
|
+
export { GENERATED_DIR, SCHEMA_FILE, schemaExportName, schemaJsonBasename } from "./names.ts";
|
|
3
|
+
export { type RunSchemagenOptions, type RunSchemagenResult, runSchemagen } from "./run.ts";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SchemaRootKind } from "./discover-schema-roots.ts";
|
|
2
|
+
|
|
3
|
+
/** Directory name for generated schema artifacts (next to `schema.ts`). */
|
|
4
|
+
export const GENERATED_DIR = "__generated__";
|
|
5
|
+
|
|
6
|
+
/** TypeScript schema root filename under `src/`. */
|
|
7
|
+
export const SCHEMA_FILE = "schema.ts";
|
|
8
|
+
|
|
9
|
+
/** JSON basename for a schema root kind (`outputSchema.json`, etc.). */
|
|
10
|
+
export function schemaJsonBasename(kind: SchemaRootKind): string {
|
|
11
|
+
return `${kind}Schema.json`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Export const name wired on leaves or `program.appConfig`. */
|
|
15
|
+
export function schemaExportName(kind: SchemaRootKind): string {
|
|
16
|
+
return `${kind}Schema`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Safe import binding for a schema JSON basename. */
|
|
20
|
+
export function schemaJsonImportVar(kind: SchemaRootKind): string {
|
|
21
|
+
return `${kind}SchemaJson`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Generate JSON Schema artifacts under __generated__/ and write index.ts re-exports.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative } from "node:path";
|
|
7
|
+
import { createGenerator } from "ts-json-schema-generator";
|
|
8
|
+
import { cleanStaleGenerated } from "./cleanup.ts";
|
|
9
|
+
import { discoverSchemaRoots, type SchemaRoot, type SchemaRootKind } from "./discover-schema-roots.ts";
|
|
10
|
+
import { GENERATED_DIR, schemaExportName, schemaJsonBasename, schemaJsonImportVar } from "./names.ts";
|
|
11
|
+
|
|
12
|
+
const KIND_ORDER: SchemaRootKind[] = ["config", "input", "output"];
|
|
13
|
+
|
|
14
|
+
export interface RunSchemagenOptions {
|
|
15
|
+
/** Project root (default: `process.cwd()`). */
|
|
16
|
+
projectRoot?: string;
|
|
17
|
+
/** Source tree directory relative to project root (default: `src`). */
|
|
18
|
+
srcDir?: string;
|
|
19
|
+
/** Path to tsconfig relative to project root (default: `tsconfig.json`). */
|
|
20
|
+
tsconfig?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RunSchemagenResult {
|
|
24
|
+
configRoots: number;
|
|
25
|
+
inputRoots: number;
|
|
26
|
+
outputRoots: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveTsconfig(projectRoot: string, tsconfig: string): string {
|
|
30
|
+
const path = join(projectRoot, tsconfig);
|
|
31
|
+
try {
|
|
32
|
+
return path;
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`tsconfig not found: ${path}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function generateJson(projectRoot: string, tsconfigPath: string, root: SchemaRoot): Record<string, unknown> {
|
|
39
|
+
const generator = createGenerator({
|
|
40
|
+
path: join(projectRoot, root.path),
|
|
41
|
+
type: root.typeName,
|
|
42
|
+
tsconfig: tsconfigPath,
|
|
43
|
+
topRef: false,
|
|
44
|
+
skipTypeCheck: false,
|
|
45
|
+
jsDoc: "extended",
|
|
46
|
+
additionalProperties: root.kind === "config" ? false : undefined,
|
|
47
|
+
});
|
|
48
|
+
const schema = generator.createSchema(root.typeName) as Record<string, unknown>;
|
|
49
|
+
if (root.kind === "config") {
|
|
50
|
+
schema.additionalProperties = false;
|
|
51
|
+
}
|
|
52
|
+
return schema;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeGeneratedIndex(generatedDir: string, roots: SchemaRoot[]): void {
|
|
56
|
+
const sorted = [...roots].sort((left, right) => KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind));
|
|
57
|
+
const lines = ["// Auto-generated by argsbarg schemagen — do not edit by hand.", ""];
|
|
58
|
+
for (const root of sorted) {
|
|
59
|
+
lines.push(`import ${schemaJsonImportVar(root.kind)} from "./${schemaJsonBasename(root.kind)}";`);
|
|
60
|
+
}
|
|
61
|
+
if (sorted.length > 0) {
|
|
62
|
+
lines.push("");
|
|
63
|
+
}
|
|
64
|
+
for (const root of sorted) {
|
|
65
|
+
lines.push(
|
|
66
|
+
`export const ${schemaExportName(root.kind)} = ${schemaJsonImportVar(root.kind)} as Record<string, unknown>;`,
|
|
67
|
+
);
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
writeFileSync(join(generatedDir, "index.ts"), `${lines.join("\n")}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Generate colocated `__generated__` JSON schemas and `index.ts` barrels. */
|
|
74
|
+
export function runSchemagen(options: RunSchemagenOptions = {}): RunSchemagenResult {
|
|
75
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
76
|
+
const srcDir = options.srcDir ?? "src";
|
|
77
|
+
const tsconfigPath = resolveTsconfig(projectRoot, options.tsconfig ?? "tsconfig.json");
|
|
78
|
+
|
|
79
|
+
const roots = discoverSchemaRoots(projectRoot, srcDir);
|
|
80
|
+
const bySchemaFile = new Map<string, SchemaRoot[]>();
|
|
81
|
+
|
|
82
|
+
for (const root of roots) {
|
|
83
|
+
const schema = generateJson(projectRoot, tsconfigPath, root);
|
|
84
|
+
const generatedDir = join(dirname(join(projectRoot, root.path)), GENERATED_DIR);
|
|
85
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
86
|
+
const jsonPath = join(generatedDir, schemaJsonBasename(root.kind));
|
|
87
|
+
writeFileSync(jsonPath, `${JSON.stringify(schema, null, 2)}\n`);
|
|
88
|
+
console.log(`wrote ${relative(projectRoot, jsonPath)} (${root.typeName})`);
|
|
89
|
+
|
|
90
|
+
const list = bySchemaFile.get(root.path) ?? [];
|
|
91
|
+
list.push(root);
|
|
92
|
+
bySchemaFile.set(root.path, list);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [relPath, fileRoots] of bySchemaFile) {
|
|
96
|
+
const generatedDir = join(dirname(join(projectRoot, relPath)), GENERATED_DIR);
|
|
97
|
+
writeGeneratedIndex(generatedDir, fileRoots);
|
|
98
|
+
console.log(`wrote ${relative(projectRoot, join(generatedDir, "index.ts"))}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cleanStaleGenerated(projectRoot, srcDir, bySchemaFile);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
configRoots: roots.filter((r) => r.kind === "config").length,
|
|
105
|
+
inputRoots: roots.filter((r) => r.kind === "input").length,
|
|
106
|
+
outputRoots: roots.filter((r) => r.kind === "output").length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { discoverSchemaRoots } from "./discover-schema-roots.ts";
|
|
6
|
+
import { schemaExportName, schemaJsonBasename } from "./names.ts";
|
|
7
|
+
import { runSchemagen } from "./run.ts";
|
|
8
|
+
|
|
9
|
+
const exampleRoot = join(import.meta.dir, "../../../examples/full-example");
|
|
10
|
+
|
|
11
|
+
const tempRoots: string[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const root of tempRoots.splice(0)) {
|
|
15
|
+
rmSync(root, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function makeTempProject(): string {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "argsbarg-schemagen-"));
|
|
21
|
+
tempRoots.push(root);
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(root, "tsconfig.json"),
|
|
24
|
+
JSON.stringify(
|
|
25
|
+
{
|
|
26
|
+
compilerOptions: {
|
|
27
|
+
strict: true,
|
|
28
|
+
target: "ESNext",
|
|
29
|
+
module: "ESNext",
|
|
30
|
+
moduleResolution: "bundler",
|
|
31
|
+
resolveJsonModule: true,
|
|
32
|
+
},
|
|
33
|
+
include: ["src/**/*"],
|
|
34
|
+
},
|
|
35
|
+
null,
|
|
36
|
+
2,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeSchema(root: string, relDir: string, body: string): void {
|
|
43
|
+
const dir = join(root, "src", relDir);
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
writeFileSync(join(dir, "schema.ts"), body);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("schemagen", () => {
|
|
49
|
+
test("discovers AppConfig and StatusJsonOutput in full-example", () => {
|
|
50
|
+
const roots = discoverSchemaRoots(exampleRoot);
|
|
51
|
+
expect(roots.map((r) => `${r.kind}:${r.typeName}`).sort()).toEqual(["config:AppConfig", "output:StatusJsonOutput"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("maps schema kinds to __generated__ filenames and export names", () => {
|
|
55
|
+
expect(schemaJsonBasename("output")).toBe("outputSchema.json");
|
|
56
|
+
expect(schemaJsonBasename("config")).toBe("configSchema.json");
|
|
57
|
+
expect(schemaExportName("output")).toBe("outputSchema");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("runSchemagen writes __generated__ artifacts in full-example", () => {
|
|
61
|
+
const counts = runSchemagen({ projectRoot: exampleRoot });
|
|
62
|
+
expect(counts).toEqual({ configRoots: 1, inputRoots: 0, outputRoots: 1 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("removes stale JSON files when a schema kind is dropped", () => {
|
|
66
|
+
const root = makeTempProject();
|
|
67
|
+
writeSchema(
|
|
68
|
+
root,
|
|
69
|
+
"commands/demo",
|
|
70
|
+
`/** Tool input. */
|
|
71
|
+
export interface DemoInput { id: string; }
|
|
72
|
+
/** Tool output. */
|
|
73
|
+
export interface DemoOutput { ok: boolean; }
|
|
74
|
+
export type inputType = DemoInput;
|
|
75
|
+
export type outputType = DemoOutput;
|
|
76
|
+
`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
runSchemagen({ projectRoot: root });
|
|
80
|
+
const generatedDir = join(root, "src/commands/demo/__generated__");
|
|
81
|
+
expect(existsSync(join(generatedDir, "inputSchema.json"))).toBe(true);
|
|
82
|
+
expect(existsSync(join(generatedDir, "outputSchema.json"))).toBe(true);
|
|
83
|
+
|
|
84
|
+
writeSchema(
|
|
85
|
+
root,
|
|
86
|
+
"commands/demo",
|
|
87
|
+
`/** Tool output. */
|
|
88
|
+
export interface DemoOutput { ok: boolean; }
|
|
89
|
+
export type outputType = DemoOutput;
|
|
90
|
+
`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
runSchemagen({ projectRoot: root });
|
|
94
|
+
expect(existsSync(join(generatedDir, "inputSchema.json"))).toBe(false);
|
|
95
|
+
expect(existsSync(join(generatedDir, "outputSchema.json"))).toBe(true);
|
|
96
|
+
expect(existsSync(join(generatedDir, "index.ts"))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("removes orphan __generated__ when schema.ts is gone", () => {
|
|
100
|
+
const root = makeTempProject();
|
|
101
|
+
const generatedDir = join(root, "src/orphan/__generated__");
|
|
102
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
103
|
+
writeFileSync(join(generatedDir, "outputSchema.json"), "{}\n");
|
|
104
|
+
writeFileSync(join(generatedDir, "index.ts"), "export const outputSchema = {};\n");
|
|
105
|
+
|
|
106
|
+
runSchemagen({ projectRoot: root });
|
|
107
|
+
expect(existsSync(generatedDir)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("removes __generated__ when schema.ts has no discoverable roots", () => {
|
|
111
|
+
const root = makeTempProject();
|
|
112
|
+
writeSchema(
|
|
113
|
+
root,
|
|
114
|
+
"commands/empty",
|
|
115
|
+
`export type outputType = never;
|
|
116
|
+
`,
|
|
117
|
+
);
|
|
118
|
+
const generatedDir = join(root, "src/commands/empty/__generated__");
|
|
119
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
120
|
+
writeFileSync(join(generatedDir, "outputSchema.json"), "{}\n");
|
|
121
|
+
|
|
122
|
+
runSchemagen({ projectRoot: root });
|
|
123
|
+
expect(existsSync(generatedDir)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// Auto-generated by scripts/schemagen.ts — do not edit by hand.
|
|
2
|
-
|
|
3
|
-
import app_config from "./generated/app-config.json";
|
|
4
|
-
|
|
5
|
-
/** JSON Schema for program.appConfig.jsonSchema from `AppConfig`. */
|
|
6
|
-
export const APP_CONFIG_JSON_SCHEMA = app_config as Record<string, unknown>;
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { discoverSchemaRoots } from "./discover-schema-roots.ts";
|
|
4
|
-
|
|
5
|
-
const projectRoot = join(import.meta.dir, "../..");
|
|
6
|
-
|
|
7
|
-
describe("discover-schema-roots", () => {
|
|
8
|
-
test("finds AppConfig and StatusJsonOutput in schema-types.ts files", () => {
|
|
9
|
-
const roots = discoverSchemaRoots(projectRoot);
|
|
10
|
-
const config = roots.find((r) => r.kind === "config");
|
|
11
|
-
const output = roots.find((r) => r.kind === "output");
|
|
12
|
-
expect(config).toMatchObject({
|
|
13
|
-
typeName: "AppConfig",
|
|
14
|
-
path: "src/config/schema-types.ts",
|
|
15
|
-
outfile: "app-config.json",
|
|
16
|
-
exportName: "APP_CONFIG_JSON_SCHEMA",
|
|
17
|
-
});
|
|
18
|
-
expect(output).toMatchObject({
|
|
19
|
-
typeName: "StatusJsonOutput",
|
|
20
|
-
path: "src/commands/status/schema-types.ts",
|
|
21
|
-
outfile: "status.json",
|
|
22
|
-
exportName: "STATUS_JSON_OUTPUT_SCHEMA",
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Maps discovered schema root type names to generated filenames and bridge export constants.
|
|
3
|
-
Matches conventions in docs/output-schema.md and docs/config-schema.md.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/** kebab-case from PascalCase segments. */
|
|
7
|
-
export function camelToKebab(name: string): string {
|
|
8
|
-
return name
|
|
9
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
10
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
11
|
-
.toLowerCase();
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** SCREAMING_SNAKE from PascalCase. */
|
|
15
|
-
export function camelToScreamingSnake(name: string): string {
|
|
16
|
-
return camelToKebab(name).replace(/-/g, "_").toUpperCase();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Generated JSON filename for a tool-input schema root. */
|
|
20
|
-
export function outfileForInputType(typeName: string): string {
|
|
21
|
-
if (typeName.endsWith("ToolInput")) {
|
|
22
|
-
return `${camelToKebab(typeName.slice(0, -"ToolInput".length))}-tool-input.json`;
|
|
23
|
-
}
|
|
24
|
-
return `${camelToKebab(typeName)}-tool-input.json`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Bridge export constant for a tool-input schema root. */
|
|
28
|
-
export function inputSchemaExportName(typeName: string): string {
|
|
29
|
-
if (typeName.endsWith("ToolInput")) {
|
|
30
|
-
const base = typeName.slice(0, -"ToolInput".length);
|
|
31
|
-
return `${camelToScreamingSnake(base)}_TOOL_INPUT_SCHEMA`;
|
|
32
|
-
}
|
|
33
|
-
return `${camelToScreamingSnake(typeName)}_TOOL_INPUT_SCHEMA`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Generated JSON filename for an output-schema root type. */
|
|
37
|
-
export function outfileForOutputType(typeName: string): string {
|
|
38
|
-
if (typeName.endsWith("JsonOutput")) {
|
|
39
|
-
return `${camelToKebab(typeName.slice(0, -"JsonOutput".length))}.json`;
|
|
40
|
-
}
|
|
41
|
-
if (typeName.endsWith("OpResult")) {
|
|
42
|
-
return `${camelToKebab(typeName.slice(0, -"OpResult".length))}-op-result.json`;
|
|
43
|
-
}
|
|
44
|
-
if (typeName.endsWith("Output")) {
|
|
45
|
-
return `${camelToKebab(typeName.slice(0, -"Output".length))}.json`;
|
|
46
|
-
}
|
|
47
|
-
if (typeName.endsWith("Result")) {
|
|
48
|
-
return `${camelToKebab(typeName.slice(0, -"Result".length))}.json`;
|
|
49
|
-
}
|
|
50
|
-
return `${camelToKebab(typeName)}.json`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Bridge export constant for an output-schema root. */
|
|
54
|
-
export function outputSchemaExportName(typeName: string): string {
|
|
55
|
-
if (typeName.endsWith("JsonOutput")) {
|
|
56
|
-
const base = typeName.slice(0, -"JsonOutput".length);
|
|
57
|
-
return `${camelToScreamingSnake(base)}_JSON_OUTPUT_SCHEMA`;
|
|
58
|
-
}
|
|
59
|
-
if (typeName.endsWith("OpResult")) {
|
|
60
|
-
const base = typeName.slice(0, -"OpResult".length);
|
|
61
|
-
return `${camelToScreamingSnake(base)}_OP_RESULT_OUTPUT_SCHEMA`;
|
|
62
|
-
}
|
|
63
|
-
if (typeName.endsWith("Output")) {
|
|
64
|
-
const base = typeName.slice(0, -"Output".length);
|
|
65
|
-
return `${camelToScreamingSnake(base)}_OUTPUT_SCHEMA`;
|
|
66
|
-
}
|
|
67
|
-
if (typeName.endsWith("Result")) {
|
|
68
|
-
const base = typeName.slice(0, -"Result".length);
|
|
69
|
-
return `${camelToScreamingSnake(base)}_RESULT_OUTPUT_SCHEMA`;
|
|
70
|
-
}
|
|
71
|
-
return `${camelToScreamingSnake(typeName)}_OUTPUT_SCHEMA`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Generated JSON filename for a config-schema root (typically AppConfig → app-config.json). */
|
|
75
|
-
export function outfileForConfigType(typeName: string): string {
|
|
76
|
-
if (typeName.endsWith("Config")) {
|
|
77
|
-
return `${camelToKebab(typeName.slice(0, -"Config".length))}-config.json`;
|
|
78
|
-
}
|
|
79
|
-
return `${camelToKebab(typeName)}-config.json`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Bridge export constant for a config-schema root (AppConfig → APP_CONFIG_JSON_SCHEMA). */
|
|
83
|
-
export function configSchemaExportName(typeName: string): string {
|
|
84
|
-
if (typeName.endsWith("Config")) {
|
|
85
|
-
const base = typeName.slice(0, -"Config".length);
|
|
86
|
-
return `${camelToScreamingSnake(base)}_CONFIG_JSON_SCHEMA`;
|
|
87
|
-
}
|
|
88
|
-
return `${camelToScreamingSnake(typeName)}_CONFIG_JSON_SCHEMA`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Import path basename for a generated JSON file (no extension). */
|
|
92
|
-
export function jsonImportBasename(outfile: string): string {
|
|
93
|
-
return outfile.replace(/\.json$/, "");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Safe import binding for a generated JSON file (no extension, hyphens → underscores). */
|
|
97
|
-
export function jsonImportVar(outfile: string): string {
|
|
98
|
-
return jsonImportBasename(outfile).replace(/-/g, "_");
|
|
99
|
-
}
|