@amritk/mjst 0.13.9 → 0.14.8

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.
@@ -4,4 +4,4 @@
4
4
  * documents these flags (config.schema.json) is not shipped in the published
5
5
  * package; the help-text test cross-checks that every flag is listed.
6
6
  */
7
- export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nSubcommands (each with its own flags and --help):\n mjst lint <files> Lint JSON/YAML documents against a ruleset\n mjst compile-api <module> --out <file> Compile @amritk/api route contracts into a fetch-handler module\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --validators Also emit validateX/isX validators into a validators/ subdir\n --examples Also emit fast-check arbitraries + example values under examples/\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --case-insensitive Normalize mis-cased enum/const strings to their exact casing\n --log-warnings console.warn on input keys not declared in the schema\n\nReference resolution:\n --resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)\n --allowed-hosts <h> Restrict remote $ref fetches to these hosts (comma-separated; implies --resolve-remote)\n --allow-private-hosts Permit remote $refs to private/loopback hosts (SSRF guard, off by default)\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
7
+ export declare const HELP_TEXT = "mjst \u2014 generate TypeScript parsers and type definitions from JSON Schemas\n\nUsage:\n mjst --schema <path> --out-dir <dir> [options]\n mjst --schema-dir <dir> --out-dir <dir> [options]\n mjst --schema <path> --out-file <file> --types-only [options]\n\nSubcommands (each with its own flags and --help):\n mjst lint <files> Lint JSON/YAML documents against a ruleset\n mjst compile-api <module> --out <file> Compile @amritk/api route contracts into a fetch-handler module\n\nInput:\n --schema <path> Schema to process: a JSON Schema file, or a module when --input is set\n --schema-dir <dir> Directory of JSON Schemas, processed recursively (instead of --schema)\n --input <format> Schema source format: json (default), typebox, zod, valibot, effect\n --export <name> Which export of the schema module to use when --input is not json\n\nOutput:\n --out-dir <dir> Output directory for the generated files\n --out-file <file> Single-file output instead of a directory (requires --types-only)\n --types-only Generate type definitions only, without parser functions\n --validators Also emit validateX/isX validators into a validators/ subdir\n --examples Also emit fast-check arbitraries + example values under examples/\n --build Compile the generated files to .js/.d.ts (implies --import-ext js)\n --force Overwrite pre-existing files in the output destination\n --import-ext <ext> Extension on generated relative imports: ts (default) or js\n --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)\n --root-type <name> Root type name for a single --schema run (default: schema title or filename)\n --type-suffix <s> Suffix appended to every $ref-derived type name\n --banner [text] Prepend a header comment to every generated file\n --readonly Emit deeply readonly type definitions\n\nValidation:\n --strict Throw on type/shape mismatches instead of coercing to defaults\n --strip-unknown Silently drop undeclared input keys at every nesting level\n --case-insensitive Normalize mis-cased enum/const strings to their exact casing\n --log-warnings console.warn on input keys not declared in the schema\n\nReference resolution:\n --resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)\n --allowed-hosts <h> Restrict remote $ref fetches to these hosts (comma-separated; implies --resolve-remote)\n --allow-private-hosts Permit remote $refs to private/loopback hosts (SSRF guard, off by default)\n --allowed-roots <d> Extra directories a local $ref may resolve into, on top of the schema's own\n (comma-separated; relative to the current directory)\n\nMisc:\n --config <path> JSON config file with the same keys; CLI flags take precedence\n --version, -v Print the CLI version\n --help, -h Print this help\n\nDocs: https://github.com/amritk/mjst/tree/main/packages/cli#readme\n";
package/dist/help-text.js CHANGED
@@ -22,6 +22,7 @@ Output:
22
22
  --validators Also emit validateX/isX validators into a validators/ subdir
23
23
  --examples Also emit fast-check arbitraries + example values under examples/
24
24
  --build Compile the generated files to .js/.d.ts (implies --import-ext js)
25
+ --force Overwrite pre-existing files in the output destination
25
26
  --import-ext <ext> Extension on generated relative imports: ts (default) or js
26
27
  --helpers <mode> Runtime helpers: package or embedded (default: auto-detect from package.json)
27
28
  --root-type <name> Root type name for a single --schema run (default: schema title or filename)
@@ -39,6 +40,8 @@ Reference resolution:
39
40
  --resolve-remote Fetch http(s) $refs while resolving cross-file/remote refs (off by default)
40
41
  --allowed-hosts <h> Restrict remote $ref fetches to these hosts (comma-separated; implies --resolve-remote)
41
42
  --allow-private-hosts Permit remote $refs to private/loopback hosts (SSRF guard, off by default)
