zopia 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +354 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/bin/zopia.js +20 -0
- package/docs/01-overview.md +94 -0
- package/docs/02-targets.md +55 -0
- package/docs/03-roadmap.md +205 -0
- package/docs/04-architecture.md +345 -0
- package/docs/05-concepts.md +239 -0
- package/docs/06-conversions.md +493 -0
- package/docs/07-api-docs.md +337 -0
- package/docs/08-components.md +223 -0
- package/docs/09-configuration.md +167 -0
- package/docs/10-usage.md +208 -0
- package/docs/11-testing.md +267 -0
- package/docs/12-standards.md +242 -0
- package/docs/README.md +42 -0
- package/docs/publish-workflow.yml.example +48 -0
- package/package.json +77 -0
- package/src/api-docs-navigation.ts +353 -0
- package/src/cli-command.ts +537 -0
- package/src/cli.ts +4 -0
- package/src/config.ts +190 -0
- package/src/conversions/api-docs-facade.ts +42 -0
- package/src/conversions/api-docs-generate.ts +567 -0
- package/src/conversions/api-docs-layout.ts +39 -0
- package/src/conversions/api-docs-plan.ts +130 -0
- package/src/conversions/api-docs-presets.ts +246 -0
- package/src/conversions/json-schema-to-zod.ts +931 -0
- package/src/conversions/manifest-staleness.ts +211 -0
- package/src/conversions/manifest-to-openapi.ts +1861 -0
- package/src/conversions/manifest-writer.ts +778 -0
- package/src/conversions/openapi-contracts.ts +333 -0
- package/src/conversions/openapi-external-ref.ts +233 -0
- package/src/conversions/openapi-ir.ts +74 -0
- package/src/conversions/openapi-ref.ts +38 -0
- package/src/conversions/openapi-to-api-docs-public.ts +466 -0
- package/src/conversions/openapi-to-api-docs.ts +203 -0
- package/src/conversions/openapi.ts +80 -0
- package/src/conversions/reverse-security.ts +68 -0
- package/src/conversions/yaml.ts +876 -0
- package/src/conversions/zod-to-json-schema.ts +536 -0
- package/src/diff.ts +353 -0
- package/src/errors.ts +114 -0
- package/src/index.ts +80 -0
- package/src/validation.ts +299 -0
- package/src/warnings.ts +164 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname } from 'node:path';
|
|
4
|
+
import { loadZopiaConfig, type ZopiaProjectConfig } from './config';
|
|
5
|
+
import { asZopiaError, ZopiaError } from './errors';
|
|
6
|
+
import { openApiToApiDocs } from './conversions/openapi-to-api-docs-public';
|
|
7
|
+
import { apiDocsToOpenApi } from './conversions/manifest-to-openapi';
|
|
8
|
+
import { validateZopia, type ZopiaValidationResult } from './validation';
|
|
9
|
+
import { diffOpenApiSpecs, type ZopiaDiffResult } from './diff';
|
|
10
|
+
import { loadNavigationIndex } from './api-docs-navigation';
|
|
11
|
+
import { formatZopiaWarning, type ZopiaWarning } from './warnings';
|
|
12
|
+
|
|
13
|
+
/** Output channels used by the CLI command runner. */
|
|
14
|
+
export interface ZopiaCliOutput {
|
|
15
|
+
/**
|
|
16
|
+
* Write generated command output without diagnostics.
|
|
17
|
+
*
|
|
18
|
+
* @param content Command output to write to the standard-output channel.
|
|
19
|
+
* @returns Nothing.
|
|
20
|
+
*/
|
|
21
|
+
stdout(content: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Write one diagnostic line without contaminating generated output.
|
|
24
|
+
*
|
|
25
|
+
* @param content Diagnostic text to write to the standard-error channel.
|
|
26
|
+
* @returns Nothing.
|
|
27
|
+
*/
|
|
28
|
+
stderr(content: string): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface GenerateArguments {
|
|
32
|
+
input: string;
|
|
33
|
+
outputDirectory?: string;
|
|
34
|
+
mode?: 'directory' | 'flat';
|
|
35
|
+
insertComponents: boolean;
|
|
36
|
+
useComponentAsReference: boolean;
|
|
37
|
+
manifest: boolean;
|
|
38
|
+
custom: boolean;
|
|
39
|
+
preset?: 'multi-tag' | 'multi-server';
|
|
40
|
+
config?: string;
|
|
41
|
+
watch: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ReverseArguments {
|
|
45
|
+
input: string;
|
|
46
|
+
outputFile?: string;
|
|
47
|
+
version?: '2.0' | '3.0' | '3.1';
|
|
48
|
+
config?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ValidateArguments {
|
|
52
|
+
input: string;
|
|
53
|
+
config?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DiffArguments {
|
|
57
|
+
before: string;
|
|
58
|
+
after: string;
|
|
59
|
+
config?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const HELP_TEXT = `Usage:
|
|
63
|
+
zopia generate <spec.json|spec.yaml> [output-dir] [--mode directory|flat] [--insert-components] [--use-component-as-reference] [--custom] [--no-manifest] [--preset multi-tag|multi-server] [--watch] [--config path]
|
|
64
|
+
zopia reverse <docs-dir|manifest.json> [--out file] [--version 2.0|3.0|3.1] [--config path]
|
|
65
|
+
zopia validate <spec.json|spec.yaml|docs-dir> [--config path]
|
|
66
|
+
zopia diff <old.json|old.yaml> <new.json|new.yaml> [--config path]
|
|
67
|
+
zopia navigate <docs-dir> (--to-code <spec-pointer> | --to-spec <tree-file>) [--config path]
|
|
68
|
+
|
|
69
|
+
Global options:
|
|
70
|
+
-h, --help Show this help.
|
|
71
|
+
--config path Use an explicit config file instead of discovering zopia.config.ts in the
|
|
72
|
+
working directory. CLI flags always override config values; config values
|
|
73
|
+
override built-in defaults. With no config value the output directory stays
|
|
74
|
+
a required positional argument for generate.
|
|
75
|
+
|
|
76
|
+
Generate options:
|
|
77
|
+
--mode directory|flat Select endpoint layout (default: config generate.mode, then directory).
|
|
78
|
+
--insert-components Emit component schema modules.
|
|
79
|
+
--use-component-as-reference Import emitted components; requires --insert-components.
|
|
80
|
+
--custom Write merge-safe custom companion modules per endpoint and export them.
|
|
81
|
+
--preset multi-tag|multi-server Split generation into per-bucket sub-trees: one tree per primary tag,
|
|
82
|
+
or one per effective first server (falls through when there is nothing
|
|
83
|
+
to split; each sub-tree keeps its own manifest).
|
|
84
|
+
--no-manifest Do not write .zopia-manifest.json (overrides config generate.manifest).
|
|
85
|
+
zopia navigate Jump table between a generated tree and its source spec:
|
|
86
|
+
--to-code <pointer> prints the generated file(s) implementing
|
|
87
|
+
'#/paths/~1pets/get' style pointers; --to-spec <file> prints the
|
|
88
|
+
source pointer owning a tree-relative file (both directions are
|
|
89
|
+
manifest-driven, including custom companions and components).
|
|
90
|
+
--watch Regenerate whenever the spec file changes (Ctrl+C to stop).
|
|
91
|
+
|
|
92
|
+
Reverse options:
|
|
93
|
+
--out file Write JSON to a file instead of stdout (default: config reverse.out, then stdout).
|
|
94
|
+
--version 2.0|3.0|3.1 Select OpenAPI output (default: config reverse.version, then 3.1).
|
|
95
|
+
|
|
96
|
+
Validate options:
|
|
97
|
+
(none) Checks a spec for broken refs, name collisions, and unreachable components,
|
|
98
|
+
or a generated tree for manifest problems, reverse dry-run failures, and km-api
|
|
99
|
+
drift. Diagnostics print to stdout; exit status is 1 when any error-severity
|
|
100
|
+
diagnostic was found.
|
|
101
|
+
|
|
102
|
+
Diff options:
|
|
103
|
+
(none) Compares two specs: dialect, info, endpoints (+/-/~ with parameter,
|
|
104
|
+
request-body, and response details), webhooks, schema components, and
|
|
105
|
+
document fields. Changes print to stdout with a summary line; differences
|
|
106
|
+
are data, so a non-identical pair still exits 0.
|
|
107
|
+
|
|
108
|
+
Security: reverse executes generated TypeScript referenced by the manifest and the config file is executed
|
|
109
|
+
JavaScript; use only trusted trees and trusted config files.
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const processOutput: ZopiaCliOutput = {
|
|
113
|
+
stdout: (content) => process.stdout.write(content),
|
|
114
|
+
stderr: (content) => console.error(content),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function invalid(message: string, at: string, hint: string): never {
|
|
118
|
+
throw new ZopiaError('ZOPIA_CONFIG_INVALID', message, { at, hint });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function usage(): never {
|
|
122
|
+
invalid('usage: zopia generate <spec.json|spec.yaml> [output-dir] [options] | zopia reverse <docs-dir|manifest.json> [options]', 'argv', "run 'zopia --help' for command syntax");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function markOption(seen: Set<string>, option: string): void {
|
|
126
|
+
if (seen.has(option)) invalid(`duplicate CLI option: ${option}`, option, `remove the repeated ${option} option`);
|
|
127
|
+
seen.add(option);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function optionValue(argv: string[], index: number, option: string): string {
|
|
131
|
+
const value = argv[index + 1];
|
|
132
|
+
if (!value || value.startsWith('-')) invalid(`${option} requires a value`, option, `provide a value after ${option}`);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseGenerate(argv: string[]): GenerateArguments {
|
|
137
|
+
const positional: string[] = [];
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
let mode: 'directory' | 'flat' | undefined;
|
|
140
|
+
let insertComponents = false;
|
|
141
|
+
let useComponentAsReference = false;
|
|
142
|
+
let manifest = true;
|
|
143
|
+
let custom = false;
|
|
144
|
+
let preset: 'multi-tag' | 'multi-server' | undefined;
|
|
145
|
+
let config: string | undefined;
|
|
146
|
+
let watchMode = false;
|
|
147
|
+
|
|
148
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
149
|
+
const argument = argv[index];
|
|
150
|
+
if (argument === '--mode') {
|
|
151
|
+
markOption(seen, argument);
|
|
152
|
+
const value = optionValue(argv, index, argument);
|
|
153
|
+
if (value !== 'directory' && value !== 'flat') invalid('invalid --mode; expected directory or flat', argument, "use '--mode directory' or '--mode flat'");
|
|
154
|
+
mode = value;
|
|
155
|
+
index += 1;
|
|
156
|
+
} else if (argument === '--insert-components') {
|
|
157
|
+
markOption(seen, argument);
|
|
158
|
+
insertComponents = true;
|
|
159
|
+
} else if (argument === '--use-component-as-reference') {
|
|
160
|
+
markOption(seen, argument);
|
|
161
|
+
useComponentAsReference = true;
|
|
162
|
+
} else if (argument === '--no-manifest') {
|
|
163
|
+
markOption(seen, argument);
|
|
164
|
+
manifest = false;
|
|
165
|
+
} else if (argument === '--custom') {
|
|
166
|
+
markOption(seen, argument);
|
|
167
|
+
custom = true;
|
|
168
|
+
} else if (argument === '--preset') {
|
|
169
|
+
markOption(seen, argument);
|
|
170
|
+
const value = optionValue(argv, index, argument);
|
|
171
|
+
if (value !== 'multi-tag' && value !== 'multi-server') invalid('invalid --preset; expected multi-tag or multi-server', argument, "use '--preset multi-tag' or '--preset multi-server'");
|
|
172
|
+
preset = value;
|
|
173
|
+
index += 1;
|
|
174
|
+
} else if (argument === '--config') {
|
|
175
|
+
markOption(seen, argument);
|
|
176
|
+
config = optionValue(argv, index, argument);
|
|
177
|
+
index += 1;
|
|
178
|
+
} else if (argument === '--watch') {
|
|
179
|
+
markOption(seen, argument);
|
|
180
|
+
watchMode = true;
|
|
181
|
+
} else if (argument.startsWith('-')) {
|
|
182
|
+
invalid(`unknown generate option: ${argument}`, argument, "run 'zopia generate --help' for supported options");
|
|
183
|
+
} else {
|
|
184
|
+
positional.push(argument);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (positional.length < 1) invalid('generate requires <spec.json|spec.yaml>', 'argv', 'provide the input spec path');
|
|
189
|
+
if (positional.length > 2) invalid(`unexpected generate argument: ${positional[2]}`, positional[2], 'remove the extra positional argument');
|
|
190
|
+
return { input: positional[0], outputDirectory: positional[1], mode, insertComponents, useComponentAsReference, manifest, custom, preset, config, watch: watchMode };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseReverse(argv: string[]): ReverseArguments {
|
|
194
|
+
const positional: string[] = [];
|
|
195
|
+
const seen = new Set<string>();
|
|
196
|
+
let outputFile: string | undefined;
|
|
197
|
+
let version: '2.0' | '3.0' | '3.1' | undefined;
|
|
198
|
+
let config: string | undefined;
|
|
199
|
+
|
|
200
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
201
|
+
const argument = argv[index];
|
|
202
|
+
if (argument === '--out') {
|
|
203
|
+
markOption(seen, argument);
|
|
204
|
+
outputFile = optionValue(argv, index, argument);
|
|
205
|
+
index += 1;
|
|
206
|
+
} else if (argument === '--version') {
|
|
207
|
+
markOption(seen, argument);
|
|
208
|
+
const value = optionValue(argv, index, argument);
|
|
209
|
+
if (value !== '2.0' && value !== '3.0' && value !== '3.1') invalid("invalid --version; expected '2.0', '3.0', or '3.1'", argument, "use '--version 2.0', '--version 3.0', or '--version 3.1'");
|
|
210
|
+
version = value;
|
|
211
|
+
index += 1;
|
|
212
|
+
} else if (argument === '--config') {
|
|
213
|
+
markOption(seen, argument);
|
|
214
|
+
config = optionValue(argv, index, argument);
|
|
215
|
+
index += 1;
|
|
216
|
+
} else if (argument.startsWith('-')) {
|
|
217
|
+
invalid(`unknown reverse option: ${argument}`, argument, "run 'zopia reverse --help' for supported options");
|
|
218
|
+
} else {
|
|
219
|
+
positional.push(argument);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (positional.length < 1) invalid('reverse requires <docs-dir|manifest.json>', 'argv', 'provide a generated docs directory or manifest path');
|
|
224
|
+
if (positional.length > 1) invalid(`unexpected reverse argument: ${positional[1]}`, positional[1], 'remove the extra positional argument');
|
|
225
|
+
return { input: positional[0], outputFile, version, config };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function printWarnings(warnings: readonly ZopiaWarning[], output: ZopiaCliOutput): void {
|
|
229
|
+
for (const warning of warnings) output.stderr(`Warning: ${formatZopiaWarning(warning)}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseValidate(argv: string[]): ValidateArguments {
|
|
233
|
+
const positional: string[] = [];
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
let config: string | undefined;
|
|
236
|
+
|
|
237
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
238
|
+
const argument = argv[index];
|
|
239
|
+
if (argument === '--config') {
|
|
240
|
+
markOption(seen, argument);
|
|
241
|
+
config = optionValue(argv, index, argument);
|
|
242
|
+
index += 1;
|
|
243
|
+
} else if (argument.startsWith('-')) {
|
|
244
|
+
invalid(`unknown validate option: ${argument}`, argument, "run 'zopia validate --help' for supported options");
|
|
245
|
+
} else {
|
|
246
|
+
positional.push(argument);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (positional.length < 1) invalid('validate requires <spec.json|spec.yaml|docs-dir>', 'argv', 'provide the spec path or generated docs directory');
|
|
251
|
+
if (positional.length > 1) invalid(`unexpected validate argument: ${positional[1]}`, positional[1], 'remove the extra positional argument');
|
|
252
|
+
return { input: positional[0], config };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface NavigateArguments {
|
|
256
|
+
tree: string;
|
|
257
|
+
toCode?: string;
|
|
258
|
+
toSpec?: string;
|
|
259
|
+
config?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseNavigate(argv: string[]): NavigateArguments {
|
|
263
|
+
const positional: string[] = [];
|
|
264
|
+
const seen = new Set<string>();
|
|
265
|
+
let config: string | undefined;
|
|
266
|
+
let toCode: string | undefined;
|
|
267
|
+
let toSpec: string | undefined;
|
|
268
|
+
|
|
269
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
270
|
+
const argument = argv[index];
|
|
271
|
+
if (argument === '--config') {
|
|
272
|
+
markOption(seen, argument);
|
|
273
|
+
config = optionValue(argv, index, argument);
|
|
274
|
+
index += 1;
|
|
275
|
+
} else if (argument === '--to-code') {
|
|
276
|
+
markOption(seen, argument);
|
|
277
|
+
toCode = optionValue(argv, index, argument);
|
|
278
|
+
index += 1;
|
|
279
|
+
} else if (argument === '--to-spec') {
|
|
280
|
+
markOption(seen, argument);
|
|
281
|
+
toSpec = optionValue(argv, index, argument);
|
|
282
|
+
index += 1;
|
|
283
|
+
} else if (argument.startsWith('-')) {
|
|
284
|
+
invalid(`unknown navigate option: ${argument}`, argument, "run 'zopia navigate --help' for supported options");
|
|
285
|
+
} else {
|
|
286
|
+
positional.push(argument);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (positional.length < 1) invalid('navigate requires <docs-dir>', 'argv', 'provide the generated tree root (or preset bucket root) to navigate');
|
|
291
|
+
if (positional.length > 1) invalid(`unexpected navigate argument: ${positional[1]}`, positional[1], 'remove the extra positional argument');
|
|
292
|
+
if (toCode === undefined && toSpec === undefined) invalid('navigate requires --to-code or --to-spec', 'argv', "use --to-code '#/paths/~1pets/get' to find files or --to-spec pets/get/index.ts to find the pointer");
|
|
293
|
+
if (toCode !== undefined && toSpec !== undefined) invalid('navigate accepts either --to-code or --to-spec, not both', toSpec, 'pick one direction per invocation');
|
|
294
|
+
return { tree: positional[0], ...(toCode === undefined ? {} : { toCode }), ...(toSpec === undefined ? {} : { toSpec }), ...(config === undefined ? {} : { config }) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseDiff(argv: string[]): DiffArguments {
|
|
298
|
+
const positional: string[] = [];
|
|
299
|
+
const seen = new Set<string>();
|
|
300
|
+
let config: string | undefined;
|
|
301
|
+
|
|
302
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
303
|
+
const argument = argv[index];
|
|
304
|
+
if (argument === '--config') {
|
|
305
|
+
markOption(seen, argument);
|
|
306
|
+
config = optionValue(argv, index, argument);
|
|
307
|
+
index += 1;
|
|
308
|
+
} else if (argument.startsWith('-')) {
|
|
309
|
+
invalid(`unknown diff option: ${argument}`, argument, "run 'zopia diff --help' for supported options");
|
|
310
|
+
} else {
|
|
311
|
+
positional.push(argument);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (positional.length < 2) invalid('diff requires <old.json|old.yaml> and <new.json|new.yaml>', 'argv', 'provide both spec paths or documents to compare');
|
|
316
|
+
if (positional.length > 2) invalid(`unexpected diff argument: ${positional[2]}`, positional[2], 'remove the extra positional argument');
|
|
317
|
+
return { before: positional[0], after: positional[1], config };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function printValidation(result: ZopiaValidationResult, output: ZopiaCliOutput): void {
|
|
321
|
+
for (const issue of result.diagnostics) {
|
|
322
|
+
output.stdout(`${issue.severity === 'error' ? 'Error' : 'Warning'}: ${issue.code}${issue.at ? ` ${issue.at}` : ''}: ${issue.message}\n`);
|
|
323
|
+
}
|
|
324
|
+
const errors = result.diagnostics.filter((issue) => issue.severity === 'error').length;
|
|
325
|
+
const warnings = result.diagnostics.length - errors;
|
|
326
|
+
output.stdout(`zopia validate ${result.kind} ${result.target}: ${errors === 0 ? 'ok' : 'failed'} (${errors} errors, ${warnings} warnings)\n`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function printDiff(result: ZopiaDiffResult, before: string, after: string, output: ZopiaCliOutput): void {
|
|
330
|
+
const glyphs = { added: '+', removed: '-', changed: '~' } as const;
|
|
331
|
+
for (const entry of result.changes) output.stdout(`${' '.repeat(entry.depth)}${glyphs[entry.kind]} ${entry.message}\n`);
|
|
332
|
+
const total = result.changes.length;
|
|
333
|
+
output.stdout(result.identical
|
|
334
|
+
? `zopia diff ${before} ${after}: identical (0 changes)\n`
|
|
335
|
+
: `zopia diff ${before} ${after}: ${total} change${total === 1 ? '' : 's'} (${result.counts.added} added, ${result.counts.removed} removed, ${result.counts.changed} changed)\n`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Repeat engine ③ whenever the spec file changes.
|
|
340
|
+
*
|
|
341
|
+
* The initial run always executes once (surfacing the exact same errors as
|
|
342
|
+
* non-watch generate). Subsequent runs are coalesced: bursts within 50 ms are
|
|
343
|
+
* collapsed, and a change observed while a run is executing is re-run once the
|
|
344
|
+
* active run settles. Warnings land on stderr in deterministic order; run
|
|
345
|
+
* failures print the error and keep watching (spec edits are the natural fix).
|
|
346
|
+
*
|
|
347
|
+
* @param input Spec path to watch (JSON or YAML).
|
|
348
|
+
* @param options Resolved generate options shared by every run.
|
|
349
|
+
* @param output Destinations for generated output and diagnostics.
|
|
350
|
+
* @param signal Optional abort signal that stops watching and settles the returned promise (CLI usage passes none).
|
|
351
|
+
* @returns A promise that never resolves while watching (it resolves only if the watcher stops after a fatal error or abort).
|
|
352
|
+
* @throws {@link ZopiaError} when the watched spec cannot be resolved to a file.
|
|
353
|
+
*/
|
|
354
|
+
export async function runGenerateWatch(input: string, options: Parameters<typeof openApiToApiDocs>[1], output: ZopiaCliOutput = processOutput, signal?: AbortSignal): Promise<never> {
|
|
355
|
+
if (!input || typeof input !== 'string') throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'watch mode requires a spec file path', { at: 'input', hint: 'pass a JSON or YAML spec path to `zopia generate --watch`' });
|
|
356
|
+
const run = async (): Promise<void> => {
|
|
357
|
+
try {
|
|
358
|
+
const result = await openApiToApiDocs(input, options);
|
|
359
|
+
printWarnings(result.warnings, output);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
const typed = asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'watch run failed', { at: input });
|
|
362
|
+
output.stderr(`Error: ${typed.message}`);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
await run();
|
|
366
|
+
let running = false;
|
|
367
|
+
let queued = false;
|
|
368
|
+
const trigger = (): void => {
|
|
369
|
+
if (running) {
|
|
370
|
+
queued = true;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
running = true;
|
|
374
|
+
void run().finally(() => {
|
|
375
|
+
running = false;
|
|
376
|
+
if (queued) {
|
|
377
|
+
queued = false;
|
|
378
|
+
trigger();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
383
|
+
// Watch the parent directory and filter on the spec basename: editors saving
|
|
384
|
+
// atomically (write-temp + rename) replace the inode a naive file watcher is
|
|
385
|
+
// bound to, which would silently end regeneration on Linux.
|
|
386
|
+
const directory = dirname(input);
|
|
387
|
+
const name = basename(input);
|
|
388
|
+
const watcher = watch(directory, (eventType, filename) => {
|
|
389
|
+
if (filename !== null && filename.toString() !== name) return;
|
|
390
|
+
if (debounce) clearTimeout(debounce);
|
|
391
|
+
debounce = setTimeout(trigger, 50);
|
|
392
|
+
});
|
|
393
|
+
watcher.on('error', (error) => {
|
|
394
|
+
output.stderr(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
395
|
+
process.exitCode = 1;
|
|
396
|
+
watcher.close();
|
|
397
|
+
});
|
|
398
|
+
return new Promise<never>((resolve) => {
|
|
399
|
+
if (!signal) return;
|
|
400
|
+
if (signal.aborted) {
|
|
401
|
+
if (debounce) clearTimeout(debounce);
|
|
402
|
+
watcher.close();
|
|
403
|
+
resolve(undefined as never);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
signal.addEventListener('abort', () => {
|
|
407
|
+
if (debounce) clearTimeout(debounce);
|
|
408
|
+
watcher.close();
|
|
409
|
+
resolve(undefined as never);
|
|
410
|
+
}, { once: true });
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Run one zopia CLI command with independently routable output channels.
|
|
416
|
+
*
|
|
417
|
+
* @param argv Command arguments after the executable name.
|
|
418
|
+
* @param output Destinations for generated output and diagnostics.
|
|
419
|
+
* @returns A promise that resolves when generation or reverse conversion finishes.
|
|
420
|
+
* @throws {@link ZopiaError} when arguments, conversion input, or filesystem output is invalid.
|
|
421
|
+
*/
|
|
422
|
+
export async function runCli(argv: string[], output: ZopiaCliOutput = processOutput): Promise<void> {
|
|
423
|
+
if (!Array.isArray(argv) || !argv.every((argument) => typeof argument === 'string')) invalid('CLI arguments must be strings', 'argv', "run 'zopia --help' for command syntax");
|
|
424
|
+
if (!output || typeof output.stdout !== 'function' || typeof output.stderr !== 'function') invalid('CLI output channels are invalid', 'output', 'provide stdout and stderr functions');
|
|
425
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
426
|
+
output.stdout(HELP_TEXT);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const [command, ...commandArguments] = argv;
|
|
431
|
+
if (!command) usage();
|
|
432
|
+
|
|
433
|
+
if (command === 'generate') {
|
|
434
|
+
const parsed = parseGenerate(commandArguments);
|
|
435
|
+
const project = await loadZopiaConfig({ file: parsed.config });
|
|
436
|
+
const generateDefaults = project?.generate;
|
|
437
|
+
const outputDirectory = parsed.outputDirectory ?? generateDefaults?.outDir;
|
|
438
|
+
if (outputDirectory === undefined) invalid('generate requires <spec.json|spec.yaml> and <output-dir>', 'argv', 'provide both input and output paths, or set generate.outDir in zopia.config.ts');
|
|
439
|
+
const options = {
|
|
440
|
+
outDir: outputDirectory,
|
|
441
|
+
mode: parsed.mode ?? generateDefaults?.mode,
|
|
442
|
+
insertComponents: parsed.insertComponents || (generateDefaults?.insertComponents ?? false),
|
|
443
|
+
useComponentAsReference: parsed.useComponentAsReference || (generateDefaults?.useComponentAsReference ?? false),
|
|
444
|
+
// `--no-manifest` is explicit and always wins over config defaults.
|
|
445
|
+
manifest: parsed.manifest && (generateDefaults?.manifest ?? true),
|
|
446
|
+
custom: parsed.custom || (generateDefaults?.custom ?? false),
|
|
447
|
+
preset: parsed.preset ?? generateDefaults?.preset,
|
|
448
|
+
};
|
|
449
|
+
if (parsed.watch) {
|
|
450
|
+
await runGenerateWatch(parsed.input, options, output);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const result = await openApiToApiDocs(parsed.input, options);
|
|
454
|
+
printWarnings(result.warnings, output);
|
|
455
|
+
if (result.trees) output.stdout(`zopia generate ${parsed.input}: ${result.trees.length} preset trees in ${outputDirectory} (${result.trees.map((tree) => tree.directory).join(', ')})\n`);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (command === 'validate') {
|
|
460
|
+
const parsed = parseValidate(commandArguments);
|
|
461
|
+
// `--config` is accepted for grammar parity and future validate defaults (D-19);
|
|
462
|
+
// validation currently has no configurable knobs, so the project file only needs
|
|
463
|
+
// to load successfully when explicitly named.
|
|
464
|
+
await loadZopiaConfig({ file: parsed.config });
|
|
465
|
+
const result = await validateZopia(parsed.input);
|
|
466
|
+
printValidation(result, output);
|
|
467
|
+
const errors = result.diagnostics.filter((issue) => issue.severity === 'error').length;
|
|
468
|
+
if (errors > 0) invalid(`zopia validate failed with ${errors} error${errors === 1 ? '' : 's'}`, parsed.input, 'resolve the reported error diagnostics');
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (command === 'navigate') {
|
|
473
|
+
const parsed = parseNavigate(commandArguments);
|
|
474
|
+
// `--config` is accepted for grammar parity (D-19) — navigate has no
|
|
475
|
+
// configurable knobs; the project file only needs to load when explicitly named.
|
|
476
|
+
await loadZopiaConfig({ file: parsed.config });
|
|
477
|
+
const index = await loadNavigationIndex(parsed.tree);
|
|
478
|
+
if (parsed.toCode !== undefined) {
|
|
479
|
+
for (const location of index.specToLocations(parsed.toCode)) output.stdout(`zopia navigate ${parsed.tree} --to-code ${parsed.toCode}: ${location.file} (${location.label})\n`);
|
|
480
|
+
} else {
|
|
481
|
+
const location = index.treeToSpecLocation(parsed.toSpec as string);
|
|
482
|
+
output.stdout(`zopia navigate ${parsed.tree} --to-spec ${parsed.toSpec as string}: ${location.pointer} (${location.label})\n`);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (command === 'diff') {
|
|
488
|
+
const parsed = parseDiff(commandArguments);
|
|
489
|
+
// `--config` is accepted for grammar parity (D-19) — diff currently has no
|
|
490
|
+
// configurable defaults, the project file only needs to load when explicitly named.
|
|
491
|
+
await loadZopiaConfig({ file: parsed.config });
|
|
492
|
+
const result = await diffOpenApiSpecs(parsed.before, parsed.after);
|
|
493
|
+
printDiff(result, parsed.before, parsed.after, output);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (command === 'reverse') {
|
|
498
|
+
const parsed = parseReverse(commandArguments);
|
|
499
|
+
const project = await loadZopiaConfig({ file: parsed.config });
|
|
500
|
+
const reverseDefaults = project?.reverse;
|
|
501
|
+
const outputFile = parsed.outputFile ?? reverseDefaults?.out;
|
|
502
|
+
const result = await apiDocsToOpenApi(parsed.input, { version: parsed.version ?? reverseDefaults?.version ?? '3.1' });
|
|
503
|
+
printWarnings(result.warnings, output);
|
|
504
|
+
const content = `${JSON.stringify(result.openapi, null, 2)}\n`;
|
|
505
|
+
if (outputFile) {
|
|
506
|
+
try { await writeFile(outputFile, content, 'utf8'); }
|
|
507
|
+
catch (error) { throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to write reverse output', { at: outputFile, hint: 'check the destination path and permissions' }); }
|
|
508
|
+
} else output.stdout(content);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
invalid(`unknown CLI command: ${command}`, command, "use 'zopia generate', 'zopia reverse', 'zopia validate', 'zopia diff', 'zopia navigate', or 'zopia --help'");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Run the CLI entry point and convert failures to deterministic diagnostics and
|
|
517
|
+
* process-compatible exit codes.
|
|
518
|
+
*
|
|
519
|
+
* @param argv Command arguments after the executable name.
|
|
520
|
+
* @param output Destinations for command output and diagnostics.
|
|
521
|
+
* @returns `0` for success, `1` for a typed user error, or `2` for an unexpected failure.
|
|
522
|
+
*/
|
|
523
|
+
export async function runCliEntrypoint(argv: string[], output: ZopiaCliOutput = processOutput): Promise<0 | 1 | 2> {
|
|
524
|
+
try {
|
|
525
|
+
await runCli(argv, output);
|
|
526
|
+
return 0;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error instanceof ZopiaError) {
|
|
529
|
+
output.stderr(error.message);
|
|
530
|
+
if (error.at) output.stderr(`At: ${error.at}`);
|
|
531
|
+
output.stderr(`Hint: ${error.hint}`);
|
|
532
|
+
return 1;
|
|
533
|
+
}
|
|
534
|
+
output.stderr(`Unexpected zopia failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
535
|
+
return 2;
|
|
536
|
+
}
|
|
537
|
+
}
|
package/src/cli.ts
ADDED