@kirrosh/zond 0.16.0 → 0.18.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +2 -3
  4. package/src/cli/commands/export.ts +144 -0
  5. package/src/cli/commands/generate.ts +32 -0
  6. package/src/cli/commands/run.ts +22 -5
  7. package/src/cli/commands/sync.ts +241 -0
  8. package/src/cli/commands/update.ts +174 -0
  9. package/src/cli/index.ts +67 -10
  10. package/src/core/diagnostics/db-analysis.ts +79 -7
  11. package/src/core/diagnostics/failure-hints.ts +39 -0
  12. package/src/core/exporter/postman.ts +963 -0
  13. package/src/core/generator/data-factory.ts +47 -9
  14. package/src/core/generator/index.ts +1 -1
  15. package/src/core/generator/openapi-reader.ts +6 -0
  16. package/src/core/generator/serializer.ts +17 -2
  17. package/src/core/generator/suite-generator.ts +163 -14
  18. package/src/core/generator/types.ts +1 -0
  19. package/src/core/meta/meta-store.ts +78 -0
  20. package/src/core/meta/types.ts +21 -0
  21. package/src/core/parser/schema.ts +12 -2
  22. package/src/core/parser/types.ts +12 -1
  23. package/src/core/parser/variables.ts +3 -0
  24. package/src/core/parser/yaml-parser.ts +2 -1
  25. package/src/core/runner/assertions.ts +44 -20
  26. package/src/core/runner/execute-run.ts +31 -8
  27. package/src/core/runner/executor.ts +34 -8
  28. package/src/core/runner/http-client.ts +1 -1
  29. package/src/core/runner/types.ts +1 -0
  30. package/src/core/sync/spec-differ.ts +38 -0
  31. package/src/web/server.ts +1 -1
  32. package/src/cli/commands/mcp.ts +0 -16
  33. package/src/mcp/descriptions.ts +0 -47
  34. package/src/mcp/server.ts +0 -38
  35. package/src/mcp/tools/ci-init.ts +0 -54
  36. package/src/mcp/tools/coverage-analysis.ts +0 -141
  37. package/src/mcp/tools/describe-endpoint.ts +0 -27
  38. package/src/mcp/tools/manage-server.ts +0 -86
  39. package/src/mcp/tools/query-db.ts +0 -84
  40. package/src/mcp/tools/run-tests.ts +0 -116
  41. package/src/mcp/tools/send-request.ts +0 -51
  42. package/src/mcp/tools/setup-api.ts +0 -88
  43. /package/src/web/static/{htmx.min.js → htmx.min.cjs} +0 -0
