@zero.core/cli 1.0.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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +240 -0
- package/dist/cli.d.ts +4 -0
- package/dist/commands/apiGenerateCommand.d.ts +4 -0
- package/dist/commands/clientCommand.d.ts +4 -0
- package/dist/commands/diCommand.d.ts +4 -0
- package/dist/commands/doctorCommand.d.ts +19 -0
- package/dist/commands/newCommand.d.ts +4 -0
- package/dist/commands/openApiCommand.d.ts +4 -0
- package/dist/commands/pubSubCommand.d.ts +35 -0
- package/dist/commands/tasksCommand.d.ts +4 -0
- package/dist/context.d.ts +23 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1469 -0
- package/dist/utils/args.d.ts +17 -0
- package/dist/utils/clientGenerator.d.ts +25 -0
- package/dist/utils/fs.d.ts +20 -0
- package/dist/utils/names.d.ts +8 -0
- package/dist/utils/text.d.ts +6 -0
- package/dist/utils/versionCheck.d.ts +17 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import path8 from "node:path";
|
|
5
|
+
import { register as registerTypeScriptLoader } from "tsx/esm/api";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
// src/context.ts
|
|
9
|
+
import { Writable } from "node:stream";
|
|
10
|
+
var CliResult = class {
|
|
11
|
+
constructor(exitCode = 0) {
|
|
12
|
+
this.exitCode = exitCode;
|
|
13
|
+
}
|
|
14
|
+
exitCode;
|
|
15
|
+
};
|
|
16
|
+
var CliError = class extends Error {
|
|
17
|
+
constructor(message, exitCode = 1) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.exitCode = exitCode;
|
|
20
|
+
this.name = "CliError";
|
|
21
|
+
}
|
|
22
|
+
exitCode;
|
|
23
|
+
};
|
|
24
|
+
var BufferedWritable = class extends Writable {
|
|
25
|
+
chunks = [];
|
|
26
|
+
_write(chunk, _encoding, callback) {
|
|
27
|
+
this.chunks.push(String(chunk));
|
|
28
|
+
callback();
|
|
29
|
+
}
|
|
30
|
+
text() {
|
|
31
|
+
return this.chunks.join("");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// src/utils/args.ts
|
|
36
|
+
var ArgParser = class {
|
|
37
|
+
parse(argv) {
|
|
38
|
+
const positionals = [];
|
|
39
|
+
const options = /* @__PURE__ */ new Map();
|
|
40
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
41
|
+
const arg = argv[index];
|
|
42
|
+
if (!arg.startsWith("--")) {
|
|
43
|
+
positionals.push(arg);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const withoutPrefix = arg.slice(2);
|
|
47
|
+
const equalsIndex = withoutPrefix.indexOf("=");
|
|
48
|
+
if (equalsIndex >= 0) {
|
|
49
|
+
options.set(withoutPrefix.slice(0, equalsIndex), withoutPrefix.slice(equalsIndex + 1));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const next = argv[index + 1];
|
|
53
|
+
if (next && !next.startsWith("--")) {
|
|
54
|
+
options.set(withoutPrefix, next);
|
|
55
|
+
index += 1;
|
|
56
|
+
} else {
|
|
57
|
+
options.set(withoutPrefix, true);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { positionals, options };
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var OptionsReader = class {
|
|
64
|
+
constructor(args) {
|
|
65
|
+
this.args = args;
|
|
66
|
+
}
|
|
67
|
+
args;
|
|
68
|
+
string(name, fallback) {
|
|
69
|
+
const value = this.args.options.get(name);
|
|
70
|
+
if (value === void 0) return fallback;
|
|
71
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
requiredString(name) {
|
|
75
|
+
const value = this.string(name);
|
|
76
|
+
if (!value) throw new CliError(`Missing --${name}.`);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
boolean(name, fallback = false) {
|
|
80
|
+
const value = this.args.options.get(name);
|
|
81
|
+
if (value === void 0) return fallback;
|
|
82
|
+
if (typeof value === "boolean") return value;
|
|
83
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
84
|
+
}
|
|
85
|
+
integer(name, fallback) {
|
|
86
|
+
const value = this.string(name);
|
|
87
|
+
if (value === void 0) return fallback;
|
|
88
|
+
const parsed = Number.parseInt(value, 10);
|
|
89
|
+
if (Number.isNaN(parsed)) throw new CliError(`--${name} must be an integer.`);
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
function hasHelp(argv) {
|
|
94
|
+
return argv.includes("--help") || argv.includes("-h") || argv.length === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/utils/clientGenerator.ts
|
|
98
|
+
var TypeScriptClientGenerator = class {
|
|
99
|
+
constructor(document, options = {}) {
|
|
100
|
+
this.document = document;
|
|
101
|
+
this.options = options;
|
|
102
|
+
}
|
|
103
|
+
document;
|
|
104
|
+
options;
|
|
105
|
+
generate() {
|
|
106
|
+
const clientName = this.options.clientName ?? "ApiClient";
|
|
107
|
+
const optionsName = this.options.optionsName ?? `${clientName}Options`;
|
|
108
|
+
const methods = this.operations().map(({ path: path9, method, operation }) => this.methodSource(path9, method, operation));
|
|
109
|
+
return `export interface ${optionsName} {
|
|
110
|
+
baseUrl: string;
|
|
111
|
+
headers?: HeadersInit;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ${clientName} {
|
|
115
|
+
constructor(private readonly options: ${optionsName}) {}
|
|
116
|
+
|
|
117
|
+
private async request(method: string, path: string, body?: unknown, init?: RequestInit) {
|
|
118
|
+
const response = await fetch(new URL(path, this.options.baseUrl), {
|
|
119
|
+
...init,
|
|
120
|
+
method,
|
|
121
|
+
headers: {
|
|
122
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
123
|
+
...(this.options.headers ?? {}),
|
|
124
|
+
...(init?.headers ?? {})
|
|
125
|
+
},
|
|
126
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok) throw new Error(await response.text());
|
|
129
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
130
|
+
return contentType.includes("application/json") ? response.json() : response.blob();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
${methods.join(",\n")}
|
|
134
|
+
|
|
135
|
+
private buildPath(template: string, pathParams: Record<string, string | number>, query?: Record<string, string | number | boolean | undefined>) {
|
|
136
|
+
let path = template.replace(/\\{([^}]+)\\}/g, (_match, key) => encodeURIComponent(String(pathParams[key])));
|
|
137
|
+
const params = new URLSearchParams();
|
|
138
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
139
|
+
if (value !== undefined) params.set(key, String(value));
|
|
140
|
+
}
|
|
141
|
+
const queryString = params.toString();
|
|
142
|
+
return queryString ? \`\${path}?\${queryString}\` : path;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
operations() {
|
|
148
|
+
return Object.entries(this.document.paths ?? {}).flatMap(
|
|
149
|
+
([path9, pathItem]) => Object.entries(pathItem).filter((entry) => Boolean(entry[1])).map(([method, operation]) => ({ path: path9, method, operation }))
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
methodSource(path9, method, operation) {
|
|
153
|
+
const operationId = safeIdentifier(operation.operationId ?? `${method}_${path9.replace(/[^a-zA-Z0-9]+/g, "_")}`);
|
|
154
|
+
const pathParams = (operation.parameters ?? []).filter((parameter) => parameter.in === "path");
|
|
155
|
+
const queryParams = (operation.parameters ?? []).filter((parameter) => parameter.in === "query");
|
|
156
|
+
const hasBody = Boolean(operation.requestBody);
|
|
157
|
+
const args = [
|
|
158
|
+
pathParams.length ? "pathParams: Record<string, string | number>" : "",
|
|
159
|
+
queryParams.length ? "query?: Record<string, string | number | boolean | undefined>" : "",
|
|
160
|
+
hasBody ? "body?: unknown" : "",
|
|
161
|
+
"init?: RequestInit"
|
|
162
|
+
].filter(Boolean).join(", ");
|
|
163
|
+
return ` ${operationId}(${args}) {
|
|
164
|
+
return this.request(${JSON.stringify(method.toUpperCase())}, this.buildPath(${JSON.stringify(path9)}, ${pathParams.length ? "pathParams" : "{}"}, ${queryParams.length ? "query" : "undefined"}), ${hasBody ? "body" : "undefined"}, init);
|
|
165
|
+
}`;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
function safeIdentifier(value) {
|
|
169
|
+
const cleaned = value.replace(/[^a-zA-Z0-9_$]/g, "_");
|
|
170
|
+
return /^[a-zA-Z_$]/.test(cleaned) ? cleaned : `_${cleaned}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/utils/fs.ts
|
|
174
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
175
|
+
import path from "node:path";
|
|
176
|
+
import { pathToFileURL } from "node:url";
|
|
177
|
+
var WorkspaceFileSystem = class {
|
|
178
|
+
constructor(cwd) {
|
|
179
|
+
this.cwd = cwd;
|
|
180
|
+
}
|
|
181
|
+
cwd;
|
|
182
|
+
resolve(...parts) {
|
|
183
|
+
return path.resolve(this.cwd, ...parts);
|
|
184
|
+
}
|
|
185
|
+
async exists(filePath) {
|
|
186
|
+
try {
|
|
187
|
+
await stat(filePath);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async ensureDir(dir) {
|
|
194
|
+
await mkdir(dir, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
async readText(filePath) {
|
|
197
|
+
return readFile(filePath, "utf8");
|
|
198
|
+
}
|
|
199
|
+
async readJson(filePath) {
|
|
200
|
+
return JSON.parse(await this.readText(filePath));
|
|
201
|
+
}
|
|
202
|
+
async writeText(filePath, content, options = {}) {
|
|
203
|
+
if (!options.force && await this.exists(filePath)) {
|
|
204
|
+
throw new CliError(`File already exists: ${filePath}. Use --force to overwrite.`);
|
|
205
|
+
}
|
|
206
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
207
|
+
await writeFile(filePath, content, "utf8");
|
|
208
|
+
}
|
|
209
|
+
async listFiles(dir, options = {}) {
|
|
210
|
+
const fs = await import("node:fs/promises");
|
|
211
|
+
if (!await this.exists(dir)) return [];
|
|
212
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
213
|
+
const files = await Promise.all(entries.map(async (entry) => {
|
|
214
|
+
const fullPath = path.join(dir, entry.name);
|
|
215
|
+
if (entry.isDirectory()) return options.recursive === false ? [] : this.listFiles(fullPath, options);
|
|
216
|
+
if (!entry.isFile()) return [];
|
|
217
|
+
if (options.pattern && !options.pattern.test(entry.name)) return [];
|
|
218
|
+
return [fullPath];
|
|
219
|
+
}));
|
|
220
|
+
return files.flat().sort();
|
|
221
|
+
}
|
|
222
|
+
toFileUrl(filePath) {
|
|
223
|
+
return pathToFileURL(filePath).href;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// src/commands/clientCommand.ts
|
|
228
|
+
async function runApiClientCommand(positionals, options, context) {
|
|
229
|
+
const [action] = positionals;
|
|
230
|
+
if (action !== "generate") throw new CliError("Usage: zerocore api client generate --input openapi.json --output src/client.ts [--name ZeroCoreClient]");
|
|
231
|
+
const reader = new OptionsReader({ positionals, options });
|
|
232
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
233
|
+
const input = fs.resolve(reader.requiredString("input"));
|
|
234
|
+
const output = fs.resolve(reader.requiredString("output"));
|
|
235
|
+
const clientName = reader.string("name", "ZeroCoreClient") ?? "ZeroCoreClient";
|
|
236
|
+
const optionsName = reader.string("options-name", `${clientName}Options`) ?? `${clientName}Options`;
|
|
237
|
+
const openApi = await fs.readJson(input);
|
|
238
|
+
const source = new TypeScriptClientGenerator(openApi, { clientName, optionsName }).generate();
|
|
239
|
+
await fs.writeText(output, source, { force: reader.boolean("force", true) });
|
|
240
|
+
context.stdout.write(`Generated TypeScript client at ${output}
|
|
241
|
+
`);
|
|
242
|
+
return new CliResult(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/commands/diCommand.ts
|
|
246
|
+
import path2 from "node:path";
|
|
247
|
+
|
|
248
|
+
// src/utils/text.ts
|
|
249
|
+
var TablePrinter = class {
|
|
250
|
+
print(stream, rows) {
|
|
251
|
+
if (!rows.length) return;
|
|
252
|
+
const widths = rows[0].map((_, index) => Math.max(...rows.map((row) => row[index]?.length ?? 0)));
|
|
253
|
+
for (const row of rows) {
|
|
254
|
+
stream.write(`${row.map((cell, index) => cell.padEnd(widths[index])).join(" ")}
|
|
255
|
+
`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
function jsonLine(value) {
|
|
260
|
+
return `${JSON.stringify(value, null, 2)}
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/commands/diCommand.ts
|
|
265
|
+
async function runApiDiCommand(positionals, options, context) {
|
|
266
|
+
const [action] = positionals;
|
|
267
|
+
if (!["graph", "validate"].includes(action ?? "")) throw new CliError("Usage: zerocore api di <graph|validate> [--dir src] [--json]");
|
|
268
|
+
const reader = new OptionsReader({ positionals, options });
|
|
269
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
270
|
+
const graph = await new DiScanner(fs, fs.resolve(reader.string("dir", "src") ?? "src")).scan();
|
|
271
|
+
if (action === "graph") {
|
|
272
|
+
if (reader.boolean("json")) context.stdout.write(jsonLine(graph));
|
|
273
|
+
else new TablePrinter().print(context.stdout, [["Service", "Lifetime", "Dependencies"], ...graph.services.map((service) => [
|
|
274
|
+
service.name,
|
|
275
|
+
service.lifetime,
|
|
276
|
+
service.dependencies.join(", ") || "-"
|
|
277
|
+
])]);
|
|
278
|
+
return new CliResult(0);
|
|
279
|
+
}
|
|
280
|
+
const report = new DiValidator(graph).validate();
|
|
281
|
+
context.stdout.write(reader.boolean("json") ? jsonLine(report) : formatDiReport(report));
|
|
282
|
+
return new CliResult(report.errors.length ? 1 : 0);
|
|
283
|
+
}
|
|
284
|
+
var DiScanner = class {
|
|
285
|
+
constructor(fs, root) {
|
|
286
|
+
this.fs = fs;
|
|
287
|
+
this.root = root;
|
|
288
|
+
}
|
|
289
|
+
fs;
|
|
290
|
+
root;
|
|
291
|
+
async scan() {
|
|
292
|
+
const files = await this.fs.listFiles(this.root, { pattern: /\.tsx?$/ });
|
|
293
|
+
const services = [];
|
|
294
|
+
for (const file of files) {
|
|
295
|
+
const source = await this.fs.readText(file);
|
|
296
|
+
const classes = [...source.matchAll(/export\s+class\s+([A-Za-z0-9_]+)/g)];
|
|
297
|
+
for (const classMatch of classes) {
|
|
298
|
+
const name = classMatch[1];
|
|
299
|
+
const before = source.slice(Math.max(0, classMatch.index - 500), classMatch.index);
|
|
300
|
+
const injectable = before.match(/@Injectable\s*\(([^)]*)\)/);
|
|
301
|
+
const constructorSource = constructorFor(source.slice(classMatch.index));
|
|
302
|
+
const injectDependencies = [...constructorSource.matchAll(/@Inject\s*\(\s*([A-Za-z0-9_."']+)\s*\)/g)].map((match) => cleanToken(match[1]));
|
|
303
|
+
const decoratorDependencies = injectable ? splitTokens(injectable[1]) : [];
|
|
304
|
+
const staticDependencies = staticInjectDependencies(source, name);
|
|
305
|
+
const dependencies = unique([...decoratorDependencies, ...injectDependencies, ...staticDependencies]);
|
|
306
|
+
if (injectable || dependencies.length || name.endsWith("Service") || name.endsWith("Controller")) {
|
|
307
|
+
services.push({
|
|
308
|
+
name,
|
|
309
|
+
file: path2.relative(this.fs.cwd, file),
|
|
310
|
+
lifetime: injectable ? "injectable" : "implicit",
|
|
311
|
+
dependencies,
|
|
312
|
+
hasConstructorParams: constructorSource.trim().length > 0
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { services: services.sort((left, right) => left.name.localeCompare(right.name)) };
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
var DiValidator = class {
|
|
321
|
+
constructor(graph) {
|
|
322
|
+
this.graph = graph;
|
|
323
|
+
}
|
|
324
|
+
graph;
|
|
325
|
+
validate() {
|
|
326
|
+
const errors = [];
|
|
327
|
+
const warnings = [];
|
|
328
|
+
const known = new Set(this.graph.services.map((service) => service.name));
|
|
329
|
+
for (const service of this.graph.services) {
|
|
330
|
+
if (service.hasConstructorParams && !service.dependencies.length) {
|
|
331
|
+
errors.push(`${service.name} has constructor parameters but no explicit DI metadata.`);
|
|
332
|
+
}
|
|
333
|
+
for (const dependency of service.dependencies) {
|
|
334
|
+
if (!known.has(dependency) && /^[A-Z]/.test(dependency)) {
|
|
335
|
+
warnings.push(`${service.name} depends on ${dependency}, but no matching service class was found in the scan.`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const cycle of findCycles(this.graph)) {
|
|
340
|
+
errors.push(`Circular dependency: ${cycle.join(" -> ")}`);
|
|
341
|
+
}
|
|
342
|
+
return { errors, warnings };
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function constructorFor(source) {
|
|
346
|
+
const match = source.match(/constructor\s*\(([\s\S]*?)\)\s*\{/);
|
|
347
|
+
return match?.[1] ?? "";
|
|
348
|
+
}
|
|
349
|
+
function staticInjectDependencies(source, className) {
|
|
350
|
+
const classBlock = source.slice(source.indexOf(`class ${className}`));
|
|
351
|
+
const match = classBlock.match(/static\s+(?:inject|dependencies)\s*=\s*\[([^\]]*)\]/);
|
|
352
|
+
return match ? splitTokens(match[1]) : [];
|
|
353
|
+
}
|
|
354
|
+
function splitTokens(value) {
|
|
355
|
+
return value.split(",").map((token) => cleanToken(token)).filter(Boolean);
|
|
356
|
+
}
|
|
357
|
+
function cleanToken(token) {
|
|
358
|
+
return token.trim().replace(/^["']|["']$/g, "");
|
|
359
|
+
}
|
|
360
|
+
function unique(values) {
|
|
361
|
+
return [...new Set(values.filter(Boolean))];
|
|
362
|
+
}
|
|
363
|
+
function findCycles(graph) {
|
|
364
|
+
const dependencies = new Map(graph.services.map((service) => [service.name, service.dependencies]));
|
|
365
|
+
const cycles = [];
|
|
366
|
+
const visit = (node, stack) => {
|
|
367
|
+
if (stack.includes(node)) {
|
|
368
|
+
cycles.push([...stack.slice(stack.indexOf(node)), node]);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
for (const dependency of dependencies.get(node) ?? []) {
|
|
372
|
+
if (dependencies.has(dependency)) visit(dependency, [...stack, node]);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
for (const service of dependencies.keys()) visit(service, []);
|
|
376
|
+
return dedupeCycles(cycles);
|
|
377
|
+
}
|
|
378
|
+
function dedupeCycles(cycles) {
|
|
379
|
+
const seen = /* @__PURE__ */ new Set();
|
|
380
|
+
return cycles.filter((cycle) => {
|
|
381
|
+
const key = [...cycle].sort().join("|");
|
|
382
|
+
if (seen.has(key)) return false;
|
|
383
|
+
seen.add(key);
|
|
384
|
+
return true;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function formatDiReport(report) {
|
|
388
|
+
const lines = [
|
|
389
|
+
...report.errors.map((entry) => `error: ${entry}`),
|
|
390
|
+
...report.warnings.map((entry) => `warning: ${entry}`),
|
|
391
|
+
report.errors.length ? "DI validation failed." : "DI validation passed."
|
|
392
|
+
];
|
|
393
|
+
return `${lines.join("\n")}
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/commands/doctorCommand.ts
|
|
398
|
+
import path3 from "node:path";
|
|
399
|
+
async function runDoctorCommand(positionals, options, context) {
|
|
400
|
+
const scope = positionals[0] ?? "api";
|
|
401
|
+
if (scope !== "api") throw new CliError("Only `zerocore doctor api` is implemented in this version.");
|
|
402
|
+
const reader = new OptionsReader({ positionals, options });
|
|
403
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
404
|
+
const report = await new ApiDoctor(fs, fs.resolve(reader.string("dir", ".") ?? ".")).run();
|
|
405
|
+
context.stdout.write(reader.boolean("json") ? jsonLine(report) : formatDoctorReport(report));
|
|
406
|
+
return new CliResult(report.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0);
|
|
407
|
+
}
|
|
408
|
+
var ApiDoctor = class {
|
|
409
|
+
constructor(fs, root) {
|
|
410
|
+
this.fs = fs;
|
|
411
|
+
this.root = root;
|
|
412
|
+
}
|
|
413
|
+
fs;
|
|
414
|
+
root;
|
|
415
|
+
async run() {
|
|
416
|
+
const diagnostics = [];
|
|
417
|
+
const packagePath = path3.join(this.root, "package.json");
|
|
418
|
+
const tsconfigPath = path3.join(this.root, "tsconfig.json");
|
|
419
|
+
if (!await this.fs.exists(packagePath)) diagnostics.push({ severity: "warning", message: "package.json not found." });
|
|
420
|
+
if (!await this.fs.exists(tsconfigPath)) diagnostics.push({ severity: "warning", message: "tsconfig.json not found." });
|
|
421
|
+
const sourceRoot = await this.sourceRoot();
|
|
422
|
+
const controllerFiles = await this.fs.listFiles(path3.join(sourceRoot, "controllers"), { pattern: /Controller\.tsx?$/ });
|
|
423
|
+
const modelFiles = await this.fs.listFiles(path3.join(sourceRoot, "models"), { pattern: /\.tsx?$/ });
|
|
424
|
+
const serviceFiles = await this.fs.listFiles(path3.join(sourceRoot, "services"), { pattern: /Service\.tsx?$/ });
|
|
425
|
+
if (!controllerFiles.length) diagnostics.push({ severity: "warning", message: "No controller files found under src/controllers." });
|
|
426
|
+
for (const file of controllerFiles) {
|
|
427
|
+
const source = await this.fs.readText(file);
|
|
428
|
+
if (!source.includes("@ApiController")) diagnostics.push({ severity: "error", file, message: "Controller file is missing @ApiController." });
|
|
429
|
+
if (!source.includes("@Route")) diagnostics.push({ severity: "error", file, message: "Controller file is missing @Route." });
|
|
430
|
+
if (!source.includes("extends ControllerBase") && !source.includes("extends BaseController")) {
|
|
431
|
+
diagnostics.push({ severity: "warning", file, message: "Controller should extend ControllerBase or a project BaseController." });
|
|
432
|
+
}
|
|
433
|
+
for (const method of source.matchAll(/@(HttpGet|HttpPost|HttpPut|HttpPatch|HttpDelete|Get|Post|Put|Patch|Delete)\b[\s\S]*?\n\s*(?:async\s+)?([A-Za-z0-9_]+)\s*\(/g)) {
|
|
434
|
+
const beforeMethod = source.slice(Math.max(0, method.index - 400), method.index);
|
|
435
|
+
const routeBlock = method[0];
|
|
436
|
+
if (!beforeMethod.includes("@ProducesResponseType") && !beforeMethod.includes("@Produces(") && !routeBlock.includes("@ProducesResponseType") && !routeBlock.includes("@Produces(")) {
|
|
437
|
+
diagnostics.push({ severity: "warning", file, message: `Route method ${method[2]} has no explicit response metadata.` });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (const file of modelFiles) {
|
|
442
|
+
const source = await this.fs.readText(file);
|
|
443
|
+
if (/\bclass\s+[A-Za-z0-9_]+/.test(source) && !source.includes("@ApiModel") && !source.includes("@DataContract")) {
|
|
444
|
+
diagnostics.push({ severity: "warning", file, message: "Model class file has no @ApiModel or @DataContract metadata." });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
diagnostics,
|
|
449
|
+
stats: {
|
|
450
|
+
controllers: controllerFiles.length,
|
|
451
|
+
models: modelFiles.length,
|
|
452
|
+
services: serviceFiles.length
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async sourceRoot() {
|
|
457
|
+
const src = path3.join(this.root, "src");
|
|
458
|
+
return await this.fs.exists(src) ? src : this.root;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
function formatDoctorReport(report) {
|
|
462
|
+
const lines = [
|
|
463
|
+
`API doctor: ${report.stats.controllers} controllers, ${report.stats.models} model files, ${report.stats.services} services`
|
|
464
|
+
];
|
|
465
|
+
for (const diagnostic of report.diagnostics) {
|
|
466
|
+
const file = diagnostic.file ? ` ${diagnostic.file}` : "";
|
|
467
|
+
lines.push(`${diagnostic.severity}:${file} ${diagnostic.message}`);
|
|
468
|
+
}
|
|
469
|
+
if (!report.diagnostics.length) lines.push("No issues found.");
|
|
470
|
+
return `${lines.join("\n")}
|
|
471
|
+
`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/commands/apiGenerateCommand.ts
|
|
475
|
+
import path4 from "node:path";
|
|
476
|
+
|
|
477
|
+
// src/utils/names.ts
|
|
478
|
+
var NameInflector = class {
|
|
479
|
+
classify(value) {
|
|
480
|
+
return this.words(value).map((word) => word.slice(0, 1).toUpperCase() + word.slice(1)).join("");
|
|
481
|
+
}
|
|
482
|
+
camel(value) {
|
|
483
|
+
const classified = this.classify(value);
|
|
484
|
+
return classified.slice(0, 1).toLowerCase() + classified.slice(1);
|
|
485
|
+
}
|
|
486
|
+
kebab(value) {
|
|
487
|
+
return this.words(value).join("-");
|
|
488
|
+
}
|
|
489
|
+
plural(value) {
|
|
490
|
+
const classified = this.classify(value);
|
|
491
|
+
if (classified.endsWith("y")) return `${classified.slice(0, -1)}ies`;
|
|
492
|
+
if (/(s|x|z|ch|sh)$/i.test(classified)) return `${classified}es`;
|
|
493
|
+
return `${classified}s`;
|
|
494
|
+
}
|
|
495
|
+
words(value) {
|
|
496
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).map((word) => word.trim().toLowerCase()).filter(Boolean);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// src/commands/apiGenerateCommand.ts
|
|
501
|
+
async function runApiGenerateCommand(positionals, options, context) {
|
|
502
|
+
const [kind, rawName] = positionals;
|
|
503
|
+
if (!kind || !rawName) throw new CliError("Usage: zerocore api generate <resource|controller|service|dto|test|subscriber> <Name> [--dir src] [--force]");
|
|
504
|
+
const reader = new OptionsReader({ positionals, options });
|
|
505
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
506
|
+
const generator = new ApiGenerator(fs, reader.string("dir", "src") ?? "src", reader.boolean("force"));
|
|
507
|
+
const name = new NameInflector().classify(rawName);
|
|
508
|
+
if (kind === "controller") await generator.controller(name);
|
|
509
|
+
else if (kind === "service") await generator.service(name);
|
|
510
|
+
else if (kind === "dto") await generator.dto(name);
|
|
511
|
+
else if (kind === "test") await generator.test(name);
|
|
512
|
+
else if (kind === "subscriber") await generator.subscriber(name, {
|
|
513
|
+
topic: reader.string("topic") ?? `${new NameInflector().kebab(name)}.created`,
|
|
514
|
+
provider: reader.string("provider"),
|
|
515
|
+
retries: reader.integer("retries"),
|
|
516
|
+
deadLetterTopic: reader.string("dead-letter-topic")
|
|
517
|
+
});
|
|
518
|
+
else if (kind === "resource") await generator.resource(name);
|
|
519
|
+
else throw new CliError(`Unsupported API generator: ${kind}`);
|
|
520
|
+
context.stdout.write(`Generated ${kind} ${name}
|
|
521
|
+
`);
|
|
522
|
+
return new CliResult(0);
|
|
523
|
+
}
|
|
524
|
+
var ApiGenerator = class {
|
|
525
|
+
constructor(fs, sourceDir, force) {
|
|
526
|
+
this.fs = fs;
|
|
527
|
+
this.sourceDir = sourceDir;
|
|
528
|
+
this.force = force;
|
|
529
|
+
}
|
|
530
|
+
fs;
|
|
531
|
+
sourceDir;
|
|
532
|
+
force;
|
|
533
|
+
names = new NameInflector();
|
|
534
|
+
async resource(name) {
|
|
535
|
+
await this.dtoModels(name);
|
|
536
|
+
await this.service(name);
|
|
537
|
+
await this.controller(name);
|
|
538
|
+
await this.test(this.names.plural(name));
|
|
539
|
+
}
|
|
540
|
+
async controller(name) {
|
|
541
|
+
const baseName = name.endsWith("Controller") ? name.slice(0, -"Controller".length) : name;
|
|
542
|
+
const plural = this.names.plural(baseName);
|
|
543
|
+
await this.write(path4.join(this.sourceDir, "controllers", `${plural}Controller.ts`), controllerTemplate(baseName, plural));
|
|
544
|
+
}
|
|
545
|
+
async service(name) {
|
|
546
|
+
const baseName = name.endsWith("Service") ? name.slice(0, -"Service".length) : name;
|
|
547
|
+
await this.write(path4.join(this.sourceDir, "services", `${baseName}Service.ts`), serviceTemplate(baseName));
|
|
548
|
+
}
|
|
549
|
+
async dto(name) {
|
|
550
|
+
await this.write(path4.join(this.sourceDir, "models", `${name}.ts`), dtoTemplate(name));
|
|
551
|
+
}
|
|
552
|
+
async test(name) {
|
|
553
|
+
const baseName = name.endsWith("Controller") ? name.slice(0, -"Controller".length) : name;
|
|
554
|
+
const plural = this.names.plural(baseName);
|
|
555
|
+
await this.write(path4.join("test", `${plural}Controller.test.ts`), testTemplate(plural));
|
|
556
|
+
}
|
|
557
|
+
async subscriber(name, options) {
|
|
558
|
+
const baseName = name.endsWith("Subscriber") ? name.slice(0, -"Subscriber".length) : name;
|
|
559
|
+
await this.write(path4.join(this.sourceDir, "events", `${baseName}Subscriber.ts`), subscriberTemplate(baseName, options));
|
|
560
|
+
}
|
|
561
|
+
async dtoModels(name) {
|
|
562
|
+
await this.write(path4.join(this.sourceDir, "models", `${name}Models.ts`), resourceModelsTemplate(name));
|
|
563
|
+
}
|
|
564
|
+
async write(relativePath, content) {
|
|
565
|
+
await this.fs.writeText(this.fs.resolve(relativePath), content, { force: this.force });
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
function resourceModelsTemplate(name) {
|
|
569
|
+
return `import { ApiModel, ApiProperty } from "@zero.core/api-framework";
|
|
570
|
+
|
|
571
|
+
@ApiModel()
|
|
572
|
+
export class ${name}Dto {
|
|
573
|
+
@ApiProperty({ type: String })
|
|
574
|
+
id = "";
|
|
575
|
+
|
|
576
|
+
@ApiProperty({ type: String })
|
|
577
|
+
name = "";
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
@ApiModel()
|
|
581
|
+
export class Create${name}Request {
|
|
582
|
+
@ApiProperty({ type: String })
|
|
583
|
+
name = "";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
@ApiModel()
|
|
587
|
+
export class Update${name}Request {
|
|
588
|
+
@ApiProperty({ type: String, required: false })
|
|
589
|
+
name?: string;
|
|
590
|
+
}
|
|
591
|
+
`;
|
|
592
|
+
}
|
|
593
|
+
function dtoTemplate(name) {
|
|
594
|
+
return `import { ApiModel, ApiProperty } from "@zero.core/api-framework";
|
|
595
|
+
|
|
596
|
+
@ApiModel()
|
|
597
|
+
export class ${name} {
|
|
598
|
+
@ApiProperty({ type: String })
|
|
599
|
+
id = "";
|
|
600
|
+
}
|
|
601
|
+
`;
|
|
602
|
+
}
|
|
603
|
+
function serviceTemplate(name) {
|
|
604
|
+
const variable = new NameInflector().camel(name);
|
|
605
|
+
return `import { randomUUID } from "node:crypto";
|
|
606
|
+
import { ApiError, Injectable } from "@zero.core/api-framework";
|
|
607
|
+
import { Create${name}Request, ${name}Dto, Update${name}Request } from "../models/${name}Models";
|
|
608
|
+
|
|
609
|
+
@Injectable()
|
|
610
|
+
export class ${name}Service {
|
|
611
|
+
private readonly ${variable}s = new Map<string, ${name}Dto>();
|
|
612
|
+
|
|
613
|
+
list(): ${name}Dto[] {
|
|
614
|
+
return [...this.${variable}s.values()];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
get(id: string): ${name}Dto {
|
|
618
|
+
const item = this.${variable}s.get(id);
|
|
619
|
+
if (!item) throw ApiError.notFound("${name} not found.", "${variable}_not_found");
|
|
620
|
+
return item;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
create(request: Create${name}Request): ${name}Dto {
|
|
624
|
+
const item = Object.assign(new ${name}Dto(), {
|
|
625
|
+
id: randomUUID(),
|
|
626
|
+
name: request.name
|
|
627
|
+
});
|
|
628
|
+
this.${variable}s.set(item.id, item);
|
|
629
|
+
return item;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
update(id: string, request: Update${name}Request): ${name}Dto {
|
|
633
|
+
const item = this.get(id);
|
|
634
|
+
if (request.name !== undefined) item.name = request.name;
|
|
635
|
+
return item;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
delete(id: string): void {
|
|
639
|
+
this.${variable}s.delete(id);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
`;
|
|
643
|
+
}
|
|
644
|
+
function controllerTemplate(name, plural) {
|
|
645
|
+
const variable = new NameInflector().camel(name);
|
|
646
|
+
return `import {
|
|
647
|
+
ApiController,
|
|
648
|
+
ControllerBase,
|
|
649
|
+
FromBody,
|
|
650
|
+
FromRoute,
|
|
651
|
+
HttpDelete,
|
|
652
|
+
HttpGet,
|
|
653
|
+
HttpPatch,
|
|
654
|
+
HttpPost,
|
|
655
|
+
Inject,
|
|
656
|
+
ProducesResponseType,
|
|
657
|
+
Route,
|
|
658
|
+
SuccessResponse
|
|
659
|
+
} from "@zero.core/api-framework";
|
|
660
|
+
import { Create${name}Request, ${name}Dto, Update${name}Request } from "../models/${name}Models";
|
|
661
|
+
import { ${name}Service } from "../services/${name}Service";
|
|
662
|
+
|
|
663
|
+
@ApiController({ tags: ["${plural}"] })
|
|
664
|
+
@Route("/api/${new NameInflector().kebab(plural)}")
|
|
665
|
+
export class ${plural}Controller extends ControllerBase {
|
|
666
|
+
constructor(@Inject(${name}Service) private readonly ${variable}s: ${name}Service) {
|
|
667
|
+
super();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
@HttpGet("/")
|
|
671
|
+
@ProducesResponseType(${name}Dto, { isArray: true })
|
|
672
|
+
list(): ${name}Dto[] {
|
|
673
|
+
return this.${variable}s.list();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
@HttpGet("/:id")
|
|
677
|
+
@ProducesResponseType(${name}Dto)
|
|
678
|
+
get(@FromRoute("id") id: string): ${name}Dto {
|
|
679
|
+
return this.${variable}s.get(id);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
@HttpPost("/")
|
|
683
|
+
@SuccessResponse(201)
|
|
684
|
+
@ProducesResponseType(${name}Dto, 201)
|
|
685
|
+
create(@FromBody(Create${name}Request, { transform: true, stripUnknown: true }) request: Create${name}Request): ${name}Dto {
|
|
686
|
+
return this.${variable}s.create(request);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
@HttpPatch("/:id")
|
|
690
|
+
@ProducesResponseType(${name}Dto)
|
|
691
|
+
update(
|
|
692
|
+
@FromRoute("id") id: string,
|
|
693
|
+
@FromBody(Update${name}Request, { transform: true, stripUnknown: true }) request: Update${name}Request
|
|
694
|
+
): ${name}Dto {
|
|
695
|
+
return this.${variable}s.update(id, request);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
@HttpDelete("/:id")
|
|
699
|
+
@SuccessResponse(204)
|
|
700
|
+
delete(@FromRoute("id") id: string): void {
|
|
701
|
+
this.${variable}s.delete(id);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
`;
|
|
705
|
+
}
|
|
706
|
+
function testTemplate(plural) {
|
|
707
|
+
return `import assert from "node:assert/strict";
|
|
708
|
+
import test from "node:test";
|
|
709
|
+
|
|
710
|
+
test("${plural}Controller exposes generated resource routes", () => {
|
|
711
|
+
assert.equal("${plural}Controller".endsWith("Controller"), true);
|
|
712
|
+
});
|
|
713
|
+
`;
|
|
714
|
+
}
|
|
715
|
+
function subscriberTemplate(name, options) {
|
|
716
|
+
const methodName = `handle${name}`;
|
|
717
|
+
return `import {
|
|
718
|
+
Injectable,
|
|
719
|
+
MessageBody,
|
|
720
|
+
MessageEnvelope,
|
|
721
|
+
Subscribe
|
|
722
|
+
} from "@zero.core/api-framework";
|
|
723
|
+
import type { PubSubEnvelope } from "@zero.core/api-framework";
|
|
724
|
+
|
|
725
|
+
export interface ${name}Message {
|
|
726
|
+
id: string;
|
|
727
|
+
occurredAt: string;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
@Injectable()
|
|
731
|
+
export class ${name}Subscriber {
|
|
732
|
+
@Subscribe("${options.topic}", ${subscriberOptions(options)})
|
|
733
|
+
${methodName}(
|
|
734
|
+
@MessageBody() message: ${name}Message,
|
|
735
|
+
@MessageEnvelope() envelope: PubSubEnvelope<${name}Message>
|
|
736
|
+
): void {
|
|
737
|
+
void message;
|
|
738
|
+
void envelope;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
`;
|
|
742
|
+
}
|
|
743
|
+
function subscriberOptions(options) {
|
|
744
|
+
const entries = [
|
|
745
|
+
`name: "${new NameInflector().kebab(options.topic)}-subscriber"`,
|
|
746
|
+
options.provider ? `provider: "${options.provider}"` : void 0,
|
|
747
|
+
options.retries !== void 0 ? `retries: ${options.retries}` : void 0,
|
|
748
|
+
options.deadLetterTopic ? `deadLetterTopic: "${options.deadLetterTopic}"` : void 0
|
|
749
|
+
].filter(Boolean);
|
|
750
|
+
return `{
|
|
751
|
+
${entries.join(",\n ")}
|
|
752
|
+
}`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/commands/newCommand.ts
|
|
756
|
+
import path5 from "node:path";
|
|
757
|
+
async function runNewCommand(positionals, options, context) {
|
|
758
|
+
const [kind, name] = positionals;
|
|
759
|
+
if (!kind || !name) throw new CliError("Usage: zerocore new <api|app|package> <name> [--dir <path>] [--force]");
|
|
760
|
+
if (!["api", "app", "package"].includes(kind)) throw new CliError(`Unsupported project kind: ${kind}`);
|
|
761
|
+
const reader = new OptionsReader({ positionals, options });
|
|
762
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
763
|
+
const targetRoot = fs.resolve(reader.string("dir", ".") ?? ".", new NameInflector().kebab(name));
|
|
764
|
+
const force = reader.boolean("force");
|
|
765
|
+
if (kind === "api") await new ProjectScaffolder(fs, targetRoot, name, force).api();
|
|
766
|
+
if (kind === "app") await new ProjectScaffolder(fs, targetRoot, name, force).app();
|
|
767
|
+
if (kind === "package") await new ProjectScaffolder(fs, targetRoot, name, force).package();
|
|
768
|
+
context.stdout.write(`Created ${kind} project at ${path5.relative(context.cwd, targetRoot)}
|
|
769
|
+
`);
|
|
770
|
+
return new CliResult(0);
|
|
771
|
+
}
|
|
772
|
+
var ProjectScaffolder = class {
|
|
773
|
+
constructor(fs, root, name, force) {
|
|
774
|
+
this.fs = fs;
|
|
775
|
+
this.root = root;
|
|
776
|
+
this.name = name;
|
|
777
|
+
this.force = force;
|
|
778
|
+
}
|
|
779
|
+
fs;
|
|
780
|
+
root;
|
|
781
|
+
name;
|
|
782
|
+
force;
|
|
783
|
+
names = new NameInflector();
|
|
784
|
+
async api() {
|
|
785
|
+
const packageName = this.names.kebab(this.name);
|
|
786
|
+
await this.write("package.json", JSON.stringify({
|
|
787
|
+
name: packageName,
|
|
788
|
+
version: "0.1.0",
|
|
789
|
+
private: true,
|
|
790
|
+
type: "module",
|
|
791
|
+
scripts: {
|
|
792
|
+
dev: "tsx src/index.ts",
|
|
793
|
+
typecheck: "tsc --noEmit -p tsconfig.json"
|
|
794
|
+
},
|
|
795
|
+
dependencies: {
|
|
796
|
+
"@zero.core/api-framework": "1.0.0",
|
|
797
|
+
express: "^5.1.0"
|
|
798
|
+
},
|
|
799
|
+
devDependencies: {
|
|
800
|
+
"@types/express": "^5.0.0",
|
|
801
|
+
tsx: "^4.19.0",
|
|
802
|
+
typescript: "^5.7.0"
|
|
803
|
+
}
|
|
804
|
+
}, null, 2));
|
|
805
|
+
await this.write("tsconfig.json", tsconfig());
|
|
806
|
+
await this.write("README.md", `# ${this.names.classify(this.name)} API
|
|
807
|
+
|
|
808
|
+
Generated with ZeroCore CLI.
|
|
809
|
+
`);
|
|
810
|
+
await this.write("src/index.ts", apiIndexTemplate());
|
|
811
|
+
await this.write("src/models/HealthDto.ts", healthDtoTemplate());
|
|
812
|
+
await this.write("src/services/HealthService.ts", healthServiceTemplate());
|
|
813
|
+
await this.write("src/controllers/HealthController.ts", healthControllerTemplate());
|
|
814
|
+
}
|
|
815
|
+
async app() {
|
|
816
|
+
const packageName = this.names.kebab(this.name);
|
|
817
|
+
await this.write("package.json", JSON.stringify({
|
|
818
|
+
name: packageName,
|
|
819
|
+
version: "0.1.0",
|
|
820
|
+
private: true,
|
|
821
|
+
type: "module",
|
|
822
|
+
scripts: {
|
|
823
|
+
dev: "tsx src/index.ts",
|
|
824
|
+
typecheck: "tsc --noEmit -p tsconfig.json"
|
|
825
|
+
},
|
|
826
|
+
devDependencies: {
|
|
827
|
+
tsx: "^4.19.0",
|
|
828
|
+
typescript: "^5.7.0"
|
|
829
|
+
}
|
|
830
|
+
}, null, 2));
|
|
831
|
+
await this.write("tsconfig.json", tsconfig());
|
|
832
|
+
await this.write("README.md", `# ${this.names.classify(this.name)}
|
|
833
|
+
|
|
834
|
+
Generated with ZeroCore CLI.
|
|
835
|
+
`);
|
|
836
|
+
await this.write("src/index.ts", `export const appName = "${packageName}";
|
|
837
|
+
|
|
838
|
+
console.log(\`Started \${appName}\`);
|
|
839
|
+
`);
|
|
840
|
+
}
|
|
841
|
+
async package() {
|
|
842
|
+
const packageName = this.names.kebab(this.name);
|
|
843
|
+
await this.write("package.json", JSON.stringify({
|
|
844
|
+
name: packageName,
|
|
845
|
+
version: "0.1.0",
|
|
846
|
+
private: true,
|
|
847
|
+
type: "module",
|
|
848
|
+
exports: {
|
|
849
|
+
".": "./src/index.ts"
|
|
850
|
+
},
|
|
851
|
+
scripts: {
|
|
852
|
+
typecheck: "tsc --noEmit -p tsconfig.json"
|
|
853
|
+
},
|
|
854
|
+
devDependencies: {
|
|
855
|
+
typescript: "^5.7.0"
|
|
856
|
+
}
|
|
857
|
+
}, null, 2));
|
|
858
|
+
await this.write("tsconfig.json", tsconfig());
|
|
859
|
+
await this.write("README.md", `# ${this.names.classify(this.name)}
|
|
860
|
+
|
|
861
|
+
Generated with ZeroCore CLI.
|
|
862
|
+
`);
|
|
863
|
+
await this.write("src/index.ts", `export const packageName = "${packageName}";
|
|
864
|
+
`);
|
|
865
|
+
}
|
|
866
|
+
async write(relativePath, content) {
|
|
867
|
+
await this.fs.writeText(path5.join(this.root, relativePath), content, { force: this.force });
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
function tsconfig() {
|
|
871
|
+
return `${JSON.stringify({
|
|
872
|
+
compilerOptions: {
|
|
873
|
+
target: "ES2022",
|
|
874
|
+
module: "ESNext",
|
|
875
|
+
moduleResolution: "Bundler",
|
|
876
|
+
strict: true,
|
|
877
|
+
esModuleInterop: true,
|
|
878
|
+
skipLibCheck: true,
|
|
879
|
+
forceConsistentCasingInFileNames: true,
|
|
880
|
+
noEmit: true
|
|
881
|
+
},
|
|
882
|
+
include: ["src", "test"]
|
|
883
|
+
}, null, 2)}
|
|
884
|
+
`;
|
|
885
|
+
}
|
|
886
|
+
function apiIndexTemplate() {
|
|
887
|
+
return `import express from "express";
|
|
888
|
+
import { ControllerDiscovery, ServiceContainer, errorHandler, notFoundHandler } from "@zero.core/api-framework";
|
|
889
|
+
import { HealthService } from "./services/HealthService";
|
|
890
|
+
|
|
891
|
+
const app = express();
|
|
892
|
+
app.use(express.json());
|
|
893
|
+
|
|
894
|
+
const services = new ServiceContainer()
|
|
895
|
+
.addSingleton(HealthService);
|
|
896
|
+
|
|
897
|
+
await new ControllerDiscovery({
|
|
898
|
+
controllersDir: new URL("./controllers", import.meta.url),
|
|
899
|
+
recursive: true
|
|
900
|
+
}).register(app, {
|
|
901
|
+
services,
|
|
902
|
+
openApi: {
|
|
903
|
+
info: { title: "Generated API", version: "0.1.0" }
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
app.use(notFoundHandler, errorHandler);
|
|
908
|
+
|
|
909
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
910
|
+
app.listen(port, () => console.log(\`API running at http://127.0.0.1:\${port}\`));
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
function healthDtoTemplate() {
|
|
914
|
+
return `import { ApiModel, ApiProperty } from "@zero.core/api-framework";
|
|
915
|
+
|
|
916
|
+
@ApiModel()
|
|
917
|
+
export class HealthDto {
|
|
918
|
+
@ApiProperty({ type: String })
|
|
919
|
+
status = "ok";
|
|
920
|
+
}
|
|
921
|
+
`;
|
|
922
|
+
}
|
|
923
|
+
function healthServiceTemplate() {
|
|
924
|
+
return `import { Injectable } from "@zero.core/api-framework";
|
|
925
|
+
import { HealthDto } from "../models/HealthDto";
|
|
926
|
+
|
|
927
|
+
@Injectable()
|
|
928
|
+
export class HealthService {
|
|
929
|
+
read(): HealthDto {
|
|
930
|
+
return new HealthDto();
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
`;
|
|
934
|
+
}
|
|
935
|
+
function healthControllerTemplate() {
|
|
936
|
+
return `import { ApiController, ControllerBase, HttpGet, Inject, ProducesResponseType, Route } from "@zero.core/api-framework";
|
|
937
|
+
import { HealthDto } from "../models/HealthDto";
|
|
938
|
+
import { HealthService } from "../services/HealthService";
|
|
939
|
+
|
|
940
|
+
@ApiController({ tags: ["Health"] })
|
|
941
|
+
@Route("/health")
|
|
942
|
+
export class HealthController extends ControllerBase {
|
|
943
|
+
constructor(@Inject(HealthService) private readonly healthService: HealthService) {
|
|
944
|
+
super();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
@HttpGet("/")
|
|
948
|
+
@ProducesResponseType(HealthDto)
|
|
949
|
+
health(): HealthDto {
|
|
950
|
+
return this.healthService.read();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// src/commands/openApiCommand.ts
|
|
957
|
+
async function runOpenApiCommand(positionals, options, context) {
|
|
958
|
+
const [action, first, second] = positionals;
|
|
959
|
+
if (!action) throw new CliError("Usage: zerocore api openapi <validate|print|diff> ...");
|
|
960
|
+
const reader = new OptionsReader({ positionals, options });
|
|
961
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
962
|
+
if (action === "validate") {
|
|
963
|
+
const file = fs.resolve(reader.string("input", first ?? "openapi.json") ?? "openapi.json");
|
|
964
|
+
const report = new OpenApiInspector(await fs.readJson(file)).validate();
|
|
965
|
+
context.stdout.write(reader.boolean("json") ? jsonLine(report) : formatValidation(report));
|
|
966
|
+
return new CliResult(report.errors.length ? 1 : 0);
|
|
967
|
+
}
|
|
968
|
+
if (action === "print") {
|
|
969
|
+
const file = fs.resolve(reader.string("input", first ?? "openapi.json") ?? "openapi.json");
|
|
970
|
+
const document = await fs.readJson(file);
|
|
971
|
+
context.stdout.write(reader.boolean("min") ? `${JSON.stringify(document)}
|
|
972
|
+
` : jsonLine(document));
|
|
973
|
+
return new CliResult(0);
|
|
974
|
+
}
|
|
975
|
+
if (action === "diff") {
|
|
976
|
+
if (!first || !second) throw new CliError("Usage: zerocore api openapi diff <old.json> <new.json> [--json]");
|
|
977
|
+
const oldDocument = await fs.readJson(fs.resolve(first));
|
|
978
|
+
const newDocument = await fs.readJson(fs.resolve(second));
|
|
979
|
+
const report = new OpenApiInspector(newDocument).diff(oldDocument);
|
|
980
|
+
context.stdout.write(reader.boolean("json") ? jsonLine(report) : formatDiff(report));
|
|
981
|
+
return new CliResult(report.breaking.length ? 1 : 0);
|
|
982
|
+
}
|
|
983
|
+
throw new CliError(`Unsupported OpenAPI action: ${action}`);
|
|
984
|
+
}
|
|
985
|
+
var OpenApiInspector = class {
|
|
986
|
+
constructor(document) {
|
|
987
|
+
this.document = document;
|
|
988
|
+
}
|
|
989
|
+
document;
|
|
990
|
+
validate() {
|
|
991
|
+
const errors = [];
|
|
992
|
+
const warnings = [];
|
|
993
|
+
if (!this.document.openapi) errors.push("Missing openapi version.");
|
|
994
|
+
if (!this.document.info?.title) errors.push("Missing info.title.");
|
|
995
|
+
if (!this.document.info?.version) errors.push("Missing info.version.");
|
|
996
|
+
if (!this.document.paths || !Object.keys(this.document.paths).length) errors.push("No paths found.");
|
|
997
|
+
for (const [route, methods] of Object.entries(this.document.paths ?? {})) {
|
|
998
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
999
|
+
if (!httpMethods.has(method)) continue;
|
|
1000
|
+
if (!operation.operationId) warnings.push(`${method.toUpperCase()} ${route} is missing operationId.`);
|
|
1001
|
+
if (!operation.responses || !Object.keys(operation.responses).length) errors.push(`${method.toUpperCase()} ${route} has no responses.`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return { errors, warnings, stats: this.stats() };
|
|
1005
|
+
}
|
|
1006
|
+
diff(oldDocument) {
|
|
1007
|
+
const oldOperations = operationKeys(oldDocument);
|
|
1008
|
+
const newOperations = operationKeys(this.document);
|
|
1009
|
+
const breaking = [];
|
|
1010
|
+
const warnings = [];
|
|
1011
|
+
const added = [];
|
|
1012
|
+
const removed = [];
|
|
1013
|
+
for (const key of oldOperations) {
|
|
1014
|
+
if (!newOperations.has(key)) {
|
|
1015
|
+
breaking.push(`Removed operation ${key}.`);
|
|
1016
|
+
removed.push(key);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
for (const key of newOperations) {
|
|
1020
|
+
if (!oldOperations.has(key)) added.push(key);
|
|
1021
|
+
}
|
|
1022
|
+
const oldSchemas = Object.keys(oldDocument.components?.schemas ?? {});
|
|
1023
|
+
const newSchemas = new Set(Object.keys(this.document.components?.schemas ?? {}));
|
|
1024
|
+
for (const schema of oldSchemas) {
|
|
1025
|
+
if (!newSchemas.has(schema)) breaking.push(`Removed schema ${schema}.`);
|
|
1026
|
+
}
|
|
1027
|
+
for (const schema of newSchemas) {
|
|
1028
|
+
if (!oldSchemas.includes(schema)) warnings.push(`Added schema ${schema}.`);
|
|
1029
|
+
}
|
|
1030
|
+
return { breaking, warnings, added, removed };
|
|
1031
|
+
}
|
|
1032
|
+
stats() {
|
|
1033
|
+
const paths = Object.keys(this.document.paths ?? {}).length;
|
|
1034
|
+
let operations = 0;
|
|
1035
|
+
for (const methods of Object.values(this.document.paths ?? {})) {
|
|
1036
|
+
operations += Object.keys(methods).filter((method) => httpMethods.has(method)).length;
|
|
1037
|
+
}
|
|
1038
|
+
return { paths, operations, schemas: Object.keys(this.document.components?.schemas ?? {}).length };
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
var httpMethods = /* @__PURE__ */ new Set(["get", "put", "post", "delete", "patch", "head", "options", "trace"]);
|
|
1042
|
+
function operationKeys(document) {
|
|
1043
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1044
|
+
for (const [route, methods] of Object.entries(document.paths ?? {})) {
|
|
1045
|
+
for (const method of Object.keys(methods)) {
|
|
1046
|
+
if (httpMethods.has(method)) keys.add(`${method.toUpperCase()} ${route}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return keys;
|
|
1050
|
+
}
|
|
1051
|
+
function formatValidation(report) {
|
|
1052
|
+
return [
|
|
1053
|
+
`OpenAPI: ${report.stats.paths} paths, ${report.stats.operations} operations, ${report.stats.schemas} schemas`,
|
|
1054
|
+
...report.errors.map((error) => `error: ${error}`),
|
|
1055
|
+
...report.warnings.map((warning) => `warning: ${warning}`),
|
|
1056
|
+
report.errors.length ? "Validation failed." : "Validation passed."
|
|
1057
|
+
].join("\n") + "\n";
|
|
1058
|
+
}
|
|
1059
|
+
function formatDiff(report) {
|
|
1060
|
+
return [
|
|
1061
|
+
...report.breaking.map((entry) => `breaking: ${entry}`),
|
|
1062
|
+
...report.warnings.map((entry) => `warning: ${entry}`),
|
|
1063
|
+
...report.added.map((entry) => `added: ${entry}`),
|
|
1064
|
+
...report.removed.map((entry) => `removed: ${entry}`),
|
|
1065
|
+
report.breaking.length ? "Diff has breaking changes." : "Diff has no breaking changes."
|
|
1066
|
+
].join("\n") + "\n";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/commands/pubSubCommand.ts
|
|
1070
|
+
import path6 from "node:path";
|
|
1071
|
+
async function runApiPubSubCommand(positionals, options, context) {
|
|
1072
|
+
const [action] = positionals;
|
|
1073
|
+
if (!["providers", "scan", "validate"].includes(action ?? "")) {
|
|
1074
|
+
throw new CliError("Usage: zerocore api pubsub <providers|scan|validate> [--dir src] [--json]");
|
|
1075
|
+
}
|
|
1076
|
+
const reader = new OptionsReader({ positionals, options });
|
|
1077
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
1078
|
+
if (action === "providers") {
|
|
1079
|
+
if (reader.boolean("json")) context.stdout.write(jsonLine({ providers: PUBSUB_PROVIDERS }));
|
|
1080
|
+
else new TablePrinter().print(context.stdout, [
|
|
1081
|
+
["Name", "Package", "Provider", "Broker"],
|
|
1082
|
+
...PUBSUB_PROVIDERS.map((provider) => [provider.name, provider.packageName, provider.providerName, provider.broker])
|
|
1083
|
+
]);
|
|
1084
|
+
return new CliResult(0);
|
|
1085
|
+
}
|
|
1086
|
+
const report = await new PubSubScanner(fs, fs.resolve(reader.string("dir", "src") ?? "src")).scan();
|
|
1087
|
+
if (action === "scan") {
|
|
1088
|
+
if (reader.boolean("json")) context.stdout.write(jsonLine(report));
|
|
1089
|
+
else printSubscribers(context, report);
|
|
1090
|
+
return new CliResult(0);
|
|
1091
|
+
}
|
|
1092
|
+
const validation = validatePubSubReport(report);
|
|
1093
|
+
context.stdout.write(reader.boolean("json") ? jsonLine(validation) : formatValidation2(validation));
|
|
1094
|
+
return new CliResult(validation.errors.length ? 1 : 0);
|
|
1095
|
+
}
|
|
1096
|
+
var PUBSUB_PROVIDERS = [
|
|
1097
|
+
{
|
|
1098
|
+
name: "memory",
|
|
1099
|
+
packageName: "@zero.core/api-framework",
|
|
1100
|
+
providerName: "MemoryPubSubProvider",
|
|
1101
|
+
broker: "in-memory",
|
|
1102
|
+
install: "yarn add @zero.core/api-framework"
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
name: "redis-pubsub",
|
|
1106
|
+
packageName: "@zero.core/pubsub-redis-pubsub-plugin",
|
|
1107
|
+
providerName: "RedisPubSubPlugin",
|
|
1108
|
+
broker: "Redis Pub/Sub",
|
|
1109
|
+
install: "yarn add @zero.core/pubsub-redis-pubsub-plugin"
|
|
1110
|
+
},
|
|
1111
|
+
{
|
|
1112
|
+
name: "redis-streams",
|
|
1113
|
+
packageName: "@zero.core/pubsub-redis-streams-plugin",
|
|
1114
|
+
providerName: "RedisStreamsPubSubPlugin",
|
|
1115
|
+
broker: "Redis Streams",
|
|
1116
|
+
install: "yarn add @zero.core/pubsub-redis-streams-plugin"
|
|
1117
|
+
},
|
|
1118
|
+
{
|
|
1119
|
+
name: "nats",
|
|
1120
|
+
packageName: "@zero.core/pubsub-nats-plugin",
|
|
1121
|
+
providerName: "NatsPubSubPlugin",
|
|
1122
|
+
broker: "NATS",
|
|
1123
|
+
install: "yarn add @zero.core/pubsub-nats-plugin"
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
name: "confluent-kafka",
|
|
1127
|
+
packageName: "@zero.core/pubsub-confluent-kafka-plugin",
|
|
1128
|
+
providerName: "ConfluentKafkaPubSubPlugin",
|
|
1129
|
+
broker: "Confluent Kafka",
|
|
1130
|
+
install: "yarn add @zero.core/pubsub-confluent-kafka-plugin"
|
|
1131
|
+
}
|
|
1132
|
+
];
|
|
1133
|
+
var PubSubScanner = class {
|
|
1134
|
+
constructor(fs, root) {
|
|
1135
|
+
this.fs = fs;
|
|
1136
|
+
this.root = root;
|
|
1137
|
+
}
|
|
1138
|
+
fs;
|
|
1139
|
+
root;
|
|
1140
|
+
async scan() {
|
|
1141
|
+
const files = await this.fs.listFiles(this.root, { pattern: /\.tsx?$/ });
|
|
1142
|
+
const subscribers = [];
|
|
1143
|
+
const diagnostics = [];
|
|
1144
|
+
for (const file of files) {
|
|
1145
|
+
const source = await this.fs.readText(file);
|
|
1146
|
+
subscribers.push(...subscribersFromSource(source, file, this.fs.cwd));
|
|
1147
|
+
for (const invalid of invalidSubscribeDecorators(source)) {
|
|
1148
|
+
diagnostics.push({ severity: "error", file: path6.relative(this.fs.cwd, file), message: invalid });
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
subscribers: subscribers.sort((left, right) => left.topic.localeCompare(right.topic) || left.className.localeCompare(right.className)),
|
|
1153
|
+
diagnostics
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
function subscribersFromSource(source, file, cwd) {
|
|
1158
|
+
const subscribers = [];
|
|
1159
|
+
const decoratorPattern = /@Subscribe\s*\(\s*["'`]([^"'`]+)["'`]\s*(?:,\s*\{([\s\S]*?)\})?\s*\)\s*(?:\r?\n\s*)+(?:async\s+)?([A-Za-z0-9_]+)\s*\(/g;
|
|
1160
|
+
for (const match of source.matchAll(decoratorPattern)) {
|
|
1161
|
+
const options = match[2] ?? "";
|
|
1162
|
+
subscribers.push({
|
|
1163
|
+
className: classNameBefore(source, match.index ?? 0),
|
|
1164
|
+
methodName: match[3],
|
|
1165
|
+
topic: match[1],
|
|
1166
|
+
provider: stringOption(options, "provider") ?? "default",
|
|
1167
|
+
subscriptionName: stringOption(options, "name"),
|
|
1168
|
+
enabled: !/\benabled\s*:\s*false\b/.test(options),
|
|
1169
|
+
file: path6.relative(cwd, file)
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
return subscribers;
|
|
1173
|
+
}
|
|
1174
|
+
function invalidSubscribeDecorators(source) {
|
|
1175
|
+
const diagnostics = [];
|
|
1176
|
+
for (const match of source.matchAll(/@Subscribe\s*\(([^)]*)\)/g)) {
|
|
1177
|
+
if (!/^\s*["'`][^"'`]+["'`]/.test(match[1])) {
|
|
1178
|
+
diagnostics.push("@Subscribe must declare a literal topic string as its first argument.");
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return diagnostics;
|
|
1182
|
+
}
|
|
1183
|
+
function classNameBefore(source, index) {
|
|
1184
|
+
const matches = [...source.slice(0, index).matchAll(/\bclass\s+([A-Za-z0-9_]+)/g)];
|
|
1185
|
+
return matches.at(-1)?.[1] ?? "UnknownSubscriber";
|
|
1186
|
+
}
|
|
1187
|
+
function stringOption(source, name) {
|
|
1188
|
+
const pattern = new RegExp(`\\b${name}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`);
|
|
1189
|
+
return source.match(pattern)?.[1];
|
|
1190
|
+
}
|
|
1191
|
+
function validatePubSubReport(report) {
|
|
1192
|
+
const errors = report.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message);
|
|
1193
|
+
const knownProviders = new Set(PUBSUB_PROVIDERS.map((provider) => provider.name));
|
|
1194
|
+
const warnings = [
|
|
1195
|
+
...report.diagnostics.filter((diagnostic) => diagnostic.severity === "warning").map((diagnostic) => diagnostic.message),
|
|
1196
|
+
...report.subscribers.filter((subscriber) => subscriber.provider !== "default" && !knownProviders.has(subscriber.provider)).map((subscriber) => `${subscriber.className}.${subscriber.methodName} uses custom provider '${subscriber.provider}'.`),
|
|
1197
|
+
...report.subscribers.length ? [] : ["No @Subscribe handlers found."]
|
|
1198
|
+
];
|
|
1199
|
+
return { errors, warnings, subscribers: report.subscribers.length };
|
|
1200
|
+
}
|
|
1201
|
+
function printSubscribers(context, report) {
|
|
1202
|
+
if (!report.subscribers.length) {
|
|
1203
|
+
context.stdout.write("No @Subscribe handlers found.\n");
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
new TablePrinter().print(context.stdout, [
|
|
1207
|
+
["Handler", "Topic", "Provider", "Enabled", "File"],
|
|
1208
|
+
...report.subscribers.map((subscriber) => [
|
|
1209
|
+
`${subscriber.className}.${subscriber.methodName}`,
|
|
1210
|
+
subscriber.topic,
|
|
1211
|
+
subscriber.provider,
|
|
1212
|
+
String(subscriber.enabled),
|
|
1213
|
+
subscriber.file
|
|
1214
|
+
])
|
|
1215
|
+
]);
|
|
1216
|
+
}
|
|
1217
|
+
function formatValidation2(report) {
|
|
1218
|
+
const lines = [
|
|
1219
|
+
...report.errors.map((error) => `error: ${error}`),
|
|
1220
|
+
...report.warnings.map((warning) => `warning: ${warning}`),
|
|
1221
|
+
report.errors.length ? "Pub/sub validation failed." : `Pub/sub validation passed: ${report.subscribers} subscribers.`
|
|
1222
|
+
];
|
|
1223
|
+
return `${lines.join("\n")}
|
|
1224
|
+
`;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/commands/tasksCommand.ts
|
|
1228
|
+
import { createRequire } from "node:module";
|
|
1229
|
+
import path7 from "node:path";
|
|
1230
|
+
async function runApiTasksCommand(positionals, options, context) {
|
|
1231
|
+
const [action, taskName] = positionals;
|
|
1232
|
+
if (!["list", "run"].includes(action ?? "")) throw new CliError("Usage: zerocore api tasks <list|run> [taskName] [--dir src/tasks] [--json]");
|
|
1233
|
+
const reader = new OptionsReader({ positionals, options });
|
|
1234
|
+
const fs = new WorkspaceFileSystem(context.cwd);
|
|
1235
|
+
if (action === "list") {
|
|
1236
|
+
const scheduler2 = await schedulerFromOptions(fs, reader);
|
|
1237
|
+
const tasks = scheduler2.tasks();
|
|
1238
|
+
if (reader.boolean("json")) context.stdout.write(jsonLine(tasks));
|
|
1239
|
+
else new TablePrinter().print(context.stdout, [["Name", "Schedule", "Enabled"], ...tasks.map((task) => [
|
|
1240
|
+
task.name,
|
|
1241
|
+
task.schedule.kind === "cron" ? task.schedule.expression ?? "" : `${task.schedule.everyMs}ms`,
|
|
1242
|
+
String(task.enabled)
|
|
1243
|
+
])]);
|
|
1244
|
+
return new CliResult(0);
|
|
1245
|
+
}
|
|
1246
|
+
if (!taskName) throw new CliError("Usage: zerocore api tasks run <taskName> [--dir src/tasks]");
|
|
1247
|
+
const scheduler = await schedulerFromOptions(fs, reader);
|
|
1248
|
+
const result = await scheduler.runOnce(taskName);
|
|
1249
|
+
context.stdout.write(reader.boolean("json") ? jsonLine(result) : `Task ${result.taskName}: ${result.status}
|
|
1250
|
+
`);
|
|
1251
|
+
return new CliResult(result.status === "failed" ? 1 : 0);
|
|
1252
|
+
}
|
|
1253
|
+
async function schedulerFromOptions(fs, reader) {
|
|
1254
|
+
const modulePath = reader.string("module");
|
|
1255
|
+
if (modulePath) return schedulerFromModule(fs, modulePath);
|
|
1256
|
+
const tasksDir = fs.resolve(reader.string("dir", "src/tasks") ?? "src/tasks");
|
|
1257
|
+
const framework = await loadApiFrameworkFromProject(fs.cwd);
|
|
1258
|
+
if (typeof framework.TaskDiscovery !== "function") {
|
|
1259
|
+
throw new CliError("The target project's @zero.core/api-framework package does not export TaskDiscovery.");
|
|
1260
|
+
}
|
|
1261
|
+
return new framework.TaskDiscovery({ tasksDir, recursive: true }).scheduler();
|
|
1262
|
+
}
|
|
1263
|
+
async function schedulerFromModule(fs, modulePath) {
|
|
1264
|
+
const loaded = await import(fs.toFileUrl(fs.resolve(modulePath)));
|
|
1265
|
+
const candidate = loaded.createScheduler ?? loaded.createTaskScheduler ?? loaded.scheduler ?? loaded.default;
|
|
1266
|
+
const scheduler = typeof candidate === "function" ? await candidate() : candidate;
|
|
1267
|
+
if (!scheduler || typeof scheduler.runOnce !== "function" || typeof scheduler.tasks !== "function") {
|
|
1268
|
+
throw new CliError("--module must export a TaskScheduler instance or a function that returns one.");
|
|
1269
|
+
}
|
|
1270
|
+
return scheduler;
|
|
1271
|
+
}
|
|
1272
|
+
async function loadApiFrameworkFromProject(cwd) {
|
|
1273
|
+
const projectRequire = createRequire(path7.join(cwd, "package.json"));
|
|
1274
|
+
let resolved;
|
|
1275
|
+
try {
|
|
1276
|
+
resolved = projectRequire.resolve("@zero.core/api-framework");
|
|
1277
|
+
} catch {
|
|
1278
|
+
throw new CliError("zerocore api tasks --dir requires @zero.core/api-framework to be installed in the target API project. Install it there or use --module to export a scheduler.");
|
|
1279
|
+
}
|
|
1280
|
+
return import(new WorkspaceFileSystem(cwd).toFileUrl(resolved));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/utils/versionCheck.ts
|
|
1284
|
+
import https from "node:https";
|
|
1285
|
+
var CLI_PACKAGE_NAME = "@zero.core/cli";
|
|
1286
|
+
var CLI_VERSION = "1.0.0";
|
|
1287
|
+
var NpmRegistryVersionChecker = class {
|
|
1288
|
+
async latestVersion(packageName, options) {
|
|
1289
|
+
return await new Promise((resolve) => {
|
|
1290
|
+
let settled = false;
|
|
1291
|
+
const done = (value) => {
|
|
1292
|
+
if (settled) return;
|
|
1293
|
+
settled = true;
|
|
1294
|
+
resolve(value);
|
|
1295
|
+
};
|
|
1296
|
+
const registry = options.registryUrl.replace(/\/+$/, "");
|
|
1297
|
+
const url = new URL(`${encodeURIComponent(packageName)}/latest`, `${registry}/`);
|
|
1298
|
+
const request = https.get(url, {
|
|
1299
|
+
headers: {
|
|
1300
|
+
accept: "application/json",
|
|
1301
|
+
"user-agent": `${CLI_PACKAGE_NAME}/${CLI_VERSION}`
|
|
1302
|
+
}
|
|
1303
|
+
}, (response) => {
|
|
1304
|
+
if (response.statusCode !== 200) {
|
|
1305
|
+
response.resume();
|
|
1306
|
+
done(void 0);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
let body = "";
|
|
1310
|
+
response.setEncoding("utf8");
|
|
1311
|
+
response.on("data", (chunk) => {
|
|
1312
|
+
body += chunk;
|
|
1313
|
+
if (body.length > 64e3) {
|
|
1314
|
+
request.destroy();
|
|
1315
|
+
done(void 0);
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
response.on("end", () => {
|
|
1319
|
+
try {
|
|
1320
|
+
const parsed = JSON.parse(body);
|
|
1321
|
+
done(typeof parsed.version === "string" ? parsed.version : void 0);
|
|
1322
|
+
} catch {
|
|
1323
|
+
done(void 0);
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
});
|
|
1327
|
+
request.setTimeout(options.timeoutMs, () => {
|
|
1328
|
+
request.destroy();
|
|
1329
|
+
done(void 0);
|
|
1330
|
+
});
|
|
1331
|
+
request.on("error", () => done(void 0));
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
async function maybeNotifyCliUpdate(options, context) {
|
|
1336
|
+
if (!shouldCheckForUpdates(options, context.env)) return;
|
|
1337
|
+
const checker = context.versionChecker ?? new NpmRegistryVersionChecker();
|
|
1338
|
+
const latest = await checker.latestVersion(CLI_PACKAGE_NAME, {
|
|
1339
|
+
registryUrl: registryUrlFromEnv(context.env),
|
|
1340
|
+
timeoutMs: timeoutFromEnv(context.env)
|
|
1341
|
+
});
|
|
1342
|
+
if (!latest || compareVersions(latest, CLI_VERSION) <= 0) return;
|
|
1343
|
+
context.stderr.write(
|
|
1344
|
+
`Update available: ${CLI_PACKAGE_NAME} ${CLI_VERSION} -> ${latest}. Run: yarn add --dev ${CLI_PACKAGE_NAME}@latest
|
|
1345
|
+
`
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
function shouldCheckForUpdates(options, env) {
|
|
1349
|
+
if (options.get("no-version-check") === true) return false;
|
|
1350
|
+
const setting = env.ZEROCORE_CLI_VERSION_CHECK;
|
|
1351
|
+
if (setting && ["0", "false", "off", "no"].includes(setting.toLowerCase())) return false;
|
|
1352
|
+
if (env.CI && !env.ZEROCORE_CLI_VERSION_CHECK_FORCE) return false;
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
function compareVersions(left, right) {
|
|
1356
|
+
const leftParts = parseVersion(left);
|
|
1357
|
+
const rightParts = parseVersion(right);
|
|
1358
|
+
for (let index = 0; index < 3; index += 1) {
|
|
1359
|
+
const diff = leftParts[index] - rightParts[index];
|
|
1360
|
+
if (diff !== 0) return diff;
|
|
1361
|
+
}
|
|
1362
|
+
return 0;
|
|
1363
|
+
}
|
|
1364
|
+
function registryUrlFromEnv(env) {
|
|
1365
|
+
return env.ZEROCORE_CLI_REGISTRY_URL ?? env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY ?? "https://registry.npmjs.org";
|
|
1366
|
+
}
|
|
1367
|
+
function timeoutFromEnv(env) {
|
|
1368
|
+
const raw = env.ZEROCORE_CLI_VERSION_CHECK_TIMEOUT_MS;
|
|
1369
|
+
if (!raw) return 500;
|
|
1370
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1371
|
+
if (!Number.isFinite(parsed) || parsed <= 0) throw new CliError("ZEROCORE_CLI_VERSION_CHECK_TIMEOUT_MS must be a positive integer.");
|
|
1372
|
+
return parsed;
|
|
1373
|
+
}
|
|
1374
|
+
function parseVersion(value) {
|
|
1375
|
+
const match = value.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
1376
|
+
if (!match) return [0, 0, 0];
|
|
1377
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/cli.ts
|
|
1381
|
+
async function runCli(argv, context) {
|
|
1382
|
+
const parser = new ArgParser();
|
|
1383
|
+
const parsed = parser.parse(argv);
|
|
1384
|
+
try {
|
|
1385
|
+
if (hasHelp(argv)) {
|
|
1386
|
+
context.stdout.write(rootHelp());
|
|
1387
|
+
return new CliResult(0);
|
|
1388
|
+
}
|
|
1389
|
+
const [command, area, action, ...rest] = parsed.positionals;
|
|
1390
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
1391
|
+
context.stdout.write(`${CLI_PACKAGE_NAME} ${CLI_VERSION}
|
|
1392
|
+
`);
|
|
1393
|
+
return new CliResult(0);
|
|
1394
|
+
}
|
|
1395
|
+
await maybeNotifyCliUpdate(parsed.options, context);
|
|
1396
|
+
if (command === "new") return await runNewCommand(clean([area, action, ...rest]), parsed.options, context);
|
|
1397
|
+
if (command === "doctor") return await runDoctorCommand(clean([area, action, ...rest]), parsed.options, context);
|
|
1398
|
+
if (command === "api" && area === "generate") return await runApiGenerateCommand(clean([action, ...rest]), parsed.options, context);
|
|
1399
|
+
if (command === "api" && area === "openapi") return await runOpenApiCommand(clean([action, ...rest]), parsed.options, context);
|
|
1400
|
+
if (command === "api" && area === "client") return await runApiClientCommand(clean([action, ...rest]), parsed.options, context);
|
|
1401
|
+
if (command === "api" && area === "di") return await runApiDiCommand(clean([action, ...rest]), parsed.options, context);
|
|
1402
|
+
if (command === "api" && area === "tasks") return await runApiTasksCommand(clean([action, ...rest]), parsed.options, context);
|
|
1403
|
+
if (command === "api" && area === "pubsub") return await runApiPubSubCommand(clean([action, ...rest]), parsed.options, context);
|
|
1404
|
+
throw new CliError(`Unknown command: ${argv.join(" ")}`);
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
if (error instanceof CliError) {
|
|
1407
|
+
context.stderr.write(`${error.message}
|
|
1408
|
+
`);
|
|
1409
|
+
return new CliResult(error.exitCode);
|
|
1410
|
+
}
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
function clean(values) {
|
|
1415
|
+
return values.filter((value) => Boolean(value));
|
|
1416
|
+
}
|
|
1417
|
+
function rootHelp() {
|
|
1418
|
+
return `ZeroCore CLI
|
|
1419
|
+
|
|
1420
|
+
Usage:
|
|
1421
|
+
zerocore new api <name> [--dir <path>] [--force]
|
|
1422
|
+
zerocore new app <name> [--dir <path>] [--force]
|
|
1423
|
+
zerocore new package <name> [--dir <path>] [--force]
|
|
1424
|
+
zerocore api generate resource <Name> [--dir src] [--force]
|
|
1425
|
+
zerocore api generate controller <Name> [--dir src] [--force]
|
|
1426
|
+
zerocore api generate service <Name> [--dir src] [--force]
|
|
1427
|
+
zerocore api generate dto <Name> [--dir src] [--force]
|
|
1428
|
+
zerocore api generate test <Name> [--dir .] [--force]
|
|
1429
|
+
zerocore api generate subscriber <Name> [--topic topic.name] [--provider provider-name] [--dir src] [--force]
|
|
1430
|
+
zerocore doctor [api] [--dir .] [--json]
|
|
1431
|
+
zerocore api openapi validate [file] [--json]
|
|
1432
|
+
zerocore api openapi print [file] [--min]
|
|
1433
|
+
zerocore api openapi diff <old.json> <new.json> [--json]
|
|
1434
|
+
zerocore api client generate --input openapi.json --output src/client.ts [--name ZeroCoreClient]
|
|
1435
|
+
zerocore api di graph [--dir src] [--json]
|
|
1436
|
+
zerocore api di validate [--dir src] [--json]
|
|
1437
|
+
zerocore api tasks list [--dir src/tasks] [--json]
|
|
1438
|
+
zerocore api tasks run <taskName> [--dir src/tasks]
|
|
1439
|
+
zerocore api pubsub providers [--json]
|
|
1440
|
+
zerocore api pubsub scan [--dir src] [--json]
|
|
1441
|
+
zerocore api pubsub validate [--dir src] [--json]
|
|
1442
|
+
|
|
1443
|
+
`;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// src/index.ts
|
|
1447
|
+
registerTypeScriptLoader();
|
|
1448
|
+
var entryPath = fileURLToPath(import.meta.url);
|
|
1449
|
+
var invokedPath = process.argv[1] ? path8.resolve(process.argv[1]) : "";
|
|
1450
|
+
if (entryPath === invokedPath) {
|
|
1451
|
+
void runCli(process.argv.slice(2), {
|
|
1452
|
+
cwd: process.cwd(),
|
|
1453
|
+
stdout: process.stdout,
|
|
1454
|
+
stderr: process.stderr,
|
|
1455
|
+
env: process.env
|
|
1456
|
+
}).then((result) => {
|
|
1457
|
+
process.exitCode = result.exitCode;
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
export {
|
|
1461
|
+
BufferedWritable,
|
|
1462
|
+
CLI_PACKAGE_NAME,
|
|
1463
|
+
CLI_VERSION,
|
|
1464
|
+
CliError,
|
|
1465
|
+
CliResult,
|
|
1466
|
+
compareVersions,
|
|
1467
|
+
runCli
|
|
1468
|
+
};
|
|
1469
|
+
//# sourceMappingURL=index.js.map
|