43
+ --allowed-roots <d> Extra directories a local $ref may resolve into, on top of the schema's own
44
+ (comma-separated; relative to the current directory)
42
45
 
43
46
  Misc:
44
47
  --config <path> JSON config file with the same keys; CLI flags take precedence
@@ -10,6 +10,13 @@ export type ResolverOptions = {
10
10
  allowedHosts?: string[];
11
11
  /** Permit remote refs to private/loopback/link-local hosts (off by default as an SSRF guard). */
12
12
  allowPrivateHosts?: boolean;
13
+ /**
14
+ * Extra directories a local `$ref` may resolve into, on top of the linted
15
+ * document's own directory (which is always allowed). Empty by default, which
16
+ * keeps a `$ref` from walking out of the tree the document belongs to.
17
+ * Relative entries are resolved against the process working directory.
18
+ */
19
+ allowedRoots?: string[];
13
20
  };
14
21
  /**
15
22
  * Builds a {@link LintResolver} backed by `@amritk/resolve-refs`, dereferencing
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { resolve as resolvePath } from "node:path";
2
+ import { dirname, resolve as resolvePath } from "node:path";
3
3
  import { createDocument, resolveSourceOriginFromMap } from "@amritk/lint";
4
4
  import { DiagnosticSeverity } from "@amritk/lint/types";
5
5
  import { resolveRefs, resolveRefsFromFile } from "@amritk/resolve-refs";
6
6
  import { parse as parseYaml } from "@amritk/yaml";
7
+ import { withAllowedRootsHint } from "../allowed-roots-hint.js";
7
8
  import { hasExternalRefs } from "../has-external-refs.js";
8
9
  const isRemote = (location) => /^https?:\/\//i.test(location);
9
10
  const parseDoc = (content, location) => {
@@ -51,7 +52,7 @@ const toFindings = (errors, root) => errors.map((error) => {
51
52
  const location = error.path.length > 0 ? root.getLocationForJsonPath(error.path, true) : void 0;
52
53
  const finding = {
53
54
  code: "unresolved-ref",
54
- message: error.message,
55
+ message: withAllowedRootsHint(error.message),
55
56
  path: error.path,
56
57
  severity: DiagnosticSeverity.Error,
57
58
  range: location?.range ?? DOCUMENT_START
@@ -68,12 +69,16 @@ const createLintResolver = (options = {}) => {
68
69
  parse: parseDoc,
69
70
  trackOrigins: true
70
71
  };
72
+ const extraRoots = (options.allowedRoots ?? []).map((root) => resolvePath(root));
71
73
  return async (document, { input }) => {
72
74
  const { source } = document;
73
75
  if (source && hasExternalRefs(document.data)) {
74
76
  const absolute = resolvePath(source);
75
77
  if (readsBackAs(absolute, input)) {
76
- const { resolved: resolved2, origins: origins2, errors: errors2 } = await resolveRefsFromFile(absolute, fromFileOptions);
78
+ const { resolved: resolved2, origins: origins2, errors: errors2 } = await resolveRefsFromFile(absolute, {
79
+ ...fromFileOptions,
80
+ ...extraRoots.length > 0 ? { allowedRoots: [dirname(absolute), ...extraRoots] } : {}
81
+ });
77
82
  return {
78
83
  resolved: resolved2,
79
84
  sources: buildSourceSet(document, resolved2, origins2, absolute),
package/dist/lint/run.js CHANGED
@@ -47,39 +47,65 @@ const mapWithConcurrency = async (items, limit, worker) => {
47
47
  await Promise.all(runners);
48
48
  return results;
49
49
  };
50
+ const parseArgs = async (argv) => {
51
+ try {
52
+ const parsed = await yargs(argv).scriptName("lint").parserConfiguration({ "greedy-arrays": false }).strict().exitProcess(false).fail((message, error) => {
53
+ throw error ?? new Error(message);
54
+ }).usage("$0 [documents..]", "Lint JSON/YAML documents against a ruleset").positional("documents", { describe: "Documents or globs to lint", type: "string", array: true }).option("ruleset", { alias: "r", type: "string", describe: "Path to a ruleset file" }).option("encoding", { type: "string", default: "utf8", describe: "Input encoding" }).option("fail-severity", { alias: "F", type: "string", default: "error", choices: Object.keys(SEVERITY_BY_NAME) }).option("display-only-failures", { alias: "D", type: "boolean", default: false }).option("verbose", { type: "boolean", default: false }).option("quiet", { alias: "q", type: "boolean", default: false }).option("stdin-filepath", {
55
+ type: "string",
56
+ describe: "Path to associate with stdin input (labels findings and enables ruleset discovery)"
57
+ }).option("concurrency", {
58
+ type: "number",
59
+ default: 8,
60
+ describe: "Maximum number of documents to lint in parallel"
61
+ }).option("resolve", {
62
+ type: "boolean",
63
+ default: true,
64
+ describe: "Dereference $ref / $dynamicRef / $recursiveRef before linting (use --no-resolve to disable)"
65
+ }).option("resolve-remote", {
66
+ type: "boolean",
67
+ default: false,
68
+ describe: "Allow fetching http(s) $refs while resolving (off by default; a lint run stays offline)"
69
+ }).option("allowed-hosts", {
70
+ type: "string",
71
+ array: true,
72
+ describe: "Restrict remote $ref fetches to these hosts (implies --resolve-remote)"
73
+ }).option("allow-private-hosts", {
74
+ type: "boolean",
75
+ default: false,
76
+ describe: "Permit remote $refs to private/loopback hosts (SSRF guard, off by default)"
77
+ }).option("allowed-roots", {
78
+ type: "string",
79
+ array: true,
80
+ describe: "Extra directories a local $ref may resolve into, beyond the document's own (repeat the flag; relative to the current directory)"
81
+ }).help().alias("help", "h").parse();
82
+ if (!Number.isFinite(parsed.concurrency) || parsed.concurrency < 1) {
83
+ return { error: "Invalid --concurrency value. Expected a positive number." };
84
+ }
85
+ return { args: parsed };
86
+ } catch (error) {
87
+ return { error: error instanceof Error ? error.message : String(error) };
88
+ }
89
+ };
50
90
  const run = async (argv, options = {}) => {
51
91
  const out = [];
52
92
  const err = [];
53
- const parsed = await yargs(argv).scriptName("lint").parserConfiguration({ "greedy-arrays": false }).usage("$0 [documents..]", "Lint JSON/YAML documents against a ruleset").positional("documents", { describe: "Documents or globs to lint", type: "string", array: true }).option("ruleset", { alias: "r", type: "string", describe: "Path to a ruleset file" }).option("encoding", { type: "string", default: "utf8", describe: "Input encoding" }).option("fail-severity", { alias: "F", type: "string", default: "error", choices: Object.keys(SEVERITY_BY_NAME) }).option("display-only-failures", { alias: "D", type: "boolean", default: false }).option("verbose", { type: "boolean", default: false }).option("quiet", { alias: "q", type: "boolean", default: false }).option("stdin-filepath", {
54
- type: "string",
55
- describe: "Path to associate with stdin input (labels findings and enables ruleset discovery)"
56
- }).option("concurrency", {
57
- type: "number",
58
- default: 8,
59
- describe: "Maximum number of documents to lint in parallel"
60
- }).option("resolve", {
61
- type: "boolean",
62
- default: true,
63
- describe: "Dereference $ref / $dynamicRef / $recursiveRef before linting (use --no-resolve to disable)"
64
- }).option("resolve-remote", {
65
- type: "boolean",
66
- default: false,
67
- describe: "Allow fetching http(s) $refs while resolving (off by default; a lint run stays offline)"
68
- }).option("allowed-hosts", {
69
- type: "string",
70
- array: true,
71
- describe: "Restrict remote $ref fetches to these hosts (implies --resolve-remote)"
72
- }).option("allow-private-hosts", {
73
- type: "boolean",
74
- default: false,
75
- describe: "Permit remote $refs to private/loopback hosts (SSRF guard, off by default)"
76
- }).help().alias("help", "h").parse();
93
+ const outcome = await parseArgs(argv);
94
+ if ("error" in outcome) {
95
+ return { code: 2, stdout: "", stderr: `${outcome.error}
96
+ Run \`mjst lint --help\` for the supported flags.
97
+ ` };
98
+ }
99
+ const parsed = outcome.args;
100
+ if (parsed.help)
101
+ return { code: 0, stdout: "", stderr: "" };
77
102
  const failSeverity = SEVERITY_BY_NAME[parsed.failSeverity] ?? DiagnosticSeverity.Error;
78
103
  const allowRemote = parsed.resolveRemote || (parsed.allowedHosts?.length ?? 0) > 0;
79
104
  const resolver = parsed.resolve ? createLintResolver({
80
105
  remote: allowRemote,
81
106
  ...parsed.allowedHosts ? { allowedHosts: parsed.allowedHosts } : {},
82
- allowPrivateHosts: parsed.allowPrivateHosts
107
+ allowPrivateHosts: parsed.allowPrivateHosts,
108
+ ...parsed.allowedRoots ? { allowedRoots: parsed.allowedRoots } : {}
83
109
  }) : void 0;
84
110
  const reportRulesetProblems = (definition, label) => {
85
111
  if (parsed.quiet)
@@ -105,8 +131,13 @@ const run = async (argv, options = {}) => {
105
131
  rulesetBasePath = dirname(isAbsolute(parsed.ruleset) ? parsed.ruleset : resolve(process.cwd(), parsed.ruleset));
106
132
  reportRulesetProblems(rulesetDefinition, parsed.ruleset);
107
133
  }
108
- const targets = await fg(parsed.documents ?? [], { dot: true, onlyFiles: true });
134
+ const documents = parsed.documents ?? [];
135
+ const targets = await fg(documents, { dot: true, onlyFiles: true });
109
136
  const allResults = [];
137
+ if (targets.length === 0 && documents.length > 0) {
138
+ return { code: 2, stdout: "", stderr: `No files matched: ${documents.join(", ")}
139
+ ` };
140
+ }
110
141
  if (targets.length === 0) {
111
142
  if (options.stdin === void 0 && process.stdin.isTTY) {
112
143
  return { code: 2, stdout: "", stderr: "No documents provided.\n" };
@@ -2,5 +2,9 @@ import type { CliConfig } from './cli-config.js';
2
2
  /**
3
3
  * Loads a JSON config file and returns the relevant CLI config properties.
4
4
  * The config file should have the same keys as the CLI flags (schema, outDir).
5
+ *
6
+ * Every key is validated up front (see {@link validateConfig}), so the `typeof`
7
+ * guards below are only here to narrow the types for TypeScript — by the time we
8
+ * reach them, nothing can fail them.
5
9
  */
