argsbarg 6.0.1 → 6.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +26 -2
  2. package/README.md +4 -3
  3. package/docs/api-server.md +1 -1
  4. package/docs/cli-program.md +28 -0
  5. package/docs/config-schema.md +15 -13
  6. package/docs/output-schema.md +76 -73
  7. package/examples/full-example/README.md +7 -7
  8. package/examples/full-example/bun.lock +0 -29
  9. package/examples/full-example/justfile +6 -3
  10. package/examples/full-example/package.json +0 -2
  11. package/examples/full-example/src/commands/status/__generated__/index.ts +5 -0
  12. package/examples/full-example/src/commands/status/command.ts +2 -2
  13. package/examples/full-example/src/commands/status/types.ts +19 -1
  14. package/examples/full-example/src/config/__generated__/index.ts +5 -0
  15. package/examples/full-example/src/program.ts +4 -4
  16. package/index.d.ts +24 -3
  17. package/package.json +8 -1
  18. package/src/cli-tool/full-example-capabilities.test.ts +2 -1
  19. package/src/cli-tool/post-create.ts +3 -3
  20. package/src/cli-tool/program.ts +36 -0
  21. package/src/cli-tool/run-schemagen.ts +23 -0
  22. package/src/cli-tool/schemagen/cleanup.ts +60 -0
  23. package/src/cli-tool/schemagen/discover-schema-roots.ts +173 -0
  24. package/src/cli-tool/schemagen/index.ts +3 -0
  25. package/src/cli-tool/schemagen/names.ts +22 -0
  26. package/src/cli-tool/schemagen/run.ts +109 -0
  27. package/src/cli-tool/schemagen/schemagen.test.ts +143 -0
  28. package/src/context.ts +20 -2
  29. package/src/help.ts +2 -0
  30. package/src/index.ts +1 -0
  31. package/src/leaf-inputs.test.ts +170 -0
  32. package/src/leaf-inputs.ts +178 -0
  33. package/src/mcp/tools.ts +5 -0
  34. package/src/parse.ts +11 -0
  35. package/src/types.ts +8 -1
  36. package/src/validate.ts +43 -0
  37. package/examples/full-example/schemas/configSchemas.ts +0 -6
  38. package/examples/full-example/schemas/outputSchemas.ts +0 -6
  39. package/examples/full-example/scripts/schemagen/discover-schema-roots.test.ts +0 -25
  40. package/examples/full-example/scripts/schemagen/discover-schema-roots.ts +0 -148
  41. package/examples/full-example/scripts/schemagen/naming.ts +0 -99
  42. package/examples/full-example/scripts/schemagen.ts +0 -91
  43. package/examples/full-example/src/commands/status/schema-types.ts +0 -14
  44. /package/examples/full-example/{schemas/generated/status.json → src/commands/status/__generated__/outputSchema.json} +0 -0
  45. /package/examples/full-example/{schemas/generated/app-config.json → src/config/__generated__/configSchema.json} +0 -0
  46. /package/examples/full-example/src/config/{schema-types.ts → types.ts} +0 -0
@@ -1 +1,19 @@
1
- export type { StatusJsonOutput } from "./schema-types.ts";
1
+ /** JSON stdout for `full-example status --json`. */
2
+ export interface StatusJsonOutput {
3
+ /** Resolved AWS region. */
4
+ defaultRegion?: string;
5
+ /** Resolved retry count. */
6
+ maxRetries?: number;
7
+ /** Whether apiToken is set (value never included). */
8
+ apiTokenSet: boolean;
9
+ /** App version from program root. */
10
+ version: string;
11
+ }
12
+
13
+ /** Returns status JSON (identity helper for schema generation). */
14
+ export function buildStatusJson(output: StatusJsonOutput): StatusJsonOutput {
15
+ return output;
16
+ }
17
+
18
+ /** Schemagen root for leaf outputSchema. */
19
+ export type outputType = StatusJsonOutput;
@@ -0,0 +1,5 @@
1
+ // Auto-generated by argsbarg schemagen — do not edit by hand.
2
+
3
+ import configSchemaJson from "./configSchema.json";
4
+
5
+ export const configSchema = configSchemaJson as Record<string, unknown>;
@@ -4,12 +4,12 @@ Kitchen-sink CliProgram — every argsbarg builtin enabled; command registration
4
4
 
