@amritk/mjst 0.13.9 → 0.14.7
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/README.md +34 -5
- package/dist/allowed-roots-hint.d.ts +8 -0
- package/dist/allowed-roots-hint.js +5 -0
- package/dist/cli-config.d.ts +25 -0
- package/dist/cli.js +99 -74
- package/dist/create-output-writer.d.ts +40 -0
- package/dist/create-output-writer.js +57 -0
- package/dist/emit-examples.d.ts +16 -5
- package/dist/emit-examples.js +7 -10
- package/dist/ensure-output-dir.d.ts +10 -0
- package/dist/ensure-output-dir.js +15 -0
- package/dist/help-text.d.ts +1 -1
- package/dist/help-text.js +3 -0
- package/dist/lint/resolver.d.ts +7 -0
- package/dist/lint/resolver.js +8 -3
- package/dist/lint/run.js +57 -26
- package/dist/load-config.d.ts +4 -0
- package/dist/load-config.js +13 -2
- package/dist/load-schema.js +7 -3
- package/dist/parse-cli-args.d.ts +9 -0
- package/dist/parse-cli-args.js +31 -12
- package/dist/parse-meta-request.d.ts +15 -0
- package/dist/parse-meta-request.js +22 -0
- package/dist/validate-config.d.ts +39 -0
- package/dist/validate-config.js +75 -0
- package/package.json +17 -13
package/README.md
CHANGED
|
@@ -144,11 +144,16 @@ npx mjst --schema ./order.json --out-dir ./generated
|
|
|
144
144
|
|
|
145
145
|
Same-document refs (`#/$defs/...`) are left untouched — the generator resolves those
|
|
146
146
|
itself into named type files, so nothing changes for schemas that only reference
|
|
147
|
-
themselves. Local cross-file refs resolve from disk with no extra flags
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
themselves. Local cross-file refs resolve from disk with no extra flags, as long as
|
|
148
|
+
they stay **inside the schema's own directory** — a `$ref` cannot walk out of the tree
|
|
149
|
+
it belongs to (`../../../secrets.env`, or an absolute path) unless you name the extra
|
|
150
|
+
directory with `--allowed-roots`. Remote (`http(s)`) refs stay **offline by default**:
|
|
151
|
+
a schema with a remote `$ref` fails rather than making a network call unless you opt in.
|
|
150
152
|
|
|
151
153
|
```bash
|
|
154
|
+
# Let a split spec reach a sibling folder: v1/api.json → ../common/user.json
|
|
155
|
+
npx mjst --schema ./specs/v1/api.json --out-dir ./generated --allowed-roots ./specs
|
|
156
|
+
|
|
152
157
|
# Allow remote $ref fetches
|
|
153
158
|
npx mjst --schema ./order.json --out-dir ./generated --resolve-remote
|
|
154
159
|
|
|
@@ -159,6 +164,11 @@ npx mjst --schema ./order.json --out-dir ./generated --allowed-hosts schemas.exa
|
|
|
159
164
|
npx mjst --schema ./order.json --out-dir ./generated --resolve-remote --allow-private-hosts
|
|
160
165
|
```
|
|
161
166
|
|
|
167
|
+
`--allowed-roots` takes a comma-separated list (or repeat the flag) and resolves each
|
|
168
|
+
entry against the current working directory. It **adds** to the schema's own directory
|
|
169
|
+
rather than replacing it, and everything outside the resulting set is still refused —
|
|
170
|
+
so widening the roots for a shared `common/` folder does not reopen the whole disk.
|
|
171
|
+
|
|
162
172
|
Unresolvable references (a missing file, a refused host, a bad URL) fail the run with
|
|
163
173
|
the underlying reason rather than emitting a half-resolved schema. This works with
|
|
164
174
|
`--schema-dir` too — each schema resolves its own external references.
|
|
@@ -211,9 +221,10 @@ With no `-r`, mjst discovers a `.lint.{yaml,yml,json,js,mjs}` ruleset by walking
|
|
|
211
221
|
| `--resolve-remote` | Fetch `http(s)` `$ref`s while resolving (off by default; a lint run stays offline). |
|
|
212
222
|
| `--allowed-hosts` | Restrict remote `$ref` fetches to these hosts (implies `--resolve-remote`). |
|
|
213
223
|
| `--allow-private-hosts` | Permit remote `$ref`s to private/loopback hosts (SSRF guard, off by default). |
|
|
224
|
+
| `--allowed-roots` | Extra directories a local `$ref` may resolve into, on top of the document's own (repeat the flag). |
|
|
214
225
|
| `-q`, `--quiet` | Suppress the findings report (the exit code still reflects findings). |
|
|
215
226
|
|
|
216
|
-
By default `mjst lint` dereferences `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor` before running rules, so rules with `resolved: true` (the ruleset default) see through references. Internal and local cross-file refs resolve from disk — a finding on a cross-file node reports that file's own `line:column` —
|
|
227
|
+
By default `mjst lint` dereferences `$ref`, `$dynamicRef`/`$dynamicAnchor`, and `$recursiveRef`/`$recursiveAnchor` before running rules, so rules with `resolved: true` (the ruleset default) see through references. Internal and local cross-file refs resolve from disk — a finding on a cross-file node reports that file's own `line:column` — as long as they stay inside the linted document's own directory; use `--allowed-roots` to let a document reach a shared sibling folder. Remote refs are only fetched when you opt in with `--resolve-remote` or `--allowed-hosts`.
|
|
217
228
|
|
|
218
229
|
The exit code is `0` when no finding reaches `--fail-severity`, `1` when one does, and `2` on a usage error. Run `mjst lint --help` for the full list.
|
|
219
230
|
|
|
@@ -350,6 +361,15 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
350
361
|
<td colspan="4">Compile the generated TypeScript files to .js and .d.ts output. A temporary tsconfig is written to the output directory, tsc is invoked, and the intermediate .ts source files are removed when compilation succeeds.</td>
|
|
351
362
|
</tr>
|
|
352
363
|
<tr>
|
|
364
|
+
<td>💥 <code>force</code></td>
|
|
365
|
+
<td><code>--force</code></td>
|
|
366
|
+
<td><code>boolean</code></td>
|
|
367
|
+
<td align="center"><code>false</code></td>
|
|
368
|
+
</tr>
|
|
369
|
+
<tr>
|
|
370
|
+
<td colspan="4">Allow generated files to overwrite files that already exist in the output destination. By default a collision with a file mjst did not generate (a hand-written index.ts, say) aborts the run before anything is written, because the generator would otherwise replace it — and, under build, delete it along with the other intermediate sources. Regenerating never needs this flag: each run records what it wrote in a .mjst-manifest.json at the root of the output directory, and anything listed there is replaced freely.</td>
|
|
371
|
+
</tr>
|
|
372
|
+
<tr>
|
|
353
373
|
<td>⚠️ <code>logWarnings</code></td>
|
|
354
374
|
<td><code>--log-warnings</code></td>
|
|
355
375
|
<td><code>boolean</code></td>
|
|
@@ -467,13 +487,22 @@ The exit code is `0` on success, `1` when compilation fails (unloadable module,
|
|
|
467
487
|
<td colspan="4">Permit remote $refs to private, loopback, and link-local hosts. Off by default: such targets are refused as a best-effort SSRF guard (notably the 169.254.169.254 cloud-metadata endpoint). An explicit allowedHosts entry always bypasses this guard.</td>
|
|
468
488
|
</tr>
|
|
469
489
|
<tr>
|
|
490
|
+
<td>📂 <code>allowedRoots</code></td>
|
|
491
|
+
<td><code>--allowed-roots <dirs></code></td>
|
|
492
|
+
<td><code>array</code></td>
|
|
493
|
+
<td align="center"></td>
|
|
494
|
+
</tr>
|
|
495
|
+
<tr>
|
|
496
|
+
<td colspan="4">Extra directories a local (cross-file) $ref may resolve into. By default a local $ref is confined to the schema's own directory, so '{"$ref": "../../../secrets.env"}' (or an absolute path) reads nothing. List a root here for the ordinary split-spec layout where v1/api.json refers to a shared common/user.json one level up. The schema's own directory is always allowed, so these roots widen the default rather than replacing it, and a $ref that still lands outside every root is refused. Accepts a comma-separated list or the flag repeated; relative entries resolve against the current working directory, from a config file as well as from the flag.<br><strong>Examples:</strong> <code>["./specs"]</code></td>
|
|
497
|
+
</tr>
|
|
498
|
+
<tr>
|
|
470
499
|
<td>⚙️ <code>config</code></td>
|
|
471
500
|
<td><code>--config <path></code></td>
|
|
472
501
|
<td><code>string</code></td>
|
|
473
502
|
<td align="center"></td>
|
|
474
503
|
</tr>
|
|
475
504
|
<tr>
|
|
476
|
-
<td colspan="4">Path to a JSON config file. Keys match the option names in this schema (schema, schemaDir, input, export, outDir, outFile, typesOnly, validators, examples, build, logWarnings, strict, stripUnknown, caseInsensitive, readonly, helpers, typeSuffix, banner, importExt, rootType, resolveRemote, allowedHosts, allowPrivateHosts). CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
|
|
505
|
+
<td colspan="4">Path to a JSON config file. Keys match the option names in this schema (schema, schemaDir, input, export, outDir, outFile, typesOnly, validators, examples, build, force, logWarnings, strict, stripUnknown, caseInsensitive, readonly, helpers, typeSuffix, banner, importExt, rootType, resolveRemote, allowedHosts, allowPrivateHosts, allowedRoots) and are validated: an unknown key or a wrong value type fails the run instead of being ignored. CLI flags take precedence over config file values.<br><strong>Examples:</strong> <code>"./mjst.config.json"</code></td>
|
|
477
506
|
</tr>
|
|
478
507
|
</tbody>
|
|
479
508
|
</table>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appends {@link ALLOWED_ROOTS_HINT} to a resolver message that blames
|
|
3
|
+
* `allowedRoots`, and leaves every other message untouched. Matching on the
|
|
4
|
+
* option name rather than the full sentence keeps this working across both
|
|
5
|
+
* refusal wordings the resolver produces ("resolves outside the allowed roots"
|
|
6
|
+
* and "no local directory is allowed for this document").
|
|
7
|
+
*/
|
|
8
|
+
export declare const withAllowedRootsHint: (message: string) => string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
const ALLOWED_ROOTS_HINT = "\u2014 pass --allowed-roots <dir> to permit it (relative paths resolve against the current directory).";
|
|
2
|
+
const withAllowedRootsHint = (message) => message.includes("allowedRoots") ? `${message} ${ALLOWED_ROOTS_HINT}` : message;
|
|
3
|
+
export {
|
|
4
|
+
withAllowedRootsHint
|
|
5
|
+
};
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -60,6 +60,18 @@ export type CliConfig = {
|
|
|
60
60
|
* The .ts source files are removed after compilation.
|
|
61
61
|
*/
|
|
62
62
|
readonly build?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* When true, generated files may overwrite files that already exist in the
|
|
65
|
+
* output destination. By default a collision with a file mjst did not generate
|
|
66
|
+
* (a hand-written `index.ts`, say) aborts the run rather than silently
|
|
67
|
+
* replacing it — and with `build`, deleting it afterwards. Regeneration does
|
|
68
|
+
* not need this: each run records what it wrote in a `.mjst-manifest.json` at
|
|
69
|
+
* the root of the output directory, and reclaims those paths freely. With
|
|
70
|
+
* `outFile` the "output directory" is the directory holding that file, so the
|
|
71
|
+
* manifest lands beside it — generated examples and the `build` output already
|
|
72
|
+
* do.
|
|
73
|
+
*/
|
|
74
|
+
readonly force?: boolean;
|
|
63
75
|
/**
|
|
64
76
|
* When true, the generated parsers emit a console.warn for every input key
|
|
65
77
|
* that is not declared in the schema's properties.
|
|
@@ -160,4 +172,17 @@ export type CliConfig = {
|
|
|
160
172
|
* always bypasses this guard.
|
|
161
173
|
*/
|
|
162
174
|
readonly allowPrivateHosts?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Extra directories a local (cross-file) `$ref` may resolve into. A local
|
|
177
|
+
* `$ref` is confined to the schema's own directory by default, so a
|
|
178
|
+
* `{"$ref": "../../../secrets.env"}` reads nothing; this is the escape hatch for
|
|
179
|
+
* the ordinary split-spec layout where `v1/api.json` refers to a shared
|
|
180
|
+
* `common/user.json` one level up.
|
|
181
|
+
*
|
|
182
|
+
* The schema's own directory is always allowed, so the listed roots widen the
|
|
183
|
+
* default rather than replacing it. Entries are resolved against the process
|
|
184
|
+
* working directory — including entries that came from a config file, which
|
|
185
|
+
* matches how `schema` and `outDir` are treated.
|
|
186
|
+
*/
|
|
187
|
+
readonly allowedRoots?: readonly string[];
|
|
163
188
|
};
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { readdir, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { buildSchema } from "@amritk/generate-parsers";
|
|
@@ -8,16 +8,18 @@ import { buildValidatorSchema } from "@amritk/generate-validators";
|
|
|
8
8
|
import { deriveRootTypeName } from "@amritk/helpers/derive-root-type-name";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
import { combineGeneratedFiles } from "./combine-files.js";
|
|
11
|
+
import { createOutputWriter } from "./create-output-writer.js";
|
|
11
12
|
import { detectHelpersMode } from "./detect-helpers-mode.js";
|
|
12
13
|
import { emitExamples } from "./emit-examples.js";
|
|
14
|
+
import { ensureOutputDir } from "./ensure-output-dir.js";
|
|
13
15
|
import { HELP_TEXT } from "./help-text.js";
|
|
14
16
|
import { loadConfig } from "./load-config.js";
|
|
15
17
|
import { loadSchema } from "./load-schema.js";
|
|
16
18
|
import { parseCliArgs } from "./parse-cli-args.js";
|
|
19
|
+
import { parseMetaRequest } from "./parse-meta-request.js";
|
|
17
20
|
import { readVersion } from "./read-version.js";
|
|
18
21
|
import { resolveImportExt } from "./resolve-import-ext.js";
|
|
19
|
-
const
|
|
20
|
-
const isHelpRequest = (args) => args[0] === "help" || args.includes("--help") || args.includes("-h");
|
|
22
|
+
const ROOT_TYPE_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
21
23
|
const schemaBaseName = (schemaPath) => basename(schemaPath).replace(/\.[^.]+$/, "");
|
|
22
24
|
const resolveHelpersMode = (config, outputDir) => {
|
|
23
25
|
const helpersMode = config.helpers ?? detectHelpersMode(outputDir);
|
|
@@ -50,21 +52,30 @@ const resolveBanner = (banner) => {
|
|
|
50
52
|
|
|
51
53
|
`;
|
|
52
54
|
};
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const commitOrDiscard = async (writer, stageAll) => {
|
|
56
|
+
try {
|
|
57
|
+
await stageAll();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
await writer.discard();
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return writer.commit();
|
|
57
63
|
};
|
|
58
|
-
const runExamples = async (config,
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
const runExamples = async (config, outputDir, tasks) => {
|
|
65
|
+
const writer = await createOutputWriter(outputDir, config.force);
|
|
66
|
+
const written = await commitOrDiscard(writer, async () => {
|
|
67
|
+
for (const task of tasks) {
|
|
68
|
+
await emitExamples({
|
|
69
|
+
schema: task.schema,
|
|
70
|
+
rootTypeName: task.rootTypeName,
|
|
71
|
+
writer,
|
|
72
|
+
subDir: task.subDir ?? "",
|
|
73
|
+
bannerPrefix: config.banner ? resolveBanner(config.banner) : "",
|
|
74
|
+
// Spread conditionally: `exactOptionalPropertyTypes` rejects an explicit
|
|
75
|
+
// `undefined` for the optional `typeSuffix`.
|
|
76
|
+
...config.typeSuffix !== void 0 ? { typeSuffix: config.typeSuffix } : {}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
68
79
|
});
|
|
69
80
|
for (const path of written) {
|
|
70
81
|
console.log(`Generated example: ${path}`);
|
|
@@ -100,11 +111,12 @@ ${output}` : ""}`);
|
|
|
100
111
|
}
|
|
101
112
|
for (const file of tsFiles) {
|
|
102
113
|
await unlink(join(outputDir, file));
|
|
103
|
-
|
|
114
|
+
if (!typesOnly)
|
|
115
|
+
console.log(`Built: ${file.replace(/\.ts$/, ".js")}`);
|
|
104
116
|
console.log(`Built: ${file.replace(/\.ts$/, ".d.ts")}`);
|
|
105
117
|
}
|
|
106
118
|
console.log(`
|
|
107
|
-
Total files built: ${tsFiles.length * 2}`);
|
|
119
|
+
Total files built: ${tsFiles.length * (typesOnly ? 1 : 2)}`);
|
|
108
120
|
};
|
|
109
121
|
const findJsonSchemas = async (dir) => {
|
|
110
122
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -121,60 +133,67 @@ const findJsonSchemas = async (dir) => {
|
|
|
121
133
|
}
|
|
122
134
|
return results;
|
|
123
135
|
};
|
|
124
|
-
const runValidators = async (config, schema, rootTypeName,
|
|
136
|
+
const runValidators = async (config, schema, rootTypeName, writer, validatorsSubDir) => {
|
|
125
137
|
const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix);
|
|
126
|
-
const
|
|
138
|
+
const staged = [];
|
|
127
139
|
for (const file of files) {
|
|
128
140
|
const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
|
|
129
141
|
const relFilename = join(validatorsSubDir, file.filename);
|
|
130
|
-
await
|
|
131
|
-
|
|
132
|
-
console.log(`Generated: ${relFilename}`);
|
|
142
|
+
await writer.stage(relFilename, content);
|
|
143
|
+
staged.push(relFilename);
|
|
133
144
|
}
|
|
134
|
-
return
|
|
145
|
+
return staged;
|
|
135
146
|
};
|
|
136
147
|
const runSingle = async (config, schemaPath, outputDir) => {
|
|
137
148
|
const schema = await loadSchema(config, schemaPath);
|
|
138
|
-
await
|
|
149
|
+
await ensureOutputDir(outputDir);
|
|
139
150
|
const helpersMode = resolveHelpersMode(config, outputDir);
|
|
140
151
|
const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
|
|
141
152
|
console.log(`Root type: ${rootTypeName}`);
|
|
142
153
|
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, helpersMode, void 0, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
const writer = await createOutputWriter(outputDir, config.force);
|
|
155
|
+
const written = await commitOrDiscard(writer, async () => {
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
const isHelper = file.filename.startsWith("_helpers/");
|
|
158
|
+
const content = config.banner && !isHelper ? resolveBanner(config.banner) + file.content : file.content;
|
|
159
|
+
await writer.stage(file.filename, content);
|
|
160
|
+
}
|
|
161
|
+
if (config.validators)
|
|
162
|
+
await runValidators(config, schema, rootTypeName, writer, "validators");
|
|
163
|
+
});
|
|
164
|
+
for (const filename of written)
|
|
165
|
+
console.log(`Generated: ${filename}`);
|
|
150
166
|
if (config.examples) {
|
|
151
|
-
await runExamples(config, schema, rootTypeName
|
|
167
|
+
await runExamples(config, outputDir, [{ schema, rootTypeName }]);
|
|
152
168
|
}
|
|
153
169
|
if (config.build) {
|
|
154
|
-
await buildOutput(outputDir,
|
|
170
|
+
await buildOutput(outputDir, written, config.typesOnly);
|
|
155
171
|
} else {
|
|
156
172
|
console.log(`
|
|
157
|
-
Total files generated: ${
|
|
173
|
+
Total files generated: ${written.length}`);
|
|
158
174
|
}
|
|
159
175
|
};
|
|
160
176
|
const runSingleFile = async (config, schemaPath, outFilePath) => {
|
|
161
177
|
const schema = await loadSchema(config, schemaPath);
|
|
162
178
|
const outputDir = dirname(outFilePath);
|
|
163
|
-
await
|
|
179
|
+
await ensureOutputDir(outputDir);
|
|
164
180
|
const rootTypeName = config.rootType ?? deriveRootTypeName(schema, schemaBaseName(schemaPath));
|
|
165
181
|
console.log(`Root type: ${rootTypeName}`);
|
|
166
182
|
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, config.helpers ?? "package", void 0, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
|
|
167
183
|
const combined = combineGeneratedFiles(files);
|
|
168
|
-
await
|
|
184
|
+
const writer = await createOutputWriter(outputDir, config.force);
|
|
185
|
+
const written = await commitOrDiscard(writer, async () => {
|
|
186
|
+
await writer.stage(basename(outFilePath), config.banner ? resolveBanner(config.banner) + combined : combined);
|
|
187
|
+
});
|
|
169
188
|
console.log(`Generated: ${relative(process.cwd(), outFilePath)}`);
|
|
170
189
|
if (config.examples) {
|
|
171
|
-
await runExamples(config, schema, rootTypeName
|
|
190
|
+
await runExamples(config, outputDir, [{ schema, rootTypeName }]);
|
|
172
191
|
}
|
|
173
192
|
if (config.build) {
|
|
174
|
-
await buildOutput(outputDir,
|
|
193
|
+
await buildOutput(outputDir, written, config.typesOnly);
|
|
175
194
|
} else {
|
|
176
195
|
console.log(`
|
|
177
|
-
Total files generated:
|
|
196
|
+
Total files generated: ${written.length}`);
|
|
178
197
|
}
|
|
179
198
|
};
|
|
180
199
|
const runRecursive = async (config, schemaDir, outputDir) => {
|
|
@@ -187,44 +206,45 @@ const runRecursive = async (config, schemaDir, outputDir) => {
|
|
|
187
206
|
console.error(`Error: no .json schema files found under ${schemaDir}.`);
|
|
188
207
|
process.exit(1);
|
|
189
208
|
}
|
|
190
|
-
await
|
|
209
|
+
await ensureOutputDir(outputDir);
|
|
191
210
|
const helpersMode = resolveHelpersMode(config, outputDir);
|
|
192
211
|
const sharedHelpers = /* @__PURE__ */ new Map();
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
212
|
+
const writer = await createOutputWriter(outputDir, config.force);
|
|
213
|
+
const exampleTasks = [];
|
|
214
|
+
const stageAll = async () => {
|
|
215
|
+
for (const schemaFile of schemaFiles) {
|
|
216
|
+
const relPath = relative(schemaDir, schemaFile);
|
|
217
|
+
const relNoExt = relPath.slice(0, -".json".length);
|
|
218
|
+
const depth = relNoExt.split(sep).filter(Boolean).length;
|
|
219
|
+
const helpersImportPrefix = "../".repeat(depth);
|
|
220
|
+
const schema = await loadSchema(config, schemaFile);
|
|
221
|
+
const rootTypeName = deriveRootTypeName(schema, schemaBaseName(schemaFile));
|
|
222
|
+
console.log(`
|
|
203
223
|
${relPath} \u2192 ${relNoExt}/ (root type: ${rootTypeName})`);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
224
|
+
const files = await buildSchema(schema, rootTypeName, void 0, config.typesOnly, config.logWarnings, config.strict, helpersMode, helpersImportPrefix, config.readonly, config.stripUnknown, config.typeSuffix, resolveImportExt(config), config.caseInsensitive);
|
|
225
|
+
for (const file of files) {
|
|
226
|
+
if (file.filename.startsWith("_helpers/")) {
|
|
227
|
+
sharedHelpers.set(file.filename, file.content);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
|
|
231
|
+
await writer.stage(join(relNoExt, file.filename), content);
|
|
209
232
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
if (config.validators) {
|
|
217
|
-
const validatorFiles = await runValidators(config, schema, rootTypeName, outputDir, join("validators", relNoExt));
|
|
218
|
-
writtenTsFiles.push(...validatorFiles);
|
|
233
|
+
if (config.validators) {
|
|
234
|
+
await runValidators(config, schema, rootTypeName, writer, join("validators", relNoExt));
|
|
235
|
+
}
|
|
236
|
+
if (config.examples)
|
|
237
|
+
exampleTasks.push({ schema, rootTypeName, subDir: relNoExt });
|
|
219
238
|
}
|
|
220
|
-
|
|
221
|
-
await
|
|
239
|
+
for (const [filename, content] of sharedHelpers) {
|
|
240
|
+
await writer.stage(filename, content);
|
|
222
241
|
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
writtenTsFiles.push(filename);
|
|
242
|
+
};
|
|
243
|
+
const writtenTsFiles = await commitOrDiscard(writer, stageAll);
|
|
244
|
+
for (const filename of writtenTsFiles)
|
|
227
245
|
console.log(`Generated: ${filename}`);
|
|
246
|
+
if (exampleTasks.length > 0) {
|
|
247
|
+
await runExamples(config, outputDir, exampleTasks);
|
|
228
248
|
}
|
|
229
249
|
if (config.build) {
|
|
230
250
|
await buildOutput(outputDir, writtenTsFiles, config.typesOnly);
|
|
@@ -253,11 +273,12 @@ const run = async () => {
|
|
|
253
273
|
process.stderr.write(result.stderr);
|
|
254
274
|
process.exit(result.code);
|
|
255
275
|
}
|
|
256
|
-
|
|
276
|
+
const metaRequest = parseMetaRequest(args);
|
|
277
|
+
if (metaRequest === "version") {
|
|
257
278
|
console.log(await readVersion());
|
|
258
279
|
return;
|
|
259
280
|
}
|
|
260
|
-
if (args.length === 0 ||
|
|
281
|
+
if (args.length === 0 || metaRequest === "help") {
|
|
261
282
|
console.log(HELP_TEXT);
|
|
262
283
|
return;
|
|
263
284
|
}
|
|
@@ -269,6 +290,10 @@ const run = async () => {
|
|
|
269
290
|
console.error("Error: provide only one of --out-dir or --out-file, not both.");
|
|
270
291
|
process.exit(1);
|
|
271
292
|
}
|
|
293
|
+
if (config.rootType !== void 0 && !ROOT_TYPE_PATTERN.test(config.rootType)) {
|
|
294
|
+
console.error(`Error: invalid --root-type "${config.rootType}". Expected a TypeScript identifier (letters, digits, _ or $, not starting with a digit).`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
272
297
|
if (config.rootType && config.schemaDir) {
|
|
273
298
|
console.error("Error: --root-type cannot be combined with --schema-dir; each schema derives its own root type.");
|
|
274
299
|
process.exit(1);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A two-phase writer for one generation run: every file is staged next to its
|
|
3
|
+
* final location under a temporary name, and only once the whole set is on disk
|
|
4
|
+
* are the temporaries renamed into place.
|
|
5
|
+
*
|
|
6
|
+
* Two failure modes drive the design:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Partial trees.** Writing straight to the final paths meant a failure
|
|
9
|
+
* halfway through (a `_helpers` path occupied by a regular file, a full disk)
|
|
10
|
+
* left some generated files behind and others missing — a tree that compiles
|
|
11
|
+
* against a schema that no longer exists. Staging first means the run either
|
|
12
|
+
* lands completely or leaves the destination as it found it. Renaming is not
|
|
13
|
+
* one atomic operation across the whole set, but every rename happens after
|
|
14
|
+
* all the risky work (generating, creating directories, writing bytes) has
|
|
15
|
+
* already succeeded, so it is the cheapest place to get the guarantee.
|
|
16
|
+
* 2. **Clobbering hand-written files.** A generated name can collide with a file
|
|
17
|
+
* the user wrote (`index.ts` is the common one), which used to be overwritten
|
|
18
|
+
* without a word — and then, under `--build`, deleted along with the other
|
|
19
|
+
* intermediate sources. A file is only replaced when a previous run claimed it
|
|
20
|
+
* (see {@link MANIFEST_FILENAME}) or `--force` says so, and `--build` is only
|
|
21
|
+
* ever handed the paths this run actually committed.
|
|
22
|
+
*/
|
|
23
|
+
export type OutputWriter = {
|
|
24
|
+
/**
|
|
25
|
+
* Stages one file. `relativePath` is relative to the writer's root and may
|
|
26
|
+
* contain subdirectories; parent directories are created eagerly.
|
|
27
|
+
*/
|
|
28
|
+
readonly stage: (relativePath: string, content: string) => Promise<void>;
|
|
29
|
+
/** Moves every staged file to its final path and returns the committed paths. */
|
|
30
|
+
readonly commit: () => Promise<string[]>;
|
|
31
|
+
/** Deletes the staged temporaries; call this when the run fails before committing. */
|
|
32
|
+
readonly discard: () => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Creates an {@link OutputWriter} rooted at `rootDir`.
|
|
36
|
+
*
|
|
37
|
+
* @param rootDir - Output root. Every write must resolve inside it.
|
|
38
|
+
* @param force - Overwrite pre-existing files instead of refusing (`--force`).
|
|
39
|
+
*/
|
|
40
|
+
export declare const createOutputWriter: (rootDir: string, force?: boolean) => Promise<OutputWriter>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { ensureOutputDir } from "./ensure-output-dir.js";
|
|
4
|
+
const MANIFEST_FILENAME = ".mjst-manifest.json";
|
|
5
|
+
const pathExists = async (path) => stat(path).then(() => true, () => false);
|
|
6
|
+
const readManifest = async (root) => {
|
|
7
|
+
const raw = await readFile(join(root, MANIFEST_FILENAME), "utf-8").catch(() => void 0);
|
|
8
|
+
if (raw === void 0)
|
|
9
|
+
return /* @__PURE__ */ new Set();
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
const files = Array.isArray(parsed.files) ? parsed.files : [];
|
|
13
|
+
return new Set(files.filter((file) => typeof file === "string").map((file) => resolve(root, file)));
|
|
14
|
+
} catch {
|
|
15
|
+
return /* @__PURE__ */ new Set();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const createOutputWriter = async (rootDir, force = false) => {
|
|
19
|
+
const root = resolve(rootDir);
|
|
20
|
+
const owned = await readManifest(root);
|
|
21
|
+
const staged = [];
|
|
22
|
+
const stamp = `${process.pid}-${Date.now().toString(36)}`;
|
|
23
|
+
const stage = async (relativePath, content) => {
|
|
24
|
+
const targetPath = resolve(root, relativePath);
|
|
25
|
+
if (targetPath !== root && !targetPath.startsWith(root + sep)) {
|
|
26
|
+
throw new Error(`Refusing to write "${relativePath}": it resolves outside the output directory ${root}.`);
|
|
27
|
+
}
|
|
28
|
+
if (!force && !owned.has(targetPath) && await pathExists(targetPath)) {
|
|
29
|
+
throw new Error(`Refusing to overwrite ${targetPath}: it already exists and was not generated by mjst. Move it aside, choose another output directory, or pass --force to overwrite it.`);
|
|
30
|
+
}
|
|
31
|
+
const tempPath = `${targetPath}.mjst-${stamp}-${staged.length}.tmp`;
|
|
32
|
+
await ensureOutputDir(dirname(targetPath));
|
|
33
|
+
await writeFile(tempPath, content, "utf-8");
|
|
34
|
+
staged.push({ relativePath, targetPath, tempPath });
|
|
35
|
+
};
|
|
36
|
+
const commit = async () => {
|
|
37
|
+
for (const file of staged) {
|
|
38
|
+
await rename(file.tempPath, file.targetPath);
|
|
39
|
+
owned.add(file.targetPath);
|
|
40
|
+
}
|
|
41
|
+
if (staged.length > 0) {
|
|
42
|
+
const files = [...owned].map((path) => relative(root, path)).sort();
|
|
43
|
+
await writeFile(join(root, MANIFEST_FILENAME), `${JSON.stringify({ version: 1, files }, null, 2)}
|
|
44
|
+
`, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
return staged.map((file) => file.relativePath);
|
|
47
|
+
};
|
|
48
|
+
const discard = async () => {
|
|
49
|
+
for (const file of staged) {
|
|
50
|
+
await rm(file.tempPath, { force: true }).catch(() => void 0);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return { stage, commit, discard };
|
|
54
|
+
};
|
|
55
|
+
export {
|
|
56
|
+
createOutputWriter
|
|
57
|
+
};
|
package/dist/emit-examples.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
import type { OutputWriter } from './create-output-writer.js';
|
|
2
3
|
/**
|
|
3
4
|
* Inputs for {@link emitExamples}. The schema and its root type name are the same
|
|
4
5
|
* ones handed to the parser generator, so the arbitraries/examples line up with
|
|
@@ -7,8 +8,15 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
7
8
|
export type EmitExamplesOptions = {
|
|
8
9
|
readonly schema: JSONSchema;
|
|
9
10
|
readonly rootTypeName: string;
|
|
10
|
-
/**
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The run's writer, rooted at the output destination. Examples used to be
|
|
13
|
+
* written straight to disk with `mkdir` + `writeFile`, which meant the one
|
|
14
|
+
* output tree mjst emits that skipped the ownership check — a hand-written
|
|
15
|
+
* `examples/index.ts` was overwritten without a word. Taking the writer instead
|
|
16
|
+
* of a directory makes that impossible to reintroduce: there is no path here
|
|
17
|
+
* that does not go through staging, the manifest, and `--force`.
|
|
18
|
+
*/
|
|
19
|
+
readonly writer: OutputWriter;
|
|
12
20
|
/**
|
|
13
21
|
* Nested location of the schema relative to `--schema-dir` (e.g. `api/order`),
|
|
14
22
|
* mirrored beneath `examples/` so each schema's test data sits beside where its
|
|
@@ -21,8 +29,8 @@ export type EmitExamplesOptions = {
|
|
|
21
29
|
readonly bannerPrefix?: string;
|
|
22
30
|
};
|
|
23
31
|
/**
|
|
24
|
-
*
|
|
25
|
-
* `examples/` subdirectory of
|
|
32
|
+
* Stages fast-check arbitrary + concrete example files for one schema into an
|
|
33
|
+
* `examples/` subdirectory of the writer's root.
|
|
26
34
|
*
|
|
27
35
|
* The dedicated subdirectory keeps the test-data output from colliding with the
|
|
28
36
|
* parser files, which otherwise share the same `<name>.ts` / `index.ts` names.
|
|
@@ -31,6 +39,9 @@ export type EmitExamplesOptions = {
|
|
|
31
39
|
* barrel re-exports them. The arbitraries import `fast-check`, which consumers
|
|
32
40
|
* must install as a (dev) dependency.
|
|
33
41
|
*
|
|
34
|
-
*
|
|
42
|
+
* Nothing lands on disk until the caller commits the writer, so a schema that
|
|
43
|
+
* fails part-way through leaves no half-written `examples/` tree behind.
|
|
44
|
+
*
|
|
45
|
+
* @returns The staged file paths, relative to the writer's root.
|
|
35
46
|
*/
|
|
36
47
|
export declare const emitExamples: (options: EmitExamplesOptions) => Promise<string[]>;
|
package/dist/emit-examples.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join, relative } from "node:path";
|
|
1
|
+
import { join } from "node:path";
|
|
3
2
|
import { buildExampleSchema } from "@amritk/generate-examples";
|
|
4
3
|
const emitExamples = async (options) => {
|
|
5
|
-
const { schema, rootTypeName,
|
|
4
|
+
const { schema, rootTypeName, writer, subDir = "", typeSuffix, bannerPrefix = "" } = options;
|
|
6
5
|
const files = await buildExampleSchema(schema, rootTypeName, typeSuffix);
|
|
7
|
-
const
|
|
8
|
-
const written = [];
|
|
6
|
+
const staged = [];
|
|
9
7
|
for (const file of files) {
|
|
10
|
-
const
|
|
11
|
-
await
|
|
12
|
-
|
|
13
|
-
written.push(relative(outputDir, filePath));
|
|
8
|
+
const relativePath = join("examples", subDir, file.filename);
|
|
9
|
+
await writer.stage(relativePath, bannerPrefix + file.content);
|
|
10
|
+
staged.push(relativePath);
|
|
14
11
|
}
|
|
15
|
-
return
|
|
12
|
+
return staged;
|
|
16
13
|
};
|
|
17
14
|
export {
|
|
18
15
|
emitExamples
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates the output directory, translating the two ways a path can already be
|
|
3
|
+
* taken by something that is not a directory into an actionable message.
|
|
4
|
+
*
|
|
5
|
+
* `mkdir --out-dir` on an existing *file* fails with a raw
|
|
6
|
+
* `EEXIST: file already exists, mkdir '/abs/path'` (and `ENOTDIR` when a file sits
|
|
7
|
+
* midway through the path), which reads like an mjst bug rather than "you pointed
|
|
8
|
+
* --out-dir at a file".
|
|
9
|
+
*/
|
|
10
|
+
export declare const ensureOutputDir: (dir: string) => Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
const ensureOutputDir = async (dir) => {
|
|
3
|
+
try {
|
|
4
|
+
await mkdir(dir, { recursive: true });
|
|
5
|
+
} catch (error) {
|
|
6
|
+
const code = error.code;
|
|
7
|
+
if (code === "EEXIST" || code === "ENOTDIR") {
|
|
8
|
+
throw new Error(`Cannot use ${dir} as the output directory: a file already exists at that path.`);
|
|
9
|
+
}
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
export {
|
|
14
|
+
ensureOutputDir
|
|
15
|
+
};
|