6
10
  export declare const loadConfig: (configPath: string) => Promise<Partial<CliConfig>>;
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
+ import { validateConfig } from "./validate-config.js";
3
4
  const SOURCE_FORMATS = ["json", "typebox", "zod", "valibot", "effect"];
4
5
  const isSourceFormat = (value) => typeof value === "string" && SOURCE_FORMATS.includes(value);
5
6
  const isHelpersMode = (value) => value === "package" || value === "embedded";
@@ -7,12 +8,17 @@ const isImportExt = (value) => value === "js" || value === "ts";
7
8
  const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
8
9
  const loadConfig = async (configPath) => {
9
10
  const absolutePath = resolve(configPath);
10
- const raw = await readFile(absolutePath, "utf-8");
11
+ const raw = await readFile(absolutePath, "utf-8").catch((error) => {
12
+ if (error.code === "ENOENT")
13
+ throw new Error(`Config file not found: ${absolutePath}`);
14
+ throw error;
15
+ });
11
16
  const parsed = JSON.parse(raw);
12
17
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
13
18
  throw new Error(`Config file must be a JSON object: ${configPath}`);
14
19
  }
15
20
  const obj = parsed;
21
+ validateConfig(obj, configPath);
16
22
  return {
17
23
  ...typeof obj["schema"] === "string" && { schema: obj["schema"] },
18
24
  ...typeof obj["schemaDir"] === "string" && { schemaDir: obj["schemaDir"] },
@@ -24,6 +30,7 @@ const loadConfig = async (configPath) => {
24
30
  ...typeof obj["typesOnly"] === "boolean" && { typesOnly: obj["typesOnly"] },
25
31
  ...typeof obj["examples"] === "boolean" && { examples: obj["examples"] },
26
32
  ...typeof obj["build"] === "boolean" && { build: obj["build"] },
33
+ ...typeof obj["force"] === "boolean" && { force: obj["force"] },
27
34
  ...typeof obj["logWarnings"] === "boolean" && { logWarnings: obj["logWarnings"] },
28
35
  ...typeof obj["strict"] === "boolean" && { strict: obj["strict"] },
29
36
  ...typeof obj["stripUnknown"] === "boolean" && { stripUnknown: obj["stripUnknown"] },
@@ -37,7 +44,11 @@ const loadConfig = async (configPath) => {
37
44
  ...(typeof obj["banner"] === "boolean" || typeof obj["banner"] === "string") && { banner: obj["banner"] },
38
45
  ...typeof obj["resolveRemote"] === "boolean" && { resolveRemote: obj["resolveRemote"] },
39
46
  ...isStringArray(obj["allowedHosts"]) && { allowedHosts: obj["allowedHosts"] },
40
- ...typeof obj["allowPrivateHosts"] === "boolean" && { allowPrivateHosts: obj["allowPrivateHosts"] }
47
+ ...typeof obj["allowPrivateHosts"] === "boolean" && { allowPrivateHosts: obj["allowPrivateHosts"] },
48
+ // Kept as written: like `schema` and `outDir`, a relative entry is resolved
49
+ // against the process working directory rather than the config file's
50
+ // directory, so one rule covers both flags and config keys.
51
+ ...isStringArray(obj["allowedRoots"]) && { allowedRoots: obj["allowedRoots"] }
41
52
  };
42
53
  };
43
54
  export {
@@ -1,18 +1,22 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
2
3
  import { getAdapter } from "@amritk/adapters/get-adapter";
3
4
  import { resolveRefsFromFile } from "@amritk/resolve-refs";
5
+ import { withAllowedRootsHint } from "./allowed-roots-hint.js";
4
6
  import { hasExternalRefs } from "./has-external-refs.js";
5
7
  import { loadSchemaModule } from "./load-schema-module.js";
6
- const buildResolveOptions = (config) => {
8
+ const buildResolveOptions = (config, schemaPath) => {
7
9
  const remote = (config.resolveRemote ?? false) || (config.allowedHosts?.length ?? 0) > 0;
10
+ const extraRoots = (config.allowedRoots ?? []).map((root) => resolve(root));
8
11
  return {
9
12
  remote,
13
+ ...extraRoots.length > 0 ? { allowedRoots: [dirname(resolve(schemaPath)), ...extraRoots] } : {},
10
14
  ...config.allowedHosts ? { allowedHosts: [...config.allowedHosts] } : {},
11
15
  ...config.allowPrivateHosts ? { allowPrivateHosts: config.allowPrivateHosts } : {}
12
16
  };
13
17
  };
14
18
  const formatResolveErrors = (schemaPath, errors) => {
15
- const details = errors.map((error) => ` - ${error.message}`).join("\n");
19
+ const details = errors.map((error) => ` - ${withAllowedRootsHint(error.message)}`).join("\n");
16
20
  return `Failed to resolve $refs in ${schemaPath}:
17
21
  ${details}`;
18
22
  };
@@ -22,7 +26,7 @@ const loadSchema = async (config, schemaPath) => {
22
26
  const data = JSON.parse(await readFile(schemaPath, "utf-8"));
23
27
  if (!hasExternalRefs(data))
24
28
  return data;
25
- const { resolved, errors } = await resolveRefsFromFile(schemaPath, buildResolveOptions(config));
29
+ const { resolved, errors } = await resolveRefsFromFile(schemaPath, buildResolveOptions(config, schemaPath));
26
30
  if (errors.length > 0)
27
31
  throw new Error(formatResolveErrors(schemaPath, errors));
28
32
  return resolved;
@@ -1,4 +1,13 @@
1
1
  import type { CliConfig } from './cli-config.js';
2
+ /**
3
+ * True when `--<flagName>` consumes the argument that follows it (`--banner`
4
+ * takes an optional one, which still swallows the next non-flag token).
5
+ *
6
+ * Exported because whoever inspects raw argv before parsing — the version/help
7
+ * detection in `cli.ts` — has to know that the `-v` in `--type-suffix -v` is a
8
+ * *value*, not a request to print the version.
9
+ */
10
+ export declare const flagTakesValue: (flagName: string) => boolean;
2
11
  /**
3
12
  * Parses command-line arguments into a partial CLI config.
4
13
  * Every flag accepts both kebab-case and camelCase (e.g. `--out-dir` and `--outDir`)
@@ -1,9 +1,11 @@
1
1
  const SOURCE_FORMATS = ["json", "typebox", "zod", "valibot", "effect"];
2
+ const LIST_KEYS = /* @__PURE__ */ new Set(["allowedHosts", "allowedRoots"]);
2
3
  const BOOLEAN_KEYS = /* @__PURE__ */ new Set([
3
4
  "typesOnly",
4
5
  "validators",
5
6
  "examples",
6
7
  "build",
8
+ "force",
7
9
  "logWarnings",
8
10
  "strict",
9
11
  "stripUnknown",
@@ -26,6 +28,10 @@ const VALUE_KEYS = /* @__PURE__ */ new Set([
26
28
  ]);
27
29
  const EXTERNAL_VALUE_KEYS = /* @__PURE__ */ new Set(["config"]);
28
30
  const toCamelCase = (key) => key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
31
+ const flagTakesValue = (flagName) => {
32
+ const key = toCamelCase(flagName);
33
+ return VALUE_KEYS.has(key) || LIST_KEYS.has(key) || EXTERNAL_VALUE_KEYS.has(key) || key === "banner";
34
+ };
29
35
  const parseHelpersValue = (value) => {
30
36
  if (value === "package" || value === "embedded")
31
37
  return value;
@@ -37,11 +43,11 @@ const parseImportExtValue = (value) => {
37
43
  return void 0;
38
44
  };
39
45
  const parseInputValue = (value) => SOURCE_FORMATS.includes(value) ? value : void 0;
40
- const appendAllowedHosts = (config, value) => {
41
- const hosts = value.split(",").map((host) => host.trim()).filter((host) => host.length > 0);
42
- if (hosts.length === 0)
46
+ const appendListValue = (config, key, value) => {
47
+ const entries = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
48
+ if (entries.length === 0)
43
49
  return;
44
- config.allowedHosts = [...config.allowedHosts ?? [], ...hosts];
50
+ config[key] = [...config[key] ?? [], ...entries];
45
51
  };
46
52
  const assignValue = (config, key, value) => {
47
53
  switch (key) {
@@ -108,6 +114,9 @@ const assignBoolean = (config, key, value) => {
108
114
  case "build":
109
115
  config.build = value;
110
116
  return true;
117
+ case "force":
118
+ config.force = value;
119
+ return true;
111
120
  case "logWarnings":
112
121
  config.logWarnings = value;
113
122
  return true;
@@ -137,8 +146,12 @@ const parseCliArgs = (args) => {
137
146
  const config = {};
138
147
  for (let i = 0; i < args.length; i++) {
139
148
  const arg = args[i];
140
- if (!arg || !arg.startsWith("--")) {
149
+ if (!arg)
141
150
  continue;
151
+ if (arg === "--")
152
+ break;
153
+ if (!arg.startsWith("--")) {
154
+ throw new Error(`Unexpected argument "${arg}". mjst takes flags only \u2014 did you mean a subcommand (\`mjst lint\`, \`mjst compile-api\`)?`);
142
155
  }
143
156
  const equalsIndex = arg.indexOf("=");
144
157
  if (equalsIndex !== -1) {
@@ -146,13 +159,16 @@ const parseCliArgs = (args) => {
146
159
  const value = arg.slice(equalsIndex + 1);
147
160
  if (key2 === "banner") {
148
161
  config.banner = value === "false" ? false : value === "true" ? true : value;
149
- } else if (key2 === "allowedHosts") {
150
- appendAllowedHosts(config, value);
162
+ } else if (LIST_KEYS.has(key2)) {
163
+ appendListValue(config, key2, value);
151
164
  } else if (BOOLEAN_KEYS.has(key2)) {
152
165
  assignBoolean(config, key2, value !== "false");
153
166
  } else if (VALUE_KEYS.has(key2)) {
154
167
  assignValue(config, key2, value);
155
- } else if (!EXTERNAL_VALUE_KEYS.has(key2)) {
168
+ } else if (EXTERNAL_VALUE_KEYS.has(key2)) {
169
+ if (value === "")
170
+ throw new Error(`Flag "--${arg.slice(2, equalsIndex)}" expects a value.`);
171
+ } else {
156
172
  throw new Error(`Unknown flag "--${arg.slice(2, equalsIndex)}".`);
157
173
  }
158
174
  continue;
@@ -169,12 +185,12 @@ const parseCliArgs = (args) => {
169
185
  }
170
186
  continue;
171
187
  }
172
- if (key === "allowedHosts") {
188
+ if (LIST_KEYS.has(key)) {
173
189
  const value = args[i + 1];
174
190
  if (value === void 0 || value.startsWith("--")) {
175
191
  throw new Error(`Flag "--${flagName}" expects a value.`);
176
192
  }
177
- appendAllowedHosts(config, value);
193
+ appendListValue(config, key, value);
178
194
  i++;
179
195
  continue;
180
196
  }
@@ -193,8 +209,10 @@ const parseCliArgs = (args) => {
193
209
  }
194
210
  if (EXTERNAL_VALUE_KEYS.has(key)) {
195
211
  const value = args[i + 1];
196
- if (value !== void 0 && !value.startsWith("--"))
197
- i++;
212
+ if (value === void 0 || value.startsWith("--")) {
213
+ throw new Error(`Flag "--${flagName}" expects a value.`);
214
+ }
215
+ i++;
198
216
  continue;
199
217
  }
200
218
  throw new Error(`Unknown flag "--${flagName}".`);
@@ -202,5 +220,6 @@ const parseCliArgs = (args) => {
202
220
  return config;
203
221
  };
204
222
  export {
223
+ flagTakesValue,
205
224
  parseCliArgs
206
225
  };
@@ -0,0 +1,15 @@
1
+ /** A request that short-circuits generation and just prints something. */
2
+ export type MetaRequest = 'version' | 'help';
3
+ /**
4
+ * Detects `mjst version` / `mjst help` and the `--version` / `-v` / `--help` /
5
+ * `-h` flags in raw argv.
6
+ *
7
+ * The subtlety is *where* the token sits. Scanning the whole argv with
8
+ * `includes('-v')` meant that any flag whose **value** happened to be `-v` — say
9
+ * `--type-suffix -v` — printed the version and exited 0 without generating
10
+ * anything, which CI reads as a successful build. So a token in the value
11
+ * position of a value-taking flag is skipped: it belongs to that flag.
12
+ *
13
+ * @returns What the args ask for, or `undefined` for a normal generation run.
14
+ */
15
+ export declare const parseMetaRequest: (args: readonly string[]) => MetaRequest | undefined;
@@ -0,0 +1,22 @@
1
+ import { flagTakesValue } from "./parse-cli-args.js";
2
+ const parseMetaRequest = (args) => {
3
+ if (args[0] === "version")
4
+ return "version";
5
+ if (args[0] === "help")
6
+ return "help";
7
+ for (let i = 0; i < args.length; i++) {
8
+ const previous = i > 0 ? args[i - 1] : void 0;
9
+ const consumedAsValue = previous?.startsWith("--") && !previous.includes("=") && flagTakesValue(previous.slice(2));
10
+ if (consumedAsValue)
11
+ continue;
12
+ const arg = args[i];
13
+ if (arg === "--version" || arg === "-v")
14
+ return "version";
15
+ if (arg === "--help" || arg === "-h")
16
+ return "help";
17
+ }
18
+ return void 0;
19
+ };
20
+ export {
21
+ parseMetaRequest
22
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The JSON type each config key accepts, mirroring `config.schema.json`.
3
+ * `boolean|string` is `banner`'s `"type": ["boolean", "string"]`.
4
+ */
5
+ type ConfigValueType = 'string' | 'boolean' | 'boolean|string' | 'string[]';
6
+ /** One key's contract: its JSON type, plus the closed value set when it has one. */
7
+ type ConfigKeySpec = {
8
+ readonly type: ConfigValueType;
9
+ readonly enum?: readonly string[];
10
+ };
11
+ /**
12
+ * Every key a config file may carry, kept in lockstep with `config.schema.json`
13
+ * by `validate-config.test.ts`.
14
+ *
15
+ * The schema itself would be the obvious thing to validate against at runtime,
16
+ * but it is deliberately not shipped in the published package (see `help-text.ts`
17
+ * for the same trade-off), and `@amritk/runtime-validators` — the interpreter that
18
+ * would run it — is not a declared dependency of this package. So the contract
19
+ * lives here as data, and the test pins it to the schema so the two cannot drift.
20
+ * (Exported for exactly that test — nothing else should read it.)
21
+ */
22
+ export declare const CONFIG_KEYS: Record<string, ConfigKeySpec>;
23
+ /**
24
+ * Checks a parsed config file against the key contract, throwing with every
25
+ * problem found (JSON Pointer + what was expected).
26
+ *
27
+ * Before this, each key was guarded by a `typeof` check whose failure branch was
28
+ * "drop it": `{"strcit": true, "strict": "true"}` generated non-strict output and
29
+ * exited 0, while the very same typo on the command line was rejected. A config
30
+ * file has to be at least as strict as the flags it stands in for.
31
+ *
32
+ * Note what this does *not* buy: a config is still trusted code. `schema` plus a
33
+ * non-`json` `input` makes `load-schema-module.ts` `import()` whatever path the
34
+ * config names, so an untrusted `mjst.config.json` still reaches arbitrary module
35
+ * evaluation. Validating the shape does not change that — do not run mjst against
36
+ * a config file you would not run as a script.
37
+ */
38
+ export declare const validateConfig: (parsed: Record<string, unknown>, configPath: string) => void;
39
+ export {};
@@ -0,0 +1,75 @@
1
+ const CONFIG_KEYS = {
2
+ schema: { type: "string" },
3
+ schemaDir: { type: "string" },
4
+ input: { type: "string", enum: ["json", "typebox", "zod", "valibot", "effect"] },
5
+ export: { type: "string" },
6
+ outDir: { type: "string" },
7
+ outFile: { type: "string" },
8
+ typesOnly: { type: "boolean" },
9
+ validators: { type: "boolean" },
10
+ examples: { type: "boolean" },
11
+ build: { type: "boolean" },
12
+ force: { type: "boolean" },
13
+ logWarnings: { type: "boolean" },
14
+ strict: { type: "boolean" },
15
+ stripUnknown: { type: "boolean" },
16
+ caseInsensitive: { type: "boolean" },
17
+ readonly: { type: "boolean" },
18
+ helpers: { type: "string", enum: ["package", "embedded"] },
19
+ typeSuffix: { type: "string" },
20
+ banner: { type: "boolean|string" },
21
+ importExt: { type: "string", enum: ["js", "ts"] },
22
+ rootType: { type: "string" },
23
+ resolveRemote: { type: "boolean" },
24
+ allowedHosts: { type: "string[]" },
25
+ allowPrivateHosts: { type: "boolean" },
26
+ allowedRoots: { type: "string[]" },
27
+ config: { type: "string" }
28
+ };
29
+ const typeNameOf = (value) => {
30
+ if (value === null)
31
+ return "null";
32
+ if (Array.isArray(value))
33
+ return "array";
34
+ return typeof value;
35
+ };
36
+ const matchesType = (value, type) => {
37
+ switch (type) {
38
+ case "string":
39
+ return typeof value === "string";
40
+ case "boolean":
41
+ return typeof value === "boolean";
42
+ case "boolean|string":
43
+ return typeof value === "boolean" || typeof value === "string";
44
+ case "string[]":
45
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
46
+ }
47
+ };
48
+ const validateConfig = (parsed, configPath) => {
49
+ const problems = [];
50
+ const knownKeys = Object.keys(CONFIG_KEYS);
51
+ for (const [key, value] of Object.entries(parsed)) {
52
+ const spec = CONFIG_KEYS[key];
53
+ if (!spec) {
54
+ if (key === "$schema")
55
+ continue;
56
+ problems.push(` /${key}: unknown option. Known options: ${knownKeys.join(", ")}.`);
57
+ continue;
58
+ }
59
+ if (!matchesType(value, spec.type)) {
60
+ problems.push(` /${key}: expected ${spec.type}, received ${typeNameOf(value)}.`);
61
+ continue;
62
+ }
63
+ if (spec.enum && typeof value === "string" && !spec.enum.includes(value)) {
64
+ problems.push(` /${key}: expected one of ${spec.enum.join(", ")}, received "${value}".`);
65
+ }
66
+ }
67
+ if (problems.length > 0) {
68
+ throw new Error(`Invalid config file ${configPath}:
69
+ ${problems.join("\n")}`);
70
+ }
71
+ };
72
+ export {
73
+ CONFIG_KEYS,
74
+ validateConfig
75
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/mjst",
3
- "version": "0.13.9",
3
+ "version": "0.14.8",
4
4
  "description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -39,27 +39,31 @@
39
39
  "scripts": {
40
40
  "dev": "bun run --conditions=development ./src/cli.ts",
41
41
  "start": "bun run ./src/cli.ts",
42
- "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs",
42
+ "build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs && node ../../scripts/chmod-bin.mjs",
43
+ "prepublishOnly": "node ../../scripts/check-publishable.mjs",
43
44
  "types:check": "tsgo -p . --noEmit",
44
45
  "test": "NODE_ENV=production vitest run --root ../.. cli",
45
46
  "generate-readme": "bun run ./scripts/generate-readme.ts"
46
47
  },
48
+ "exports": {
49
+ "./package.json": "./package.json"
50
+ },
47
51
  "dependencies": {
48
- "@amritk/adapters": "0.3.6",
49
- "@amritk/api": "0.12.0",
50
- "@amritk/generate-examples": "0.5.6",
51
- "@amritk/generate-parsers": "0.18.0",
52
- "@amritk/generate-validators": "0.11.12",
53
- "@amritk/generate-markdown": "0.4.5",
54
- "@amritk/helpers": "0.14.0",
55
- "@amritk/lint": "0.4.3",
56
- "@amritk/resolve-refs": "0.4.5",
57
- "@amritk/yaml": "0.4.0",
52
+ "@amritk/adapters": "^0.4.3",
53
+ "@amritk/api": "^0.15.1",
54
+ "@amritk/generate-examples": "^0.6.3",
55
+ "@amritk/generate-parsers": "^0.19.3",
56
+ "@amritk/generate-validators": "^0.13.0",
57
+ "@amritk/helpers": "^0.15.3",
58
+ "@amritk/lint": "^0.4.7",
59
+ "@amritk/resolve-refs": "^0.6.0",
60
+ "@amritk/yaml": "^0.7.1",
58
61
  "fast-glob": "^3.3.2",
62
+ "json-schema-typed": "^8.0.1",
59
63
  "yargs": "^17.7.2"
60
64
  },
61
65
  "devDependencies": {
62
- "json-schema-typed": "^8.0.1",
66
+ "@amritk/generate-markdown": "^0.4.8",
63
67
  "@types/yargs": "^17.0.33"
64
68
  }
65
69
  }