5
5
  import type { CliAppConfig, CliAppConfigEntry, CliProgram } from "argsbarg";
6
6
  import readmeText from "../README.md" with { type: "text" };
7
- import { APP_CONFIG_JSON_SCHEMA } from "../schemas/configSchemas.ts";
8
7
  import { createIdentity } from "../scripts/create-identity.ts";
9
8
  import { echoCommand } from "./commands/echo/command.ts";
10
9
  import { statusCommand } from "./commands/status/command.ts";
10
+ import { configSchema } from "./config/__generated__/index.ts";
11
11
 
12
- const configSchema = {
12
+ const configEntries = {
13
13
  apiToken: {
14
14
  description: "Create at https://example.com/settings/tokens",
15
15
  env: `${createIdentity.envPrefix}_API_TOKEN`,
@@ -34,8 +34,8 @@ export const program = {
34
34
  version: "1.0.0",
35
35
  description: createIdentity.desc,
36
36
  appConfig: {
37
- jsonSchema: APP_CONFIG_JSON_SCHEMA,
38
- entries: configSchema,
37
+ jsonSchema: configSchema,
38
+ entries: configEntries,
39
39
  } satisfies CliAppConfig,
40
40
  docs: {
41
41
  enabled: true,
package/index.d.ts CHANGED
@@ -45,7 +45,7 @@ declare class AppConfigSnapshot {
45
45
  }
46
46
  export type AnyAppConfigSnapshot = AppConfigSnapshot | EmptyAppConfigSnapshot;
47
47
  /** Coerced leaf inputs keyed by option and positional names. */
48
- export type CliLeafInputs = Record<string, boolean | number | string | string[] | undefined>;
48
+ export type CliLeafInputs = Record<string, boolean | number | string | string[] | unknown | undefined>;
49
49
  /**
50
50
  * Values passed to a leaf command handler after parsing: app name, routed path, args, and merged options.
51
51
  */
@@ -92,6 +92,11 @@ export declare class CliContext {
92
92
  positional(name: string): string | string[] | undefined;
93
93
  /** Reads coerced option and positional values for the current leaf from schema metadata. */
94
94
  readLeafInputs(): CliLeafInputs;
95
+ /**
96
+ * Reads coerced leaf inputs, resolving Json options from flags, piped stdin (when `pipable`),
97
+ * or MCP/API toolArgs, and validates against `leaf.inputSchema` when set.
98
+ */
99
+ readLeafInputsAsync(): Promise<CliLeafInputs>;
95
100
  private _readOptionValue;
96
101
  private _leafNode;
97
102
  private _posMap;
@@ -102,7 +107,7 @@ export declare class CliContext {
102
107
  */
103
108
  export type CliInvocation = "cli" | "mcp" | "api";
104
109
  /**
105
- * Option kinds: presence (boolean flag), string (free-form text), number (strict double), or enum (fixed choices).
110
+ * Option kinds: presence (boolean flag), string (free-form text), number (strict double), enum (fixed choices), or json (parsed JSON object/array).
106
111
  */
107
112
  export declare enum CliOptionKind {
108
113
  /** Boolean flag: no value token (may be implicit `"1"` when set). */
@@ -112,7 +117,9 @@ export declare enum CliOptionKind {
112
117
  /** Strict floating-point value (parsed at validation time). */
113
118
  Number = "number",
114
119
  /** Fixed set of allowed string values. Requires non-empty `choices` on the option. */
115
- Enum = "enum"
120
+ Enum = "enum",
121
+ /** JSON object or array (parsed from `--name '<json>'`, piped stdin when `pipable`, or MCP/API tool body). */
122
+ Json = "json"
116
123
  }
117
124
  /**
118
125
  * Named validation/coercion for string options (`format` on `CliOption`).
@@ -176,6 +183,11 @@ export interface CliOption {
176
183
  default?: string;
177
184
  /** Regex pattern for string options. Mutually exclusive with `format`. */
178
185
  pattern?: string;
186
+ /**
187
+ * When `true` on a `Json` option, CLI may omit `--name` and supply JSON via stdin instead.
188
+ * If `--name` is set, the flag value wins and stdin is not read.
189
+ */
190
+ pipable?: boolean;
179
191
  }
180
192
  /**
181
193
  * An ordered positional argument slot, listed on leaf `positionals`.
@@ -631,6 +643,15 @@ dryRun?: boolean,
631
643
  interactive?: boolean): void;
632
644
  /** Prefixes a success message when running in dry-run mode. */
633
645
  export declare function formatDryRunMessage(message: string, dryRun: boolean): string;
646
+ /** Thrown when {@link CliContext.readLeafInputsAsync} cannot resolve or validate inputs. */
647
+ export declare class LeafInputError extends Error {
648
+ constructor(message: string);
649
+ }
650
+ /**
651
+ * Reads coerced leaf inputs, resolving Json options from flags, piped stdin, or toolArgs,
652
+ * and validates against `leaf.inputSchema` when set.
653
+ */
654
+ export declare function readLeafInputsAsync(ctx: CliContext): Promise<CliLeafInputs>;
634
655
  /** Resolved paths for `mcp bundle`. */
635
656
  export interface McpBundlePaths {
636
657
  binaryPath: string;
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "6.0.1",
3
+ "version": "6.1.0",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
+ "dependencies": {
7
+ "ts-json-schema-generator": "^2.3.0"
8
+ },
6
9
  "devDependencies": {
7
10
  "@biomejs/biome": "^2.5.0",
8
11
  "@types/bun": "^1.3.12",
@@ -13,6 +16,10 @@
13
16
  ".": {
14
17
  "types": "./index.d.ts",
15
18
  "default": "./src/index.ts"
19
+ },
20
+ "./schemagen": {
21
+ "types": "./src/cli-tool/schemagen/index.ts",
22
+ "default": "./src/cli-tool/schemagen/index.ts"
16
23
  }
17
24
  },
18
25
  "bin": {
@@ -63,7 +63,8 @@ describe("full-example template", () => {
63
63
 
64
64
  test("status command defines outputSchema", () => {
65
65
  const statusSource = readFileSync(join(exampleRoot, "src/commands/status/command.ts"), "utf8");
66
- expect(statusSource).toContain("outputSchema:");
66
+ expect(statusSource).toMatch(/outputSchema[,:]/);
67
+ expect(statusSource).toContain('from "./__generated__/index.ts"');
67
68
  });
68
69
 
69
70
  test("resolveCapabilities matches full sink shape", () => {
@@ -41,10 +41,10 @@ export async function runPostCreate(targetDir: string, dryRun: boolean): Promise
41
41
  },
42
42
  },
43
43
  {
44
- label: "bun scripts/schemagen.ts",
44
+ label: "argsbarg schemagen",
45
45
  run: () => {
46
46
  if (dryRun) return;
47
- const proc = Bun.spawnSync(["bun", "scripts/schemagen.ts"], {
47
+ const proc = Bun.spawnSync(["argsbarg", "schemagen"], {
48
48
  cwd: abs,
49
49
  stdout: "inherit",
50
50
  stderr: "inherit",
@@ -103,7 +103,7 @@ export async function runPostCreate(targetDir: string, dryRun: boolean): Promise
103
103
  export function printPostCreatePlan(): void {
104
104
  process.stderr.write("Post-create steps:\n");
105
105
  process.stderr.write(" 1. bun install\n");
106
- process.stderr.write(" 2. bun scripts/schemagen.ts\n");
106
+ process.stderr.write(" 2. just schemagen\n");
107
107
  process.stderr.write(" 3. bun test\n");
108
108
  process.stderr.write(" 4. git init + Initial commit (skipped inside existing git work tree)\n");
109
109
  }
@@ -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,40 @@ export const program = {
92
93
  process.exit(code);
93
94
  },
94
95
  },
96
+ {
97
+ key: "schemagen",
98
+ description:
99
+ "Generate JSON Schema artifacts from src/**/types.ts role exports into colocated __generated__/ directories.",
100
+ options: [
101
+ {
102
+ name: "root",
103
+ description: "Project root (default: current working directory).",
104
+ kind: CliOptionKind.String,
105
+ },
106
+ {
107
+ name: "src-dir",
108
+ description: "Source directory relative to --root (default: src).",
109
+ kind: CliOptionKind.String,
110
+ },
111
+ {
112
+ name: "tsconfig",
113
+ description: "Path to tsconfig relative to --root (default: tsconfig.json).",
114
+ kind: CliOptionKind.String,
115
+ },
116
+ ],
117
+ handler: (ctx) => {
118
+ try {
119
+ runSchemagenCli({
120
+ root: ctx.stringOpt("root"),
121
+ srcDir: ctx.stringOpt("src-dir"),
122
+ tsconfig: ctx.stringOpt("tsconfig"),
123
+ });
124
+ } catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ process.stderr.write(`${message}\n`);
127
+ process.exit(1);
128
+ }
129
+ },
130
+ },
95
131
  ],
96
132
  } 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, schemaJsonBasename, TYPES_FILE } 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 relTypesPath = relative(projectRoot, join(dirname(generatedDir), TYPES_FILE));
42
+ const roots = existsSync(join(projectRoot, relTypesPath)) ? (activeBySchemaFile.get(relTypesPath) ?? []) : [];
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
+ }
@@ -0,0 +1,173 @@
1
+ /*
2
+ Discovers schema roots in types.ts files via configType / inputType / outputType exports.
3
+ */
4
+
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { dirname, join, relative } from "node:path";
7
+ import { TYPES_FILE } from "./names.ts";
8
+
9
+ export type SchemaRootKind = "config" | "input" | "output";
10
+
11
+ export type SchemaRole = "configType" | "inputType" | "outputType";
12
+
13
+ export interface SchemaRoot {
14
+ kind: SchemaRootKind;
15
+ typeName: string;
16
+ /** Path to types.ts relative to project root (anchors __generated__/ output). */
17
+ path: string;
18
+ /** Path to the file that defines `typeName` (defaults to `path`). */
19
+ sourcePath: string;
20
+ }
21
+
22
+ const ROLE_EXPORT_RE = /export\s+type\s+(configType|inputType|outputType)\s*=\s*(\w+)/g;
23
+ const HAS_ROLE_EXPORT_RE = /export\s+type\s+(configType|inputType|outputType)\s*=/;
24
+ const IMPORT_RE = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
25
+
26
+ const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
27
+ configType: "config",
28
+ inputType: "input",
29
+ outputType: "output",
30
+ };
31
+
32
+ function listTypesManifestFiles(srcDir: string, baseDir: string, out: string[]): void {
33
+ for (const ent of readdirSync(srcDir)) {
34
+ const full = join(srcDir, ent);
35
+ const st = statSync(full);
36
+ if (st.isDirectory()) {
37
+ listTypesManifestFiles(full, baseDir, out);
38
+ continue;
39
+ }
40
+ if (ent !== TYPES_FILE) {
41
+ continue;
42
+ }
43
+ const text = readFileSync(full, "utf8");
44
+ if (HAS_ROLE_EXPORT_RE.test(text)) {
45
+ out.push(relative(baseDir, full));
46
+ }
47
+ }
48
+ }
49
+
50
+ /** True when `typeName` is declared in this file (not a schemagen role alias). */
51
+ function isTypeDefinedInFile(text: string, typeName: string): boolean {
52
+ if (new RegExp(`export\\s+interface\\s+${typeName}\\b`).test(text)) {
53
+ return true;
54
+ }
55
+ if (new RegExp(`export\\s+type\\s+${typeName}\\s*=`).test(text)) {
56
+ return !["configType", "inputType", "outputType"].includes(typeName);
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function parseLocalTypeImports(text: string): Map<string, string> {
62
+ const imports = new Map<string, string>();
63
+ for (const match of text.matchAll(IMPORT_RE)) {
64
+ const names = match[1];
65
+ const from = match[2];
66
+ if (!names || !from?.startsWith(".")) {
67
+ continue;
68
+ }
69
+ for (const part of names.split(",")) {
70
+ const trimmed = part.trim();
71
+ const nameMatch = trimmed.match(/^(?:type\s+)?(\w+)(?:\s+as\s+(\w+))?$/);
72
+ if (!nameMatch?.[1]) {
73
+ continue;
74
+ }
75
+ const localName = nameMatch[2] ?? nameMatch[1];
76
+ imports.set(localName, from);
77
+ }
78
+ }
79
+ return imports;
80
+ }
81
+
82
+ function resolveModuleFile(manifestFile: string, specifier: string): string | null {
83
+ const base = join(dirname(manifestFile), specifier);
84
+ const candidates = [base, `${base}.ts`, join(base, "index.ts")];
85
+ for (const candidate of candidates) {
86
+ if (existsSync(candidate)) {
87
+ return candidate;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /** Resolve a role alias to the module that defines `typeName`, when imported from a relative path. */
94
+ function resolveAliasedTypeSource(
95
+ projectRoot: string,
96
+ manifestRelPath: string,
97
+ manifestText: string,
98
+ typeName: string,
99
+ ): string | null {
100
+ const manifestFile = join(projectRoot, manifestRelPath);
101
+ const specifier = parseLocalTypeImports(manifestText).get(typeName);
102
+ if (!specifier) {
103
+ return null;
104
+ }
105
+ const moduleFile = resolveModuleFile(manifestFile, specifier);
106
+ if (!moduleFile) {
107
+ return null;
108
+ }
109
+ const moduleText = readFileSync(moduleFile, "utf8");
110
+ if (!isTypeDefinedInFile(moduleText, typeName)) {
111
+ return null;
112
+ }
113
+ return relative(projectRoot, moduleFile);
114
+ }
115
+
116
+ function discoverFromFile(projectRoot: string, path: string, text: string): SchemaRoot[] {
117
+ const rolesSeen = new Set<SchemaRole>();
118
+ const roots: SchemaRoot[] = [];
119
+
120
+ for (const match of text.matchAll(ROLE_EXPORT_RE)) {
121
+ const role = match[1] as SchemaRole | undefined;
122
+ const typeName = match[2];
123
+ if (!role || !typeName) {
124
+ continue;
125
+ }
126
+ if (rolesSeen.has(role)) {
127
+ throw new Error(`${path}: duplicate export type ${role}`);
128
+ }
129
+ rolesSeen.add(role);
130
+
131
+ let sourcePath = path;
132
+ if (!isTypeDefinedInFile(text, typeName)) {
133
+ const resolved = resolveAliasedTypeSource(projectRoot, path, text, typeName);
134
+ if (!resolved) {
135
+ continue;
136
+ }
137
+ sourcePath = resolved;
138
+ }
139
+
140
+ roots.push({ kind: ROLE_TO_KIND[role], typeName, path, sourcePath });
141
+ }
142
+
143
+ return roots;
144
+ }
145
+
146
+ /** Find all schema roots under `srcDir` in `types.ts` files with role exports. */
147
+ export function discoverSchemaRoots(projectRoot: string, srcDir = "src"): SchemaRoot[] {
148
+ const srcPath = join(projectRoot, srcDir);
149
+ const files: string[] = [];
150
+ listTypesManifestFiles(srcPath, projectRoot, files);
151
+
152
+ const roots: SchemaRoot[] = [];
153
+ const typeOwners = new Map<string, string>();
154
+
155
+ for (const relPath of files.sort()) {
156
+ const text = readFileSync(join(projectRoot, relPath), "utf8");
157
+ for (const root of discoverFromFile(projectRoot, relPath, text)) {
158
+ const prev = typeOwners.get(root.typeName);
159
+ if (prev) {
160
+ throw new Error(`${relPath}: duplicate schema root type ${root.typeName} (already declared in ${prev})`);
161
+ }
162
+ typeOwners.set(root.typeName, relPath);
163
+ roots.push(root);
164
+ }
165
+ }
166
+
167
+ const configRoots = roots.filter((r) => r.kind === "config");
168
+ if (configRoots.length > 1) {
169
+ throw new Error(`multiple config schema roots: ${configRoots.map((r) => `${r.typeName} (${r.path})`).join(", ")}`);
170
+ }
171
+
172
+ return roots;
173
+ }
@@ -0,0 +1,3 @@
1
+ export { discoverSchemaRoots, type SchemaRoot, type SchemaRootKind } from "./discover-schema-roots.ts";
2
+ export { GENERATED_DIR, schemaExportName, schemaJsonBasename, TYPES_FILE } 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 `types.ts`). */
4
+ export const GENERATED_DIR = "__generated__";
5
+
6
+ /** TypeScript schemagen manifest filename under `src/`. */
7
+ export const TYPES_FILE = "types.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,109 @@
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 typeFile = join(projectRoot, root.sourcePath);
40
+ const generator = createGenerator({
41
+ path: typeFile,
42
+ type: root.typeName,
43
+ tsconfig: tsconfigPath,
44
+ topRef: false,
45
+ skipTypeCheck: false,
46
+ jsDoc: "extended",
47
+ additionalProperties: root.kind === "config" ? false : undefined,
48
+ });
49
+ const schema = generator.createSchema(root.typeName) as Record<string, unknown>;
50
+ if (root.kind === "config") {
51
+ schema.additionalProperties = false;
52
+ }
53
+ return schema;
54
+ }
55
+
56
+ function writeGeneratedIndex(generatedDir: string, roots: SchemaRoot[]): void {
57
+ const sorted = [...roots].sort((left, right) => KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind));
58
+ const lines = ["// Auto-generated by argsbarg schemagen — do not edit by hand.", ""];
59
+ for (const root of sorted) {
60
+ lines.push(`import ${schemaJsonImportVar(root.kind)} from "./${schemaJsonBasename(root.kind)}";`);
61
+ }
62
+ if (sorted.length > 0) {
63
+ lines.push("");
64
+ }
65
+ for (const root of sorted) {
66
+ lines.push(
67
+ `export const ${schemaExportName(root.kind)} = ${schemaJsonImportVar(root.kind)} as Record<string, unknown>;`,
68
+ );
69
+ lines.push("");
70
+ }
71
+ writeFileSync(join(generatedDir, "index.ts"), `${lines.join("\n")}`);
72
+ }
73
+
74
+ /** Generate colocated `__generated__` JSON schemas and `index.ts` barrels. */
75
+ export function runSchemagen(options: RunSchemagenOptions = {}): RunSchemagenResult {
76
+ const projectRoot = options.projectRoot ?? process.cwd();
77
+ const srcDir = options.srcDir ?? "src";
78
+ const tsconfigPath = resolveTsconfig(projectRoot, options.tsconfig ?? "tsconfig.json");
79
+
80
+ const roots = discoverSchemaRoots(projectRoot, srcDir);
81
+ const bySchemaFile = new Map<string, SchemaRoot[]>();
82
+
83
+ for (const root of roots) {
84
+ const schema = generateJson(projectRoot, tsconfigPath, root);
85
+ const generatedDir = join(dirname(join(projectRoot, root.path)), GENERATED_DIR);
86
+ mkdirSync(generatedDir, { recursive: true });
87
+ const jsonPath = join(generatedDir, schemaJsonBasename(root.kind));
88
+ writeFileSync(jsonPath, `${JSON.stringify(schema, null, 2)}\n`);
89
+ console.log(`wrote ${relative(projectRoot, jsonPath)} (${root.typeName})`);
90
+
91
+ const list = bySchemaFile.get(root.path) ?? [];
92
+ list.push(root);
93
+ bySchemaFile.set(root.path, list);
94
+ }
95
+
96
+ for (const [relPath, fileRoots] of bySchemaFile) {
97
+ const generatedDir = join(dirname(join(projectRoot, relPath)), GENERATED_DIR);
98
+ writeGeneratedIndex(generatedDir, fileRoots);
99
+ console.log(`wrote ${relative(projectRoot, join(generatedDir, "index.ts"))}`);
100
+ }
101
+
102
+ cleanStaleGenerated(projectRoot, srcDir, bySchemaFile);
103
+
104
+ return {
105
+ configRoots: roots.filter((r) => r.kind === "config").length,
106
+ inputRoots: roots.filter((r) => r.kind === "input").length,
107
+ outputRoots: roots.filter((r) => r.kind === "output").length,
108
+ };
109
+ }