@@ -0,0 +1,241 @@
1
+ import { join } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import {
4
+ readOpenApiSpec,
5
+ extractEndpoints,
6
+ extractSecuritySchemes,
7
+ serializeSuite,
8
+ } from "../../core/generator/index.ts";
9
+ import { generateSuites } from "../../core/generator/suite-generator.ts";
10
+ import { filterByTag } from "../../core/generator/chunker.ts";
11
+ import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
12
+ import { diffEndpoints } from "../../core/sync/spec-differ.ts";
13
+ import { decycleSchema } from "../../core/generator/schema-utils.ts";
14
+ import { printError, printSuccess, printWarning } from "../output.ts";
15
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
16
+ import { version as ZOND_VERSION } from "../../../package.json";
17
+ import { getDb } from "../../db/schema.ts";
18
+ import { findCollectionByTestPath, updateCollection } from "../../db/queries.ts";
19
+
20
+ export interface SyncOptions {
21
+ specPath: string;
22
+ testsDir: string;
23
+ dryRun?: boolean;
24
+ tag?: string;
25
+ json?: boolean;
26
+ }
27
+
28
+ export async function syncCommand(options: SyncOptions): Promise<number> {
29
+ try {
30
+ // Load existing metadata
31
+ const meta = await readMeta(options.testsDir);
32
+ if (!meta) {
33
+ const msg =
34
+ "No .zond-meta.json found. Run `zond generate <spec> --output <dir>` first to initialize metadata.";
35
+ if (options.json) {
36
+ printJson(jsonError("sync", [msg]));
37
+ } else {
38
+ printError(msg);
39
+ }
40
+ return 2;
41
+ }
42
+
43
+ // Load current spec
44
+ const doc = await readOpenApiSpec(options.specPath);
45
+ const specContent = JSON.stringify(decycleSchema(doc));
46
+ const currentHash = hashSpec(specContent);
47
+
48
+ if (currentHash === meta.specHash) {
49
+ const msg = "Spec unchanged — nothing to sync.";
50
+ if (options.json) {
51
+ printJson(jsonOk("sync", { newEndpoints: [], generatedFiles: [], removedKeys: [], specChanged: false }, [msg]));
52
+ } else {
53
+ console.log(msg);
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ // Extract current endpoints
59
+ let currentEndpoints = extractEndpoints(doc);
60
+ const securitySchemes = extractSecuritySchemes(doc);
61
+
62
+ if (options.tag) {
63
+ currentEndpoints = filterByTag(currentEndpoints, options.tag);
64
+ }
65
+
66
+ // Collect all previously known endpoint keys from meta
67
+ const prevKeys = Object.values(meta.files).flatMap((f) => f.endpoints);
68
+
69
+ // Compute diff
70
+ const { newEndpoints, removedKeys } = diffEndpoints(prevKeys, currentEndpoints);
71
+
72
+ const warnings: string[] = [];
73
+
74
+ if (removedKeys.length > 0) {
75
+ for (const key of removedKeys) {
76
+ warnings.push(`Removed endpoint not deleted from tests (review manually): ${key}`);
77
+ }
78
+ }
79
+
80
+ if (newEndpoints.length === 0) {
81
+ const msg = "Spec changed (hash differs) but no new endpoints detected. Existing tests may need manual review.";
82
+ warnings.push(msg);
83
+ if (options.json) {
84
+ printJson(jsonOk("sync", {
85
+ newEndpoints: [],
86
+ removedKeys,
87
+ generatedFiles: [],
88
+ specChanged: true,
89
+ }, warnings));
90
+ } else {
91
+ console.log(msg);
92
+ for (const w of warnings) {
93
+ printWarning(w);
94
+ }
95
+ }
96
+ return 0;
97
+ }
98
+
99
+ // Generate suites for new endpoints only
100
+ const suites = generateSuites({ endpoints: newEndpoints, securitySchemes });
101
+
102
+ if (options.dryRun) {
103
+ const newEndpointKeys = newEndpoints.map((ep) => `${ep.method.toUpperCase()} ${ep.path}`);
104
+ const plannedFiles = suites.map((s) => ({
105
+ file: `${s.fileStem ?? s.name}.yaml`,
106
+ suite: s.name,
107
+ tests: s.tests.length,
108
+ }));
109
+
110
+ if (options.json) {
111
+ printJson(jsonOk("sync", {
112
+ dryRun: true,
113
+ newEndpoints: newEndpointKeys,
114
+ removedKeys,
115
+ plannedFiles,
116
+ specChanged: true,
117
+ }, warnings));
118
+ } else {
119
+ console.log(`[dry-run] Detected ${newEndpoints.length} new endpoint(s):`);
120
+ for (const ep of newEndpoints) {
121
+ console.log(` + ${ep.method.toUpperCase()} ${ep.path}`);
122
+ }
123
+ console.log(`\nWould generate ${suites.length} new suite file(s):`);
124
+ for (const f of plannedFiles) {
125
+ console.log(` ${f.file} (${f.tests} tests)`);
126
+ }
127
+ if (removedKeys.length > 0) {
128
+ console.log("\nRemoved endpoints (not deleted — review tests):");
129
+ for (const key of removedKeys) {
130
+ console.log(` - ${key}`);
131
+ }
132
+ }
133
+ console.log("\nNo files written (dry-run).");
134
+ }
135
+ return 0;
136
+ }
137
+
138
+ // Write new files (skip if file already exists)
139
+ await mkdir(options.testsDir, { recursive: true });
140
+
141
+ const generatedFiles: Array<{ file: string; suite: string; tests: number }> = [];
142
+ const skippedFiles: string[] = [];
143
+ const updatedMetaFiles: Record<string, import("../../core/meta/types.ts").FileMeta> = {};
144
+
145
+ for (const suite of suites) {
146
+ const fileName = `${suite.fileStem ?? suite.name}.yaml`;
147
+ const filePath = join(options.testsDir, fileName);
148
+ const existing = Bun.file(filePath);
149
+
150
+ if (await existing.exists()) {
151
+ skippedFiles.push(fileName);
152
+ warnings.push(`Skipped ${fileName} (already exists — add new endpoints manually)`);
153
+ continue;
154
+ }
155
+
156
+ const yaml = serializeSuite(suite);
157
+ await Bun.write(filePath, yaml);
158
+ generatedFiles.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
159
+ updatedMetaFiles[fileName] = buildFileMeta(suite, ZOND_VERSION);
160
+ }
161
+
162
+ // Update metadata: merge new file entries, update hash and timestamp
163
+ await writeMeta(options.testsDir, {
164
+ zondVersion: ZOND_VERSION,
165
+ lastSyncedAt: new Date().toISOString(),
166
+ specUrl: options.specPath,
167
+ specHash: currentHash,
168
+ files: { ...meta.files, ...updatedMetaFiles },
169
+ });
170
+
171
+ // Sync DB collection if one is registered for this tests directory
172
+ try {
173
+ getDb();
174
+ const collection = findCollectionByTestPath(options.testsDir);
175
+ if (collection && collection.openapi_spec !== options.specPath) {
176
+ updateCollection(collection.id, { openapi_spec: options.specPath });
177
+ warnings.push(`Updated collection '${collection.name}' spec reference: ${collection.openapi_spec ?? "(none)"} → ${options.specPath}`);
178
+ }
179
+ } catch {
180
+ // DB unavailable (e.g. no zond.db yet) — not a fatal error for sync
181
+ }
182
+
183
+ const newEndpointKeys = newEndpoints.map((ep) => `${ep.method.toUpperCase()} ${ep.path}`);
184
+
185
+ if (options.json) {
186
+ printJson(jsonOk("sync", {
187
+ newEndpoints: newEndpointKeys,
188
+ removedKeys,
189
+ generatedFiles,
190
+ skippedFiles,
191
+ specChanged: true,
192
+ }, warnings));
193
+ } else {
194
+ console.log(`Spec changed. Detected ${newEndpoints.length} new endpoint(s):`);
195
+ for (const ep of newEndpoints) {
196
+ console.log(` + ${ep.method.toUpperCase()} ${ep.path}`);
197
+ }
198
+
199
+ if (generatedFiles.length > 0) {
200
+ console.log(`\nGenerated ${generatedFiles.length} new suite file(s):`);
201
+ for (const f of generatedFiles) {
202
+ console.log(` ${f.file} (${f.tests} tests)`);
203
+ }
204
+ }
205
+
206
+ if (skippedFiles.length > 0) {
207
+ console.log("\nSkipped (file exists, review manually):");
208
+ for (const f of skippedFiles) {
209
+ console.log(` ${f}`);
210
+ }
211
+ }
212
+
213
+ if (removedKeys.length > 0) {
214
+ console.log("\nRemoved endpoints (not deleted — review tests):");
215
+ for (const key of removedKeys) {
216
+ console.log(` - ${key}`);
217
+ }
218
+ }
219
+
220
+ if (generatedFiles.length > 0) {
221
+ printSuccess(`\nSync complete. ${generatedFiles.length} file(s) written.`);
222
+ } else {
223
+ printWarning("No new files written — all target files already exist.");
224
+ }
225
+
226
+ for (const w of warnings) {
227
+ printWarning(w);
228
+ }
229
+ }
230
+
231
+ return 0;
232
+ } catch (err) {
233
+ const message = err instanceof Error ? err.message : String(err);
234
+ if (options.json) {
235
+ printJson(jsonError("sync", [message]));
236
+ } else {
237
+ printError(message);
238
+ }
239
+ return 2;
240
+ }
241
+ }
@@ -0,0 +1,174 @@
1
+ import { VERSION } from "../index.ts";
2
+ import { isCompiledBinary } from "../runtime.ts";
3
+ import { printError, printSuccess, printWarning } from "../output.ts";
4
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
5
+
6
+ export interface UpdateOptions {
7
+ json?: boolean;
8
+ check?: boolean;
9
+ }
10
+
11
+ const REPO = "kirrosh/zond";
12
+ const GITHUB_API = `https://api.github.com/repos/${REPO}/releases/latest`;
13
+
14
+ interface GitHubRelease {
15
+ tag_name: string;
16
+ assets: { name: string; browser_download_url: string }[];
17
+ }
18
+
19
+ function getTarget(): { target: string; ext: string } | null {
20
+ const platform = process.platform;
21
+ const arch = process.arch;
22
+
23
+ if (platform === "linux" && arch === "x64") return { target: "linux-x64", ext: "tar.gz" };
24
+ if (platform === "darwin" && arch === "arm64") return { target: "darwin-arm64", ext: "tar.gz" };
25
+ if (platform === "win32" && arch === "x64") return { target: "win-x64", ext: "zip" };
26
+ return null;
27
+ }
28
+
29
+ async function fetchLatestRelease(): Promise<GitHubRelease> {
30
+ const resp = await fetch(GITHUB_API, {
31
+ headers: { "User-Agent": `zond/${VERSION}` },
32
+ });
33
+ if (!resp.ok) {
34
+ throw new Error(`GitHub API returned ${resp.status}: ${resp.statusText}`);
35
+ }
36
+ return resp.json() as Promise<GitHubRelease>;
37
+ }
38
+
39
+ export async function updateCommand(options: UpdateOptions): Promise<number> {
40
+ try {
41
+ if (!isCompiledBinary()) {
42
+ const msg = "Self-update is only available for standalone binaries. Update via: npm update -g @kirrosh/zond or bun update -g @kirrosh/zond";
43
+ if (options.json) {
44
+ printJson(jsonOk("update", { action: "skip", reason: "not-standalone" }, [msg]));
45
+ } else {
46
+ printWarning(msg);
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ const target = getTarget();
52
+ if (!target) {
53
+ const msg = `Unsupported platform: ${process.platform}-${process.arch}`;
54
+ if (options.json) {
55
+ printJson(jsonError("update", [msg]));
56
+ } else {
57
+ printError(msg);
58
+ }
59
+ return 2;
60
+ }
61
+
62
+ const release = await fetchLatestRelease();
63
+ const latest = release.tag_name.replace(/^v/, "");
64
+
65
+ if (latest === VERSION) {
66
+ const msg = `Already up to date (${VERSION})`;
67
+ if (options.json) {
68
+ printJson(jsonOk("update", { action: "none", currentVersion: VERSION, latestVersion: latest }));
69
+ } else {
70
+ console.log(msg);
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ if (options.check) {
76
+ const msg = `Update available: ${VERSION} → ${latest}`;
77
+ if (options.json) {
78
+ printJson(jsonOk("update", { action: "available", currentVersion: VERSION, latestVersion: latest }));
79
+ } else {
80
+ console.log(msg);
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ // Find the right asset
86
+ const assetName = `zond-${target.target}.${target.ext}`;
87
+ const asset = release.assets.find(a => a.name === assetName);
88
+ if (!asset) {
89
+ const msg = `Binary not found for ${target.target} in release ${release.tag_name}`;
90
+ if (options.json) {
91
+ printJson(jsonError("update", [msg]));
92
+ } else {
93
+ printError(msg);
94
+ }
95
+ return 2;
96
+ }
97
+
98
+ console.log(`Updating zond ${VERSION} → ${latest}...`);
99
+ console.log(`Downloading ${assetName}...`);
100
+
101
+ // Download the archive
102
+ const resp = await fetch(asset.browser_download_url, {
103
+ headers: { "User-Agent": `zond/${VERSION}` },
104
+ });
105
+ if (!resp.ok) {
106
+ throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
107
+ }
108
+ const archiveData = new Uint8Array(await resp.arrayBuffer());
109
+
110
+ const currentBinary = process.execPath;
111
+ const { join, dirname } = await import("path");
112
+ const tmpDir = join(dirname(currentBinary), `.zond-update-${Date.now()}`);
113
+ const { mkdir, rm, rename, chmod } = await import("fs/promises");
114
+ await mkdir(tmpDir, { recursive: true });
115
+
116
+ try {
117
+ const archivePath = join(tmpDir, assetName);
118
+ await Bun.write(archivePath, archiveData);
119
+
120
+ // Extract
121
+ if (target.ext === "tar.gz") {
122
+ const proc = Bun.spawn(["tar", "-xzf", archivePath, "-C", tmpDir]);
123
+ const exitCode = await proc.exited;
124
+ if (exitCode !== 0) throw new Error(`tar extraction failed (exit ${exitCode})`);
125
+ } else {
126
+ // Windows zip
127
+ const proc = Bun.spawn([
128
+ "powershell", "-NoProfile", "-Command",
129
+ `Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`,
130
+ ]);
131
+ const exitCode = await proc.exited;
132
+ if (exitCode !== 0) throw new Error(`Zip extraction failed (exit ${exitCode})`);
133
+ }
134
+
135
+ // Find the extracted binary
136
+ const binaryName = process.platform === "win32" ? "zond.exe" : "zond";
137
+ const newBinary = join(tmpDir, binaryName);
138
+ const file = Bun.file(newBinary);
139
+ if (!await file.exists()) {
140
+ throw new Error(`Binary '${binaryName}' not found in archive`);
141
+ }
142
+
143
+ // Replace current binary
144
+ if (process.platform === "win32") {
145
+ // Windows: rename current to .old, move new, clean up
146
+ const oldBinary = currentBinary + ".old";
147
+ try { await rm(oldBinary, { force: true }); } catch {}
148
+ await rename(currentBinary, oldBinary);
149
+ await rename(newBinary, currentBinary);
150
+ try { await rm(oldBinary, { force: true }); } catch {}
151
+ } else {
152
+ await rename(newBinary, currentBinary);
153
+ await chmod(currentBinary, 0o755);
154
+ }
155
+
156
+ if (options.json) {
157
+ printJson(jsonOk("update", { action: "updated", previousVersion: VERSION, newVersion: latest }));
158
+ } else {
159
+ printSuccess(`Updated zond ${VERSION} → ${latest}`);
160
+ }
161
+ return 0;
162
+ } finally {
163
+ try { await rm(tmpDir, { recursive: true, force: true }); } catch {}
164
+ }
165
+ } catch (err) {
166
+ const message = err instanceof Error ? err.message : String(err);
167
+ if (options.json) {
168
+ printJson(jsonError("update", [message]));
169
+ } else {
170
+ printError(message);
171
+ }
172
+ return 2;
173
+ }
174
+ }
package/src/cli/index.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  import { runCommand } from "./commands/run.ts";
4
4
  import { validateCommand } from "./commands/validate.ts";
5
5
  import { serveCommand } from "./commands/serve.ts";
6
- import { mcpCommand } from "./commands/mcp.ts";
7
6
  import { coverageCommand } from "./commands/coverage.ts";
8
7
  import { ciInitCommand } from "./commands/ci-init.ts";
9
8
  import { initCommand } from "./commands/init.ts";
@@ -12,6 +11,9 @@ import { dbCommand } from "./commands/db.ts";
12
11
  import { requestCommand } from "./commands/request.ts";
13
12
  import { guideCommand } from "./commands/guide.ts";
14
13
  import { generateCommand } from "./commands/generate.ts";
14
+ import { exportCommand } from "./commands/export.ts";
15
+ import { syncCommand } from "./commands/sync.ts";
16
+ import { updateCommand } from "./commands/update.ts";
15
17
  import { printError } from "./output.ts";
16
18
  import { getRuntimeInfo } from "./runtime.ts";
17
19
  import { getDb } from "../db/schema.ts";
@@ -103,9 +105,10 @@ Usage:
103
105
  zond guide <spec> Generate test generation guide from OpenAPI spec
104
106
  zond serve Start web dashboard
105
107
  zond ui Alias for 'serve --open' (start dashboard & open browser)
106
- zond mcp Start MCP server (stdio transport for AI agents)
107
- --dir <path> Set working directory (relative paths resolve here)
108
108
  zond ci init Generate CI/CD workflow (GitHub Actions, GitLab CI)
109
+ zond export postman <path> Export YAML tests as Postman Collection v2.1
110
+ zond sync <spec> Detect new/removed endpoints and generate tests for new ones
111
+ zond update Check for updates and self-update the binary
109
112
 
110
113
  Options for 'run':
111
114
  --dry-run Show requests without sending them (exit code always 0)
@@ -175,6 +178,19 @@ Options for 'ci init':
175
178
  --dir <path> Project root directory (default: current directory)
176
179
  --force Overwrite existing CI config
177
180
 
181
+ Options for 'export postman':
182
+ --output <file> Output file path (default: collection.postman.json)
183
+ --env <file> Also export .env.yaml as Postman environment
184
+ --collection-name <name> Collection name (default: derived from path)
185
+
186
+ Options for 'sync':
187
+ --tests <dir> Path to test files directory (required)
188
+ --dry-run Show what would be generated without writing files
189
+ --tag <tag> Limit sync to endpoints with this tag
190
+
191
+ Options for 'update':
192
+ --check Only check for updates, do not download
193
+
178
194
  General:
179
195
  --json Output in JSON envelope format (available for all commands)
180
196
  --help, -h Show this help
@@ -308,13 +324,6 @@ async function main(): Promise<number> {
308
324
  });
309
325
  }
310
326
 
311
- case "mcp": {
312
- return mcpCommand({
313
- dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
314
- dir: typeof flags["dir"] === "string" ? flags["dir"] : undefined,
315
- });
316
- }
317
-
318
327
  case "ci": {
319
328
  const ciSub = positional[0];
320
329
  if (ciSub !== "init") {
@@ -504,6 +513,54 @@ async function main(): Promise<number> {
504
513
  });
505
514
  }
506
515
 
516
+ case "export": {
517
+ const subcommand = positional[0];
518
+ if (subcommand !== "postman") {
519
+ printError(`Unknown export subcommand: ${subcommand ?? "(none)"}. Usage: zond export postman <path>`);
520
+ return 2;
521
+ }
522
+ const testsPath = positional[1];
523
+ if (!testsPath) {
524
+ printError("Missing tests path. Usage: zond export postman <path> [--output <file>]");
525
+ return 2;
526
+ }
527
+ return exportCommand({
528
+ testsPath,
529
+ output: typeof flags["output"] === "string" ? flags["output"] : "collection.postman.json",
530
+ env: typeof flags["env"] === "string" ? flags["env"] : undefined,
531
+ collectionName: typeof flags["collection-name"] === "string" ? flags["collection-name"] : undefined,
532
+ json: jsonFlag,
533
+ });
534
+ }
535
+
536
+ case "update":
537
+ case "self-update": {
538
+ return updateCommand({
539
+ check: flags["check"] === true,
540
+ json: jsonFlag,
541
+ });
542
+ }
543
+
544
+ case "sync": {
545
+ const specPath = positional[0];
546
+ if (!specPath) {
547
+ printError("Missing spec path. Usage: zond sync <spec> --tests <dir> [--dry-run] [--tag <tag>]");
548
+ return 2;
549
+ }
550
+ const testsDir = typeof flags["tests"] === "string" ? flags["tests"] : undefined;
551
+ if (!testsDir) {
552
+ printError("Missing --tests <dir>. Usage: zond sync <spec> --tests <dir>");
553
+ return 2;
554
+ }
555
+ return syncCommand({
556
+ specPath,
557
+ testsDir,
558
+ dryRun: flags["dry-run"] === true,
559
+ tag: typeof flags["tag"] === "string" ? flags["tag"] : undefined,
560
+ json: jsonFlag,
561
+ });
562
+ }
563
+
507
564
  default: {
508
565
  printError(`Unknown command: ${command}`);
509
566
  printUsage();