@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.
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. Remote
148
- (`http(s)`) refs stay **offline by default**: a schema with a remote `$ref` fails
149
- rather than making a network call unless you opt in.
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` — while remote refs are only fetched when you opt in with `--resolve-remote` or `--allowed-hosts`.
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 &lt;dirs&gt;</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 &lt;path&gt;</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
+ };
@@ -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 { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
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 isVersionRequest = (args) => args[0] === "version" || args.includes("--version") || args.includes("-v");
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 writeGeneratedFile = async (baseDir, filename, content) => {
54
- const filePath = join(baseDir, filename);
55
- await mkdir(dirname(filePath), { recursive: true });
56
- await writeFile(filePath, content, "utf-8");
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, schema, rootTypeName, outputDir, subDir = "") => {
59
- const written = await emitExamples({
60
- schema,
61
- rootTypeName,
62
- outputDir,
63
- subDir,
64
- bannerPrefix: config.banner ? resolveBanner(config.banner) : "",
65
- // Spread conditionally: `exactOptionalPropertyTypes` rejects an explicit
66
- // `undefined` for the optional `typeSuffix`.
67
- ...config.typeSuffix !== void 0 ? { typeSuffix: config.typeSuffix } : {}
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
- console.log(`Built: ${file.replace(/\.ts$/, ".js")}`);
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, outputDir, validatorsSubDir) => {
136
+ const runValidators = async (config, schema, rootTypeName, writer, validatorsSubDir) => {
125
137
  const files = await buildValidatorSchema(schema, rootTypeName, config.typeSuffix);
126
- const written = [];
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 writeGeneratedFile(outputDir, relFilename, content);
131
- written.push(relFilename);
132
- console.log(`Generated: ${relFilename}`);
142
+ await writer.stage(relFilename, content);
143
+ staged.push(relFilename);
133
144
  }
134
- return written;
145
+ return staged;
135
146
  };
136
147
  const runSingle = async (config, schemaPath, outputDir) => {
137
148
  const schema = await loadSchema(config, schemaPath);
138
- await mkdir(outputDir, { recursive: true });
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
- for (const file of files) {
144
- const isHelper = file.filename.startsWith("_helpers/");
145
- const content = config.banner && !isHelper ? resolveBanner(config.banner) + file.content : file.content;
146
- await writeGeneratedFile(outputDir, file.filename, content);
147
- console.log(`Generated: ${file.filename}`);
148
- }
149
- const validatorFiles = config.validators ? await runValidators(config, schema, rootTypeName, outputDir, "validators") : [];
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, outputDir);
167
+ await runExamples(config, outputDir, [{ schema, rootTypeName }]);
152
168
  }
153
169
  if (config.build) {
154
- await buildOutput(outputDir, [...files.map((f) => f.filename), ...validatorFiles], config.typesOnly);
170
+ await buildOutput(outputDir, written, config.typesOnly);
155
171
  } else {
156
172
  console.log(`
157
- Total files generated: ${files.length + validatorFiles.length}`);
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 mkdir(outputDir, { recursive: true });
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 writeFile(outFilePath, config.banner ? resolveBanner(config.banner) + combined : combined, "utf-8");
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, outputDir);
190
+ await runExamples(config, outputDir, [{ schema, rootTypeName }]);
172
191
  }
173
192
  if (config.build) {
174
- await buildOutput(outputDir, [basename(outFilePath)], config.typesOnly);
193
+ await buildOutput(outputDir, written, config.typesOnly);
175
194
  } else {
176
195
  console.log(`
177
- Total files generated: 1`);
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 mkdir(outputDir, { recursive: true });
209
+ await ensureOutputDir(outputDir);
191
210
  const helpersMode = resolveHelpersMode(config, outputDir);
192
211
  const sharedHelpers = /* @__PURE__ */ new Map();
193
- const writtenTsFiles = [];
194
- for (const schemaFile of schemaFiles) {
195
- const relPath = relative(schemaDir, schemaFile);
196
- const relNoExt = relPath.slice(0, -".json".length);
197
- const schemaSubDir = join(outputDir, relNoExt);
198
- const depth = relNoExt.split(sep).filter(Boolean).length;
199
- const helpersImportPrefix = "../".repeat(depth);
200
- const schema = await loadSchema(config, schemaFile);
201
- const rootTypeName = deriveRootTypeName(schema, schemaBaseName(schemaFile));
202
- console.log(`
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
- 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);
205
- for (const file of files) {
206
- if (file.filename.startsWith("_helpers/")) {
207
- sharedHelpers.set(file.filename, file.content);
208
- continue;
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
- const content = config.banner ? resolveBanner(config.banner) + file.content : file.content;
211
- await writeGeneratedFile(schemaSubDir, file.filename, content);
212
- const relativeFilename = join(relNoExt, file.filename);
213
- writtenTsFiles.push(relativeFilename);
214
- console.log(`Generated: ${relativeFilename}`);
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
- if (config.examples) {
221
- await runExamples(config, schema, rootTypeName, outputDir, relNoExt);
239
+ for (const [filename, content] of sharedHelpers) {
240
+ await writer.stage(filename, content);
222
241
  }
223
- }
224
- for (const [filename, content] of sharedHelpers) {
225
- await writeGeneratedFile(outputDir, filename, content);
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
- if (isVersionRequest(args)) {
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 || isHelpRequest(args)) {
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
+ };
@@ -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
- /** Output root; example files always land under `<outputDir>/examples/`. */
11
- readonly outputDir: string;
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
- * Emits fast-check arbitrary + concrete example files for one schema into an
25
- * `examples/` subdirectory of `outputDir`.
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
- * @returns The written file paths, relative to `outputDir`.
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[]>;
@@ -1,18 +1,15 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
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, outputDir, subDir = "", typeSuffix, bannerPrefix = "" } = options;
4
+ const { schema, rootTypeName, writer, subDir = "", typeSuffix, bannerPrefix = "" } = options;
6
5
  const files = await buildExampleSchema(schema, rootTypeName, typeSuffix);
7
- const exampleDir = join(outputDir, "examples", subDir);
8
- const written = [];
6
+ const staged = [];
9
7
  for (const file of files) {
10
- const filePath = join(exampleDir, file.filename);
11
- await mkdir(dirname(filePath), { recursive: true });
12
- await writeFile(filePath, bannerPrefix + file.content, "utf-8");
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 written;
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
+ };