@contractspec/tool.create-contractspec-plugin 1.48.1

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 (2) hide show
  1. package/dist/index.mjs +1231 -0
  2. package/package.json +70 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,1231 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { input, select } from "@inquirer/prompts";
4
+ import chalk from "chalk";
5
+ import { execSync } from "child_process";
6
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
7
+ import { dirname, join } from "path";
8
+ import * as mustache from "mustache";
9
+
10
+ //#region src/utils.ts
11
+ function renderTemplate(template, data) {
12
+ return mustache.render(template, data);
13
+ }
14
+
15
+ //#endregion
16
+ //#region src/templates/example-generator.ts
17
+ /**
18
+ * Template for the example-generator plugin
19
+ * Creates a markdown documentation generator from ContractSpec specs
20
+ */
21
+ function createExampleGeneratorTemplate() {
22
+ return { files: {
23
+ "package.json": `{
24
+ "name": "{{integrationPackageName}}",
25
+ "version": "{{version}}",
26
+ "description": "{{description}}",
27
+ "keywords": [
28
+ "contractspec",
29
+ "plugin",
30
+ "generator",
31
+ "markdown",
32
+ "documentation",
33
+ "typescript"
34
+ ],
35
+ "type": "module",
36
+ "main": "./dist/index.js",
37
+ "types": "./dist/index.d.ts",
38
+ "exports": {
39
+ ".": "./dist/index.js",
40
+ "./types": "./dist/types.js",
41
+ "./generator": "./dist/generator.js",
42
+ "./config": "./dist/config.js",
43
+ "./*": "./*"
44
+ },
45
+ "files": [
46
+ "dist",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "scripts": {
51
+ "publish:pkg": "bun publish --tolerate-republish --ignore-scripts --verbose",
52
+ "publish:pkg:canary": "bun publish:pkg --tag canary",
53
+ "build": "bun build:types && bun build:bundle",
54
+ "build:bundle": "tsdown",
55
+ "build:types": "tsc --noEmit",
56
+ "dev": "bun build:bundle --watch",
57
+ "clean": "rimraf dist .turbo",
58
+ "lint": "bun lint:fix",
59
+ "lint:fix": "eslint src --fix",
60
+ "lint:check": "eslint src",
61
+ "test": "bun test",
62
+ "test:watch": "bun test --watch",
63
+ "test:coverage": "bun test --coverage",
64
+ "test:smoke": "bun test/smoke.test.ts"
65
+ },
66
+ "dependencies": {
67
+ "@contractspec/lib.contracts": "workspace:*",
68
+ "@contractspec/lib.schema": "workspace:*",
69
+ "zod": "catalog:"
70
+ },
71
+ "devDependencies": {
72
+ "@contractspec/tool.tsdown": "workspace:*",
73
+ "@contractspec/tool.typescript": "workspace:*",
74
+ "tsdown": "catalog:build",
75
+ "typescript": "catalog:",
76
+ "@types/node": "^22.0.0",
77
+ "rimraf": "^6.0.1"
78
+ },
79
+ "peerDependencies": {
80
+ "@contractspec/lib.contracts": "^1.0.0",
81
+ "@contractspec/lib.schema": "^1.0.0"
82
+ },
83
+ "publishConfig": {
84
+ "access": "public",
85
+ "registry": "https://registry.npmjs.org/"
86
+ },
87
+ "license": "MIT",
88
+ "repository": {
89
+ "type": "git",
90
+ "url": "https://github.com/lssm-tech/contractspec.git",
91
+ "directory": "packages/integrations/{{name}}"
92
+ },
93
+ "homepage": "https://contractspec.io",
94
+ "bugs": {
95
+ "url": "https://github.com/lssm-tech/contractspec/issues"
96
+ },
97
+ "author": {
98
+ "name": "{{author}}"
99
+ },
100
+ "engines": {
101
+ "node": ">=18.0.0",
102
+ "bun": ">=1.0.0"
103
+ }
104
+ }`,
105
+ "README.md": `# {{integrationPackageName}}
106
+
107
+ {{description}}
108
+
109
+ ## Overview
110
+
111
+ This is a ContractSpec plugin that generates markdown documentation from ContractSpec specifications. It transforms structured spec definitions into human-readable documentation that can be used for API docs, user guides, or technical specifications.
112
+
113
+ ## Features
114
+
115
+ - ๐Ÿš€ **Spec-First Generation**: Converts ContractSpec specs to markdown automatically
116
+ - ๐Ÿ“ **Rich Formatting**: Supports tables, lists, and detail views
117
+ - ๐Ÿ”ง **Configurable Output**: Customize formatting, field selection, and styling
118
+ - ๐Ÿ“Š **Data Integration**: Works with schema models and instance data
119
+ - ๐ŸŽฏ **Type Safe**: Full TypeScript support with proper type definitions
120
+ - ๐Ÿงช **Well Tested**: Comprehensive test suite included
121
+
122
+ ## Installation
123
+
124
+ \`\`\`bash
125
+ npm install {{integrationPackageName}}
126
+ \`\`\`
127
+
128
+ ## Usage
129
+
130
+ ### Basic Usage
131
+
132
+ \`\`\`typescript
133
+ import { {{className}} } from "{{integrationPackageName}}";
134
+
135
+ const generator = new {{className}}({
136
+ outputDir: "./docs",
137
+ format: "table", // or "list", "detail", "auto"
138
+ includeFields: ["id", "name", "description"],
139
+ });
140
+
141
+ // Generate markdown from specs
142
+ await generator.generateFromSpec(specPath, outputPath);
143
+ \`\`\`
144
+
145
+ ### Advanced Configuration
146
+
147
+ \`\`\`typescript
148
+ import { {{className}} } from "{{integrationPackageName}}";
149
+
150
+ const generator = new {{className}}({
151
+ outputDir: "./docs",
152
+ format: "auto",
153
+ title: "API Documentation",
154
+ description: "Auto-generated API documentation",
155
+ maxItems: 100,
156
+ maxDepth: 3,
157
+ fieldLabels: {
158
+ id: "ID",
159
+ createdAt: "Created Date",
160
+ updatedAt: "Last Modified"
161
+ },
162
+ summaryFields: ["id", "name", "status"],
163
+ excludeFields: ["internalNotes", "metadata"]
164
+ });
165
+ \`\`\`
166
+
167
+ ## Configuration Options
168
+
169
+ | Option | Type | Default | Description |
170
+ |--------|------|---------|-------------|
171
+ | \`outputDir\` | \`string\` | \`"./docs"\` | Directory for generated files |
172
+ | \`format\` | \`string\` | \`"auto"\` | Output format: \`"table"\`, \`"list"\`, \`"detail"\`, \`"auto"\` |
173
+ | \`title\` | \`string\` | undefined | Document title |
174
+ | \`description\` | \`string\` | undefined | Document description |
175
+ | \`maxItems\` | \`number\` | \`100\` | Maximum items to render in tables |
176
+ | \`maxDepth\` | \`number\` | \`2\` | Maximum nesting depth for objects |
177
+ | \`includeFields\` | \`string[]\` | undefined | Only include these fields |
178
+ | \`excludeFields\` | \`string[]\` | \`[]\` | Exclude these fields from output |
179
+ | \`fieldLabels\` | \`Record<string, string>\` | undefined | Custom field labels |
180
+ | \`summaryFields\` | \`string[]\` | undefined | Fields for list summaries |
181
+
182
+ ## Plugin Interface
183
+
184
+ This plugin implements the ContractSpec generator interface:
185
+
186
+ \`\`\`typescript
187
+ interface GeneratorPlugin {
188
+ readonly id: string;
189
+ readonly name: string;
190
+ readonly version: string;
191
+
192
+ initialize(config: GeneratorConfig): Promise<void>;
193
+ generate(spec: SpecDefinition, context: GeneratorContext): Promise<GeneratorResult>;
194
+ cleanup(): Promise<void>;
195
+ }
196
+ \`\`\`
197
+
198
+ ## Development
199
+
200
+ ### Setup
201
+
202
+ \`\`\`bash
203
+ # Clone the repository
204
+ git clone https://github.com/lssm-tech/contractspec.git
205
+ cd contractspec/packages/libs/plugins/{{name}}
206
+
207
+ # Install dependencies
208
+ bun install
209
+
210
+ # Run tests
211
+ bun test
212
+
213
+ # Build the plugin
214
+ bun run build
215
+ \`\`\`
216
+
217
+ ### Testing
218
+
219
+ \`\`\`bash
220
+ # Run all tests
221
+ bun test
222
+
223
+ # Run tests in watch mode
224
+ bun test:watch
225
+
226
+ # Run tests with coverage
227
+ bun test:coverage
228
+ \`\`\`
229
+
230
+ ### Building
231
+
232
+ \`\`\`bash
233
+ # Build the plugin
234
+ bun run build
235
+
236
+ # Build types only
237
+ bun run build:types
238
+
239
+ # Build bundle only
240
+ bun run build:bundle
241
+ \`\`\`
242
+
243
+ ## Contributing
244
+
245
+ We welcome contributions! Please see our [Contributing Guide](../../CONTRIBUTING.md) for details.
246
+
247
+ ## License
248
+
249
+ MIT ยฉ {{author}}
250
+
251
+ ## Support
252
+
253
+ - ๐Ÿ“– [Documentation](https://contractspec.io/docs)
254
+ - ๐Ÿ› [Issues](https://github.com/lssm-tech/contractspec/issues)
255
+ - ๐Ÿ’ฌ [Discussions](https://github.com/lssm-tech/contractspec/discussions)`,
256
+ "src/index.ts": `/**
257
+ * {{integrationPackageName}}
258
+ * {{description}}
259
+
260
+ */
261
+
262
+ export { {{className}} } from "./generator.js";
263
+ export type { {{className}}Config, GeneratorResult } from "./types.js";
264
+ export { defaultConfig } from "./config.js";`,
265
+ "src/types.ts": `import type { AnySchemaModel } from "@contractspec/lib.schema";
266
+ import type { SpecDefinition } from "@contractspec/lib.contracts";
267
+
268
+ /**
269
+ * Configuration for the {{className}} plugin
270
+ */
271
+ export interface {{className}}Config {
272
+ /** Directory where markdown files will be generated */
273
+ outputDir: string;
274
+ /** Output format: table, list, detail, or auto */
275
+ format?: "table" | "list" | "detail" | "auto";
276
+ /** Title for the generated documentation */
277
+ title?: string;
278
+ /** Description to include below the title */
279
+ description?: string;
280
+ /** Maximum number of items to render in tables */
281
+ maxItems?: number;
282
+ /** Maximum nesting depth for nested objects */
283
+ maxDepth?: number;
284
+ /** Only include these fields (if not specified, all fields are included) */
285
+ includeFields?: string[];
286
+ /** Exclude these fields from output */
287
+ excludeFields?: string[];
288
+ /** Custom field labels (field name -> display label) */
289
+ fieldLabels?: Record<string, string>;
290
+ /** Fields to use for summary in list format */
291
+ summaryFields?: string[];
292
+ }
293
+
294
+ /**
295
+ * Context provided during generation
296
+ */
297
+ export interface GeneratorContext {
298
+ /** The spec definition being processed */
299
+ spec: SpecDefinition;
300
+ /** Schema models from the spec */
301
+ schemas: Record<string, AnySchemaModel>;
302
+ /** Instance data (optional) */
303
+ data?: unknown;
304
+ /** Additional metadata */
305
+ metadata?: Record<string, unknown>;
306
+ }
307
+
308
+ /**
309
+ * Result of generation
310
+ */
311
+ export interface GeneratorResult {
312
+ /** Path to the generated file */
313
+ outputPath: string;
314
+ /** Number of items processed */
315
+ itemCount: number;
316
+ /** Generation metadata */
317
+ metadata: {
318
+ specId: string;
319
+ generatedAt: Date;
320
+ format: string;
321
+ config: Partial<{{className}}Config>;
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Plugin metadata
327
+ */
328
+ export interface PluginMetadata {
329
+ readonly id: string;
330
+ readonly name: string;
331
+ readonly version: string;
332
+ readonly description: string;
333
+ readonly author: string;
334
+ readonly homepage?: string;
335
+ }
336
+
337
+ /**
338
+ * Error types for the plugin
339
+ */
340
+ export class {{className}}Error extends Error {
341
+ constructor(
342
+ message: string,
343
+ public readonly code: string,
344
+ public readonly details?: unknown
345
+ ) {
346
+ super(message);
347
+ this.name = "{{className}}Error";
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Validation errors
353
+ */
354
+ export class ValidationError extends {{className}}Error {
355
+ constructor(message: string, details?: unknown) {
356
+ super(message, "VALIDATION_ERROR", details);
357
+ this.name = "ValidationError";
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Configuration errors
363
+ */
364
+ export class ConfigurationError extends {{className}}Error {
365
+ constructor(message: string, details?: unknown) {
366
+ super(message, "CONFIGURATION_ERROR", details);
367
+ this.name = "ConfigurationError";
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Generation errors
373
+ */
374
+ export class GenerationError extends {{className}}Error {
375
+ constructor(message: string, details?: unknown) {
376
+ super(message, "GENERATION_ERROR", details);
377
+ this.name = "GenerationError";
378
+ }
379
+ }`,
380
+ "src/config.ts": `import type { {{className}}Config } from "./types.js";
381
+
382
+ /**
383
+ * Default configuration for the {{className}} plugin
384
+ */
385
+ export const defaultConfig: {{className}}Config = {
386
+ outputDir: "./docs",
387
+ format: "auto",
388
+ maxItems: 100,
389
+ maxDepth: 2,
390
+ excludeFields: [],
391
+ };
392
+
393
+ /**
394
+ * Merge user config with defaults
395
+ */
396
+ export function mergeConfig(userConfig: Partial<{{className}}Config>): {{className}}Config {
397
+ return {
398
+ ...defaultConfig,
399
+ ...userConfig,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Validate configuration
405
+ */
406
+ export function validateConfig(config: {{className}}Config): void {
407
+ if (!config.outputDir) {
408
+ throw new Error("outputDir is required");
409
+ }
410
+
411
+ if (config.format && !["table", "list", "detail", "auto"].includes(config.format)) {
412
+ throw new Error("format must be one of: table, list, detail, auto");
413
+ }
414
+
415
+ if (config.maxItems !== undefined && config.maxItems < 1) {
416
+ throw new Error("maxItems must be greater than 0");
417
+ }
418
+
419
+ if (config.maxDepth !== undefined && config.maxDepth < 1) {
420
+ throw new Error("maxDepth must be greater than 0");
421
+ }
422
+ }`,
423
+ "src/generator.ts": `import { existsSync, mkdirSync, writeFileSync } from "fs";
424
+ import { join, dirname } from "path";
425
+ import type {
426
+ {{className}}Config,
427
+ GeneratorContext,
428
+ GeneratorResult,
429
+ PluginMetadata,
430
+ ConfigurationError,
431
+ GenerationError
432
+ } from "./types.js";
433
+ import { schemaToMarkdown } from "@contractspec/lib.contracts";
434
+ import { validateConfig, mergeConfig } from "./config.js";
435
+
436
+ /**
437
+ * {{className}} - Markdown Documentation Generator
438
+ *
439
+ * Generates markdown documentation from ContractSpec specifications.
440
+ */
441
+ export class {{className}} {
442
+ private readonly metadata: PluginMetadata;
443
+ private config: {{className}}Config;
444
+
445
+ constructor(config: Partial<{{className}}Config> = {}) {
446
+ this.metadata = {
447
+ id: "{{name}}",
448
+ name: "{{integrationPackageName}}",
449
+ version: "{{version}}",
450
+ description: "{{description}}",
451
+ author: "{{author}}",
452
+ homepage: "https://contractspec.io",
453
+ };
454
+
455
+ this.config = mergeConfig(config);
456
+ validateConfig(this.config);
457
+
458
+ // Ensure output directory exists
459
+ if (!existsSync(this.config.outputDir)) {
460
+ mkdirSync(this.config.outputDir, { recursive: true });
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Get plugin metadata
466
+ */
467
+ getMetadata(): PluginMetadata {
468
+ return this.metadata;
469
+ }
470
+
471
+ /**
472
+ * Generate markdown documentation from a spec and optional data
473
+ */
474
+ async generate(context: GeneratorContext): Promise<GeneratorResult> {
475
+ try {
476
+ const { spec, schemas, data } = context;
477
+
478
+ if (!spec) {
479
+ throw new GenerationError("Spec definition is required");
480
+ }
481
+
482
+ // Find the primary schema (first one by convention)
483
+ const schemaNames = Object.keys(schemas);
484
+ if (schemaNames.length === 0) {
485
+ throw new GenerationError("No schemas found in spec");
486
+ }
487
+
488
+ const primarySchemaName = schemaNames[0];
489
+ const primarySchema = schemas[primarySchemaName];
490
+
491
+ // Generate markdown
492
+ const markdown = schemaToMarkdown(primarySchema, data, {
493
+ title: this.config.title,
494
+ description: this.config.description,
495
+ format: this.config.format,
496
+ maxItems: this.config.maxItems,
497
+ maxDepth: this.config.maxDepth,
498
+ includeFields: this.config.includeFields,
499
+ excludeFields: this.config.excludeFields,
500
+ fieldLabels: this.config.fieldLabels,
501
+ summaryFields: this.config.summaryFields,
502
+ });
503
+
504
+ // Determine output file path
505
+ const fileName = this.generateFileName(spec.id || primarySchemaName);
506
+ const outputPath = join(this.config.outputDir, fileName);
507
+
508
+ // Write markdown file
509
+ writeFileSync(outputPath, markdown, "utf8");
510
+
511
+ const itemCount = Array.isArray(data) ? data.length : data ? 1 : 0;
512
+
513
+ return {
514
+ outputPath,
515
+ itemCount,
516
+ metadata: {
517
+ specId: spec.id || "unknown",
518
+ generatedAt: new Date(),
519
+ format: this.config.format || "auto",
520
+ config: {
521
+ format: this.config.format,
522
+ maxItems: this.config.maxItems,
523
+ maxDepth: this.config.maxDepth,
524
+ },
525
+ },
526
+ };
527
+ } catch (error) {
528
+ if (error instanceof GenerationError) {
529
+ throw error;
530
+ }
531
+ throw new GenerationError("Failed to generate documentation", error);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Generate documentation from a spec file path
537
+ */
538
+ async generateFromSpec(specPath: string, outputPath?: string): Promise<GeneratorResult> {
539
+ try {
540
+ // This would typically load the spec from file
541
+ // For now, we'll throw an error indicating this needs implementation
542
+ throw new Error("generateFromSpec needs to be implemented with spec loading logic");
543
+ } catch (error) {
544
+ if (error instanceof GenerationError) {
545
+ throw error;
546
+ }
547
+ throw new GenerationError("Failed to generate from spec file", error);
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Generate appropriate filename for the output
553
+ */
554
+ private generateFileName(specId: string): string {
555
+ // Convert spec ID to a filename-friendly format
556
+ const fileName = specId
557
+ .replace(/[^a-zA-Z0-9-_]/g, "-")
558
+ .replace(/-+/g, "-")
559
+ .toLowerCase();
560
+
561
+ return fileName.endsWith(".md")
562
+ ? fileName
563
+ : fileName + ".md";
564
+ }
565
+
566
+ /**
567
+ * Update configuration
568
+ */
569
+ updateConfig(newConfig: Partial<{{className}}Config>): void {
570
+ this.config = mergeConfig({ ...this.config, ...newConfig });
571
+ validateConfig(this.config);
572
+
573
+ // Ensure output directory exists if it changed
574
+ if (!existsSync(this.config.outputDir)) {
575
+ mkdirSync(this.config.outputDir, { recursive: true });
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Get current configuration
581
+ */
582
+ getConfig(): {{className}}Config {
583
+ return { ...this.config };
584
+ }
585
+
586
+ /**
587
+ * Cleanup resources (no-op for this plugin)
588
+ */
589
+ async cleanup(): Promise<void> {
590
+ // No resources to cleanup for this plugin
591
+ }
592
+ }`,
593
+ "src/utils/test-utils.ts": `
594
+
595
+ import type { AnySchemaModel } from "@contractspec/lib.schema";
596
+ import type { {{className}}Config } from "../types.js";
597
+
598
+ /**
599
+ * Create a mock schema for testing
600
+ */
601
+ export function createMockSchema(overrides: Partial<AnySchemaModel> = {}): AnySchemaModel {
602
+ return {
603
+ config: {
604
+ fields: {
605
+ id: { type: "string", isOptional: false },
606
+ name: { type: "string", isOptional: false },
607
+ description: { type: "string", isOptional: true },
608
+ status: { type: "string", isOptional: true },
609
+ createdAt: { type: "date", isOptional: false },
610
+ updatedAt: { type: "date", isOptional: true },
611
+ },
612
+ ...overrides.config,
613
+ },
614
+ ...overrides,
615
+ };
616
+ }
617
+
618
+ /**
619
+ * Create mock data for testing
620
+ */
621
+ export function createMockData(count: number = 3) {
622
+ return Array.from({ length: count }, (_, i) => ({
623
+ id: "item-" + (i + 1),
624
+ name: "Test Item " + (i + 1),
625
+ description: "Description for test item " + (i + 1),
626
+ status: i % 2 === 0 ? "active" : "inactive",
627
+ createdAt: new Date(2024, 0, i + 1),
628
+ updatedAt: i % 3 === 0 ? undefined : new Date(2024, 0, i + 2),
629
+ }));
630
+ }
631
+
632
+
633
+ /**
634
+ * Create test configuration
635
+ */
636
+ export function createTestConfig(overrides: Partial<Config> = {}): {{className}}Config {
637
+ return {
638
+ outputDir: "./test-docs",
639
+ format: "table",
640
+ maxItems: 10,
641
+ maxDepth: 2,
642
+ excludeFields: [],
643
+ ...overrides,
644
+ };
645
+ }`,
646
+ "tests/generator.test.ts": `import { describe, it, expect, beforeEach, afterEach } from "bun:test";
647
+ import { existsSync, unlinkSync, readFileSync } from "fs";
648
+ import { join } from "path";
649
+ import { {{className}}, ConfigurationError, GenerationError } from "../src/generator.js";
650
+ import type { {{className}}Config } from "../src/types.js";
651
+ import { createMockSchema, createMockData, createTestConfig } from "../src/utils/test-utils.js";
652
+
653
+ describe("{{className}}", () => {
654
+ let generator: {{className}};
655
+ let testConfig: {{className}}Config;
656
+ const testOutputDir = "./test-output";
657
+
658
+ beforeEach(() => {
659
+ testConfig = createTestConfig({ outputDir: testOutputDir });
660
+ generator = new {{className}}(testConfig);
661
+ });
662
+
663
+ afterEach(() => {
664
+ // Clean up test files
665
+ if (existsSync(testOutputDir)) {
666
+ // In a real implementation, you'd recursively delete the directory
667
+ console.log("Cleaning up test directory: " + testOutputDir);
668
+ }
669
+ });
670
+
671
+ describe("constructor", () => {
672
+ it("should create a generator with default config", () => {
673
+ const gen = new {{className}}();
674
+ const config = gen.getConfig();
675
+
676
+ expect(config.outputDir).toBe("./docs");
677
+ expect(config.format).toBe("auto");
678
+ expect(config.maxItems).toBe(100);
679
+ });
680
+
681
+ it("should create a generator with custom config", () => {
682
+ const customConfig = {
683
+ outputDir: "./custom-docs",
684
+ format: "table" as const,
685
+ maxItems: 50,
686
+ };
687
+
688
+ const gen = new {{className}}(customConfig);
689
+ const config = gen.getConfig();
690
+
691
+ expect(config.outputDir).toBe("./custom-docs");
692
+ expect(config.format).toBe("table");
693
+ expect(config.maxItems).toBe(50);
694
+ });
695
+
696
+ it("should throw ConfigurationError for invalid config", () => {
697
+ expect(() => {
698
+ new {{className}}({ outputDir: "" });
699
+ }).toThrow(ConfigurationError);
700
+ });
701
+ });
702
+
703
+ describe("getMetadata", () => {
704
+ it("should return correct plugin metadata", () => {
705
+ const metadata = generator.getMetadata();
706
+
707
+ expect(metadata.id).toBe("{{name}}");
708
+ expect(metadata.name).toBe("{{integrationPackageName}}");
709
+ expect(metadata.description).toBe("{{description}}");
710
+ expect(metadata.author).toBe("{{author}}");
711
+ });
712
+ });
713
+
714
+ describe("generate", () => {
715
+ it("should generate markdown from schema and data", async () => {
716
+ const schema = createMockSchema();
717
+ const data = createMockData(3);
718
+
719
+ const result = await generator.generate({
720
+ spec: { id: "test-spec" } as any,
721
+ schemas: { TestItem: schema },
722
+ data,
723
+ });
724
+
725
+ expect(result.outputPath).toContain(testOutputDir);
726
+ expect(result.itemCount).toBe(3);
727
+ expect(existsSync(result.outputPath)).toBe(true);
728
+
729
+ // Check generated content
730
+ const content = readFileSync(result.outputPath, "utf8");
731
+ expect(content).toContain("item-1");
732
+ expect(content).toContain("Test Item 1");
733
+ });
734
+
735
+ it("should handle empty data", async () => {
736
+ const schema = createMockSchema();
737
+
738
+ const result = await generator.generate({
739
+ spec: { id: "test-spec" } as any,
740
+ schemas: { TestItem: schema },
741
+ data: [],
742
+ });
743
+
744
+ expect(result.itemCount).toBe(0);
745
+ expect(existsSync(result.outputPath)).toBe(true);
746
+ });
747
+
748
+ it("should throw GenerationError for missing spec", async () => {
749
+ await expect(
750
+ generator.generate({
751
+ spec: null as any,
752
+ schemas: {},
753
+ data: [],
754
+ })
755
+ ).rejects.toThrow(GenerationError);
756
+ });
757
+
758
+ it("should throw GenerationError for missing schemas", async () => {
759
+ await expect(
760
+ generator.generate({
761
+ spec: { id: "test-spec" } as any,
762
+ schemas: {},
763
+ data: [],
764
+ })
765
+ ).rejects.toThrow(GenerationError);
766
+ });
767
+ });
768
+
769
+ describe("updateConfig", () => {
770
+ it("should update configuration", () => {
771
+ generator.updateConfig({ maxItems: 25 });
772
+
773
+ const config = generator.getConfig();
774
+ expect(config.maxItems).toBe(25);
775
+ });
776
+
777
+ it("should throw ConfigurationError for invalid update", () => {
778
+ expect(() => {
779
+ generator.updateConfig({ format: "invalid" as any });
780
+ }).toThrow(ConfigurationError);
781
+ });
782
+ });
783
+
784
+ describe("cleanup", () => {
785
+ it("should cleanup successfully", async () => {
786
+ await expect(generator.cleanup()).resolves.not.toThrow();
787
+ });
788
+ });
789
+ });`,
790
+ "tests/utils.test.ts": `import { describe, it, expect } from "bun:test";
791
+ import { createMockSchema, createMockData, createTestConfig } from "../src/utils/test-utils.js";
792
+ import type { {{className}}Config } from "../src/types.js";
793
+
794
+ describe("Test Utils", () => {
795
+ describe("createMockSchema", () => {
796
+ it("should create a mock schema with default fields", () => {
797
+ const schema = createMockSchema();
798
+
799
+ expect(schema.config.fields).toBeDefined();
800
+ expect(schema.config.fields.id).toBeDefined();
801
+ expect(schema.config.fields.name).toBeDefined();
802
+ expect(schema.config.fields.description).toBeDefined();
803
+ });
804
+
805
+ it("should apply overrides", () => {
806
+ const overrides = {
807
+ config: {
808
+ fields: {
809
+ customField: { type: "number", isOptional: false },
810
+ },
811
+ },
812
+ };
813
+
814
+ const schema = createMockSchema(overrides);
815
+
816
+ expect(schema.config.fields.customField).toBeDefined();
817
+ expect(schema.config.fields.customField.type).toBe("number");
818
+ });
819
+ });
820
+
821
+ describe("createMockData", () => {
822
+ it("should create mock data with specified count", () => {
823
+ const data = createMockData(5);
824
+
825
+ expect(data).toHaveLength(5);
826
+ expect(data[0].id).toBe("item-1");
827
+ expect(data[4].id).toBe("item-5");
828
+ });
829
+
830
+ it("should create default count when not specified", () => {
831
+ const data = createMockData();
832
+
833
+ expect(data).toHaveLength(3);
834
+ });
835
+ });
836
+
837
+ describe("createTestConfig", () => {
838
+ it("should create test config with defaults", () => {
839
+ const config = createTestConfig();
840
+
841
+ expect(config.outputDir).toBe("./test-docs");
842
+ expect(config.format).toBe("table");
843
+ expect(config.maxItems).toBe(10);
844
+ });
845
+
846
+ it("should apply overrides", () => {
847
+ const overrides: Partial<{{className}}Config> = {
848
+ format: "list",
849
+ maxItems: 20,
850
+ };
851
+
852
+ const config = createTestConfig(overrides);
853
+
854
+ expect(config.format).toBe("list");
855
+ expect(config.maxItems).toBe(20);
856
+ });
857
+ });
858
+ });`,
859
+ ".github/workflows/ci.yml": `name: CI
860
+
861
+ on:
862
+ push:
863
+ branches: [ main, develop ]
864
+ pull_request:
865
+ branches: [ main ]
866
+
867
+ jobs:
868
+ smoke-test:
869
+ runs-on: ubuntu-latest
870
+ steps:
871
+ - uses: actions/checkout@v4
872
+
873
+ - name: Setup Bun
874
+ uses: oven-sh/setup-bun@v1
875
+ with:
876
+ bun-version: latest
877
+
878
+ - name: Install dependencies
879
+ run: bun install
880
+
881
+ - name: Run smoke test
882
+ run: bun run test:smoke
883
+
884
+ test:
885
+ runs-on: ubuntu-latest
886
+
887
+ strategy:
888
+ matrix:
889
+ node-version: [18, 20]
890
+
891
+ steps:
892
+ - uses: actions/checkout@v4
893
+
894
+ - name: Setup Bun
895
+ uses: oven-sh/setup-bun@v1
896
+ with:
897
+ bun-version: latest
898
+
899
+ - name: Install dependencies
900
+ run: bun install
901
+
902
+ - name: Run tests
903
+ run: bun test
904
+
905
+ - name: Run tests with coverage
906
+ run: bun test --coverage
907
+
908
+ - name: Build
909
+ run: bun run build
910
+
911
+ - name: Lint
912
+ run: bun run lint:check
913
+
914
+ publish:
915
+ needs: test
916
+ runs-on: ubuntu-latest
917
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
918
+
919
+ steps:
920
+ - uses: actions/checkout@v4
921
+
922
+ - name: Setup Bun
923
+ uses: oven-sh/setup-bun@v1
924
+ with:
925
+ bun-version: latest
926
+
927
+ - name: Install dependencies
928
+ run: bun install
929
+
930
+ - name: Build
931
+ run: bun run build
932
+
933
+ - name: Publish to NPM
934
+ run: bun run publish:pkg
935
+ env:
936
+ NPM_TOKEN: \${{ secrets.NPM_TOKEN }}`,
937
+ "tests/smoke.test.ts": `import { describe, it, expect, beforeAll, afterAll } from "bun:test";
938
+ import { existsSync, mkdirSync, rmSync } from "fs";
939
+ import { {{className}} } from "../src/generator.js";
940
+
941
+ const SMOKE_OUTPUT_DIR = "./.smoke-test-output";
942
+
943
+ describe("{{className}} Smoke Test", () => {
944
+ beforeAll(() => {
945
+ if (!existsSync(SMOKE_OUTPUT_DIR)) {
946
+ mkdirSync(SMOKE_OUTPUT_DIR, { recursive: true });
947
+ }
948
+ });
949
+
950
+ afterAll(() => {
951
+ if (existsSync(SMOKE_OUTPUT_DIR)) {
952
+ rmSync(SMOKE_OUTPUT_DIR, { recursive: true, force: true });
953
+ }
954
+ });
955
+
956
+ it("should instantiate without errors", () => {
957
+ const generator = new {{className}}({ outputDir: SMOKE_OUTPUT_DIR });
958
+ expect(generator).toBeDefined();
959
+ expect(typeof generator.generate).toBe("function");
960
+ expect(typeof generator.getMetadata).toBe("function");
961
+ expect(typeof generator.getConfig).toBe("function");
962
+ expect(typeof generator.cleanup).toBe("function");
963
+ });
964
+
965
+ it("should generate output file", async () => {
966
+ const generator = new {{className}}({ outputDir: SMOKE_OUTPUT_DIR });
967
+
968
+ const result = await generator.generate({
969
+ spec: { id: "smoke-test-spec" } as any,
970
+ schemas: {
971
+ TestEntity: {
972
+ config: {
973
+ fields: {
974
+ id: { type: "string", isOptional: false },
975
+ name: { type: "string", isOptional: false },
976
+ },
977
+ },
978
+ },
979
+ },
980
+ data: [{ id: "test-1", name: "Smoke Test Entity" }],
981
+ });
982
+
983
+ expect(result.outputPath).toBeDefined();
984
+ expect(result.itemCount).toBe(1);
985
+ expect(existsSync(result.outputPath)).toBe(true);
986
+ });
987
+
988
+ it("should handle config updates", () => {
989
+ const generator = new {{className}}();
990
+ generator.updateConfig({ format: "list", maxItems: 50 });
991
+ const config = generator.getConfig();
992
+ expect(config.format).toBe("list");
993
+ expect(config.maxItems).toBe(50);
994
+ });
995
+
996
+ it("should provide valid metadata", () => {
997
+ const generator = new {{className}}();
998
+ const metadata = generator.getMetadata();
999
+ expect(metadata.id).toBeDefined();
1000
+ expect(metadata.name).toBeDefined();
1001
+ expect(metadata.version).toBeDefined();
1002
+ expect(typeof metadata.id).toBe("string");
1003
+ expect(typeof metadata.name).toBe("string");
1004
+ expect(typeof metadata.version).toBe("string");
1005
+ });
1006
+
1007
+ it("should cleanup without errors", async () => {
1008
+ const generator = new {{className}}({ outputDir: SMOKE_OUTPUT_DIR });
1009
+ await expect(generator.cleanup()).resolves.not.toThrow();
1010
+ });
1011
+ });`,
1012
+ ".eslintrc.json": `{
1013
+ "extends": [
1014
+ "@contractspec/eslint-config-typescript"
1015
+ ],
1016
+ "parser": "@typescript-eslint/parser",
1017
+ "plugins": ["@typescript-eslint"],
1018
+ "rules": {
1019
+ "@typescript-eslint/no-unused-vars": "error",
1020
+ "@typescript-eslint/explicit-function-return-type": "warn",
1021
+ "@typescript-eslint/no-explicit-any": "warn"
1022
+ }
1023
+ }`,
1024
+ "tsconfig.json": `{
1025
+ "extends": "@contractspec/tsconfig-base",
1026
+ "compilerOptions": {
1027
+ "outDir": "./dist",
1028
+ "rootDir": "./src",
1029
+ "declaration": true,
1030
+ "declarationMap": true,
1031
+ "sourceMap": true
1032
+ },
1033
+ "include": [
1034
+ "src/**/*"
1035
+ ],
1036
+ "exclude": [
1037
+ "node_modules",
1038
+ "dist",
1039
+ "tests"
1040
+ ]
1041
+ }`,
1042
+ "tsdown.config.js": `import { defineConfig } from "tsdown";
1043
+
1044
+ export default defineConfig({
1045
+ entry: ["src/index.ts"],
1046
+ format: ["esm"],
1047
+ dts: true,
1048
+ clean: true,
1049
+ sourcemap: true,
1050
+ });`,
1051
+ LICENSE: `MIT License
1052
+
1053
+ Copyright (c) {{currentYear}} {{author}}
1054
+
1055
+ Permission is hereby granted, free of charge, to any person obtaining a copy
1056
+ of this software and associated documentation files (the "Software"), to deal
1057
+ in the Software without restriction, including without limitation the rights
1058
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1059
+ copies of the Software, and to permit persons to whom the Software is
1060
+ furnished to do so, subject to the following conditions:
1061
+
1062
+ The above copyright notice and this permission notice shall be included in all
1063
+ copies or substantial portions of the Software.
1064
+
1065
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1066
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1067
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1068
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1069
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1070
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1071
+ SOFTWARE.`,
1072
+ "src/templates/index.ts": `/**
1073
+ * Template registry for the create-contractspec-plugin tool
1074
+ */
1075
+
1076
+ export { createExampleGeneratorTemplate } from "./example-generator.js";
1077
+ export type { Template, TemplateFile } from "./types.js";`,
1078
+ "src/templates/types.ts": `/**
1079
+ * Template types
1080
+ */
1081
+
1082
+ export interface TemplateFile {
1083
+ content: string;
1084
+ path: string;
1085
+ }
1086
+
1087
+ export interface Template {
1088
+ name: string;
1089
+ description: string;
1090
+ files: Record<string, string>;
1091
+ dependencies?: string[];
1092
+ devDependencies?: string[];
1093
+ }
1094
+ `
1095
+ } };
1096
+ }
1097
+
1098
+ //#endregion
1099
+ //#region src/index.ts
1100
+ /**
1101
+ * ContractSpec Plugin Creator CLI
1102
+ *
1103
+ * A CLI tool for scaffolding ContractSpec plugins from templates.
1104
+ * Supports various plugin types with proper structure and configuration.
1105
+ */
1106
+ const program = new Command();
1107
+ program.name("create-contractspec-plugin").description("Create a new ContractSpec plugin from templates").version("1.48.0");
1108
+ program.command("create").description("Create a new plugin").option("-n, --name <name>", "Plugin name").option("-t, --type <type>", "Plugin type (generator, transformer, validator)", "generator").option("-d, --description <description>", "Plugin description").option("-a, --author <author>", "Plugin author").option("--template <template>", "Template to use", "example-generator").option("--dry-run", "Show what would be created without creating files").action(async (options) => {
1109
+ try {
1110
+ const config = await collectPluginConfig(options);
1111
+ const template = selectTemplate(config.template);
1112
+ if (options.dryRun) {
1113
+ console.log(chalk.blue("๐Ÿ” Dry run - showing what would be created:"));
1114
+ console.log(JSON.stringify(config, null, 2));
1115
+ return;
1116
+ }
1117
+ await createPlugin(config, template);
1118
+ console.log(chalk.green("โœ… Plugin created successfully!"));
1119
+ console.log(chalk.cyan("\nNext steps:"));
1120
+ console.log(` cd ${config.packageDir}`);
1121
+ console.log(" npm install");
1122
+ console.log(" npm test");
1123
+ } catch (error) {
1124
+ console.error(chalk.red("โŒ Error creating plugin:"), error);
1125
+ process.exit(1);
1126
+ }
1127
+ });
1128
+ program.command("list-templates").description("List available templates").action(() => {
1129
+ console.log(chalk.blue("Available templates:"));
1130
+ console.log(" example-generator - Markdown documentation generator");
1131
+ console.log(" api-transformer - API response transformer");
1132
+ console.log(" schema-validator - JSON schema validator");
1133
+ console.log(" custom-event - Custom event handler");
1134
+ });
1135
+ async function collectPluginConfig(options) {
1136
+ const name = options.name ?? await input({
1137
+ message: "Plugin name (kebab-case):",
1138
+ validate: (input$1) => {
1139
+ if (!input$1.trim()) return "Plugin name is required";
1140
+ if (!/^[a-z][a-z0-9-]*$/.test(input$1)) return "Plugin name must be kebab-case and start with a letter";
1141
+ return true;
1142
+ }
1143
+ });
1144
+ const description = options.description ?? await input({
1145
+ message: "Plugin description:",
1146
+ validate: (input$1) => input$1.trim().length > 0 || "Description is required"
1147
+ });
1148
+ const defaultAuthor = (() => {
1149
+ try {
1150
+ return execSync("git config user.name", { encoding: "utf8" }).trim();
1151
+ } catch (_err) {
1152
+ return "";
1153
+ }
1154
+ })();
1155
+ const author = options.author ?? await input({
1156
+ message: "Author name:",
1157
+ default: defaultAuthor
1158
+ });
1159
+ const type = await select({
1160
+ message: "Plugin type:",
1161
+ choices: [
1162
+ {
1163
+ name: "Generator - Creates artifacts from specs",
1164
+ value: "generator"
1165
+ },
1166
+ {
1167
+ name: "Transformer - Transforms data between formats",
1168
+ value: "transformer"
1169
+ },
1170
+ {
1171
+ name: "Validator - Validates specs and data",
1172
+ value: "validator"
1173
+ },
1174
+ {
1175
+ name: "Event Handler - Processes events",
1176
+ value: "event"
1177
+ }
1178
+ ],
1179
+ default: options.type
1180
+ });
1181
+ const template = options.template;
1182
+ return {
1183
+ name,
1184
+ packageName: `@contractspec/plugin.${name}`,
1185
+ integrationPackageName: `@contractspec/integration.${name}`,
1186
+ className: name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("") + "Plugin",
1187
+ description,
1188
+ author,
1189
+ type,
1190
+ template,
1191
+ packageDir: join(process.cwd(), name),
1192
+ version: "1.0.0",
1193
+ currentYear: (/* @__PURE__ */ new Date()).getFullYear()
1194
+ };
1195
+ }
1196
+ function selectTemplate(templateName) {
1197
+ const templateFactory = { "example-generator": createExampleGeneratorTemplate }[templateName];
1198
+ if (!templateFactory) throw new Error(`Unknown template: ${templateName}`);
1199
+ return templateFactory();
1200
+ }
1201
+ async function createPlugin(config, template) {
1202
+ console.log(chalk.blue(`๐Ÿ“ฆ Creating plugin: ${config.packageName}`));
1203
+ for (const dir of [
1204
+ "",
1205
+ "src",
1206
+ "src/types",
1207
+ "src/utils",
1208
+ "src/templates",
1209
+ "tests",
1210
+ "docs",
1211
+ ".github/workflows"
1212
+ ]) {
1213
+ const fullPath = join(config.packageDir, dir);
1214
+ if (!existsSync(fullPath)) {
1215
+ mkdirSync(fullPath, { recursive: true });
1216
+ console.log(chalk.gray(`Created directory: ${fullPath}`));
1217
+ }
1218
+ }
1219
+ for (const [file, content] of Object.entries(template.files)) {
1220
+ const filePath = join(config.packageDir, file);
1221
+ const renderedContent = renderTemplate(content, config);
1222
+ const dir = dirname(filePath);
1223
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1224
+ writeFileSync(filePath, renderedContent, "utf8");
1225
+ console.log(chalk.gray(`Created file: ${filePath}`));
1226
+ }
1227
+ }
1228
+ program.parse();
1229
+
1230
+ //#endregion
1231
+ export { };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@contractspec/tool.create-contractspec-plugin",
3
+ "version": "1.48.1",
4
+ "type": "module",
5
+ "description": "CLI tool for creating ContractSpec plugins from templates",
6
+ "keywords": [
7
+ "contractspec",
8
+ "plugin",
9
+ "generator",
10
+ "cli",
11
+ "scaffold",
12
+ "typescript"
13
+ ],
14
+ "scripts": {
15
+ "publish:pkg": "bun publish --tolerate-republish --ignore-scripts --verbose",
16
+ "publish:pkg:canary": "bun publish:pkg --tag canary",
17
+ "build": "bun build:types && bun build:bundle",
18
+ "build:bundle": "tsdown",
19
+ "build:types": "tsc --noEmit",
20
+ "dev": "bun build:bundle --watch",
21
+ "clean": "rimraf dist .turbo",
22
+ "lint": "bun lint:fix",
23
+ "lint:fix": "eslint src --fix",
24
+ "lint:check": "eslint src",
25
+ "test": "bun test"
26
+ },
27
+ "bin": {
28
+ "create-contractspec-plugin": "./dist/index.js"
29
+ },
30
+ "exports": {
31
+ ".": "./dist/index.js",
32
+ "./templates": "./dist/templates/index.js",
33
+ "./templates/example-generator": "./dist/templates/example-generator.js",
34
+ "./utils": "./dist/utils.js"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "templates",
39
+ "README.md"
40
+ ],
41
+ "dependencies": {
42
+ "@contractspec/lib.contracts": "1.48.1",
43
+ "@contractspec/lib.schema": "1.48.0",
44
+ "@inquirer/prompts": "^8.2.0",
45
+ "zod": "^4.3.5",
46
+ "commander": "^12.1.0",
47
+ "chalk": "^5.3.0",
48
+ "fs-extra": "^11.2.0",
49
+ "mustache": "^4.2.0"
50
+ },
51
+ "devDependencies": {
52
+ "@contractspec/tool.tsdown": "1.48.0",
53
+ "@contractspec/tool.typescript": "1.48.0",
54
+ "@types/fs-extra": "^11.0.4",
55
+ "@types/mustache": "^4.2.5",
56
+ "tsdown": "^0.19.0",
57
+ "typescript": "^5.9.3"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "registry": "https://registry.npmjs.org/"
62
+ },
63
+ "license": "MIT",
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "https://github.com/lssm-tech/contractspec.git",
67
+ "directory": "packages/tools/create-contractspec-plugin"
68
+ },
69
+ "homepage": "https://contractspec.io"
70
+ }