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