@contractkit/plugin-typescript 0.26.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -26,11 +26,11 @@
26
26
  ".": "./dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@contractkit/core": "0.21.0"
29
+ "@contractkit/core": "0.22.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@repo/config-typescript": "0.1.0",
33
- "@repo/config-eslint": "0.3.1"
32
+ "@repo/config-eslint": "0.3.1",
33
+ "@repo/config-typescript": "0.1.0"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -277,14 +277,14 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
277
277
  // Body validation (request-side — use Input variants)
278
278
  if (hasBody && op.request) {
279
279
  if (isSingleMultipart) {
280
- lines.push(` const multipartBody = ctx.body as MultipartBody;`);
280
+ lines.push(` const multipartBody = ctx.parsedBody as MultipartBody;`);
281
281
  lines.push('');
282
282
  } else if (bodies.length === 1) {
283
- lines.push(` const body = await parseAndValidate(ctx.body, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
283
+ lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
284
284
  lines.push('');
285
285
  } else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
286
286
  // All declared MIMEs share the same body shape — single validation suffices
287
- lines.push(` const body = await parseAndValidate(ctx.body, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
287
+ lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
288
288
  lines.push('');
289
289
  } else {
290
290
  // Different body types per MIME — dispatch on Content-Type
@@ -298,9 +298,9 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
298
298
  for (const b of bodies) {
299
299
  lines.push(` case '${b.contentType}':`);
300
300
  if (b.contentType === 'multipart/form-data') {
301
- lines.push(` body = ctx.body as MultipartBody;`);
301
+ lines.push(` body = ctx.parsedBody as MultipartBody;`);
302
302
  } else {
303
- lines.push(` body = await parseAndValidate(ctx.body, ${renderInputType(b.bodyType, modelsWithInput)});`);
303
+ lines.push(` body = await parseAndValidate(ctx.parsedBody, ${renderInputType(b.bodyType, modelsWithInput)});`);
304
304
  }
305
305
  lines.push(` break;`);
306
306
  }
@@ -959,6 +959,84 @@ export function generateSdkOptions(): string {
959
959
  ].join('\n');
960
960
  }
961
961
 
962
+ // ─── Scaffold files (package.json / tsconfig.json) ─────────────────────────
963
+
964
+ /** Pinned dependency ranges for scaffolded SDK packages. Kept in one place so they're easy to bump. */
965
+ const SCAFFOLD_DEP_VERSIONS = {
966
+ zod: '^4.3.6',
967
+ luxon: '^3.5.0',
968
+ typesLuxon: '^3.4.2',
969
+ typescript: '^6.0.3',
970
+ } as const;
971
+
972
+ /** Which optional runtime deps the generated SDK references, derived from the contracts it covers. */
973
+ export interface SdkScaffoldDeps {
974
+ /** Zod schema files are emitted (`config.zod`) — the SDK imports `zod`. */
975
+ zod: boolean;
976
+ /** Any covered model uses a `date`/`time`/`datetime`/`interval` scalar — the SDK imports `luxon`. */
977
+ luxon: boolean;
978
+ }
979
+
980
+ /**
981
+ * Generate a starter `package.json` for a generated SDK package. Emitted with
982
+ * `ifAbsent` semantics — written once, then owned by the user — so the dependency
983
+ * ranges here are only ever a starting point, never re-applied on later builds.
984
+ */
985
+ export function generateSdkPackageJson(input: { name: string; deps: SdkScaffoldDeps }): string {
986
+ const dependencies: Record<string, string> = {};
987
+ if (input.deps.zod) dependencies.zod = SCAFFOLD_DEP_VERSIONS.zod;
988
+ if (input.deps.luxon) dependencies.luxon = SCAFFOLD_DEP_VERSIONS.luxon;
989
+
990
+ const devDependencies: Record<string, string> = { typescript: SCAFFOLD_DEP_VERSIONS.typescript };
991
+ if (input.deps.luxon) devDependencies['@types/luxon'] = SCAFFOLD_DEP_VERSIONS.typesLuxon;
992
+
993
+ const pkg = {
994
+ name: input.name,
995
+ version: '0.0.0',
996
+ type: 'module',
997
+ main: './dist/index.js',
998
+ types: './dist/index.d.ts',
999
+ exports: {
1000
+ '.': {
1001
+ types: './dist/index.d.ts',
1002
+ import: './dist/index.js',
1003
+ },
1004
+ },
1005
+ files: ['dist'],
1006
+ scripts: {
1007
+ build: 'tsc -p tsconfig.json',
1008
+ },
1009
+ ...(Object.keys(dependencies).length > 0 ? { dependencies } : {}),
1010
+ devDependencies,
1011
+ };
1012
+ return JSON.stringify(pkg, null, 4) + '\n';
1013
+ }
1014
+
1015
+ /**
1016
+ * Generate a standalone `tsconfig.json` for a generated SDK package. Deliberately
1017
+ * self-contained (no workspace `extends`) so the scaffold works in a freshly
1018
+ * `npm init`'d package outside this monorepo. Emitted with `ifAbsent` semantics.
1019
+ */
1020
+ export function generateSdkTsconfig(): string {
1021
+ const tsconfig = {
1022
+ compilerOptions: {
1023
+ target: 'ES2022',
1024
+ module: 'NodeNext',
1025
+ moduleResolution: 'NodeNext',
1026
+ declaration: true,
1027
+ outDir: './dist',
1028
+ rootDir: './src',
1029
+ strict: true,
1030
+ esModuleInterop: true,
1031
+ skipLibCheck: true,
1032
+ forceConsistentCasingInFileNames: true,
1033
+ },
1034
+ include: ['src'],
1035
+ exclude: ['dist', 'node_modules'],
1036
+ };
1037
+ return JSON.stringify(tsconfig, null, 4) + '\n';
1038
+ }
1039
+
962
1040
  /**
963
1041
  * Reference to a per-file leaf client emitted to its own `*.client.ts`. Used by the
964
1042
  * aggregator to import the class and wire it as either a top-level `sdk.<prop>` or a
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolve, join, relative, dirname, basename } from 'node:path';
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, rmdirSync } from 'node:fs';
3
- import { generateContract } from './codegen-contract.js';
3
+ import { generateContract, rootNeedsScalar } from './codegen-contract.js';
4
4
  import { generateOp } from './codegen-operation.js';
5
5
  import type {
6
6
  ContractKitPlugin,
@@ -34,8 +34,11 @@ import {
34
34
  deriveSubareaPropertyName,
35
35
  getAreaSubarea,
36
36
  hasPublicOperations,
37
+ generateSdkPackageJson,
38
+ generateSdkTsconfig,
37
39
  type SdkClientInfo,
38
40
  type SdkAreaInfo,
41
+ type SdkScaffoldDeps,
39
42
  } from './codegen-sdk.js';
40
43
  import { generatePlainTypes } from './codegen-plain-types.js';
41
44
  import {
@@ -80,6 +83,15 @@ export interface SdkConfig {
80
83
  clients?: string;
81
84
  };
82
85
  includeInternal?: boolean;
86
+ /**
87
+ * Emit a starter `package.json` and `tsconfig.json` at the SDK `baseDir` so the
88
+ * generated output is a buildable, publishable package on its own. Opt-in and
89
+ * write-once: the files are created only when absent and are never overwritten or
90
+ * cleaned up on later builds, so any edits you make to them are preserved.
91
+ * Dependency ranges are derived from the contracts (always `zod` when `zod: true`;
92
+ * `luxon` when any covered model uses a date/time/datetime/interval scalar).
93
+ */
94
+ scaffold?: boolean;
83
95
  }
84
96
 
85
97
  export interface ZodConfig {
@@ -165,8 +177,8 @@ async function runTypescriptCodegen(
165
177
 
166
178
  deleteStalePaths(result.deletedPaths);
167
179
 
168
- for (const { relativePath, content } of result.filesToWrite) {
169
- ctx.emitFile(relativePath, content);
180
+ for (const { relativePath, content, ifAbsent } of result.filesToWrite) {
181
+ ctx.emitFile(relativePath, content, ifAbsent ? { ifAbsent: true } : undefined);
170
182
  }
171
183
 
172
184
  writeManifest(manifestPath, result.manifest);
@@ -691,6 +703,31 @@ function collectSdkOutput(
691
703
  relativePath: join(sdkSrcDir, 'index.ts'),
692
704
  content: `// Auto-generated barrel file\n${rootExports.sort().join('\n')}\n`,
693
705
  });
706
+
707
+ // ── Scaffold files (opt-in, write-once) ──
708
+ // Emitted at the SDK package root with `ifAbsent` so they're created once and
709
+ // then owned by the user. Deps are derived from the contracts actually surfaced
710
+ // into the SDK: zod when schema output is on, luxon when any covered model uses a
711
+ // date/time/datetime/interval scalar.
712
+ if (config.scaffold) {
713
+ const coveredRoots = sdkContractEntries.map(e => e.ast);
714
+ const deps: SdkScaffoldDeps = {
715
+ zod: !!config.zod,
716
+ luxon: coveredRoots.some(
717
+ r => rootNeedsScalar(r, 'datetime') || rootNeedsScalar(r, 'date') || rootNeedsScalar(r, 'time') || rootNeedsScalar(r, 'interval'),
718
+ ),
719
+ };
720
+ globalFiles.push({
721
+ relativePath: join(sdkBase, 'package.json'),
722
+ content: generateSdkPackageJson({ name: sdkName ?? 'sdk', deps }),
723
+ ifAbsent: true,
724
+ });
725
+ globalFiles.push({
726
+ relativePath: join(sdkBase, 'tsconfig.json'),
727
+ content: generateSdkTsconfig(),
728
+ ifAbsent: true,
729
+ });
730
+ }
694
731
  }
695
732
 
696
733
  // ─── Zod sub-generator ─────────────────────────────────────────────────────
@@ -132,7 +132,7 @@ describe('generateOperation', () => {
132
132
  it('generates body validation with parseAndValidate', () => {
133
133
  const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
134
134
  const output = generateOp(root);
135
- expect(output).toContain('parseAndValidate(ctx.body, CreateUser)');
135
+ expect(output).toContain('parseAndValidate(ctx.parsedBody, CreateUser)');
136
136
  });
137
137
 
138
138
  it('generates create service method for POST', () => {
@@ -172,7 +172,7 @@ describe('generateOperation', () => {
172
172
  ]),
173
173
  ]);
174
174
  const output = generateOp(root);
175
- expect(output).toContain('const body = await parseAndValidate(ctx.body, AuthRequest)');
175
+ expect(output).toContain('const body = await parseAndValidate(ctx.parsedBody, AuthRequest)');
176
176
  expect(output).not.toContain('switch (ctx.request.type)');
177
177
  });
178
178
 
@@ -191,8 +191,8 @@ describe('generateOperation', () => {
191
191
  expect(output).toContain('switch (ctx.request.type)');
192
192
  expect(output).toContain("case 'application/json':");
193
193
  expect(output).toContain("case 'multipart/form-data':");
194
- expect(output).toContain('body = ctx.body as MultipartBody;');
195
- expect(output).toContain('body = await parseAndValidate(ctx.body, UploadMeta)');
194
+ expect(output).toContain('body = ctx.parsedBody as MultipartBody;');
195
+ expect(output).toContain('body = await parseAndValidate(ctx.parsedBody, UploadMeta)');
196
196
  expect(output).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
197
197
  });
198
198
  });
@@ -471,7 +471,7 @@ describe('generateOperation', () => {
471
471
  const root = opRoot([opRoute('/uploads', [opOperation('post', { request: opRequest('Upload', 'multipart/form-data') })])]);
472
472
  const output = generateOp(root);
473
473
  expect(output).toContain("bodyParserMiddleware(['multipart'])");
474
- expect(output).toContain('ctx.body as MultipartBody');
474
+ expect(output).toContain('ctx.parsedBody as MultipartBody');
475
475
  expect(output).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
476
476
  });
477
477
  });
@@ -12,6 +12,8 @@ import {
12
12
  deriveSubareaPropertyName,
13
13
  getAreaSubarea,
14
14
  hasPublicOperations,
15
+ generateSdkPackageJson,
16
+ generateSdkTsconfig,
15
17
  } from '../src/codegen-sdk.js';
16
18
  import { collectPublicTypeNames } from '@contractkit/core';
17
19
  import { renderTsType, renderInputTsType } from '../src/ts-render.js';
@@ -1668,3 +1670,49 @@ describe('generateAreaClient — <Area>Client emission', () => {
1668
1670
  ).toThrow(/duplicate method 'getCurrentUser' in area 'identity'/);
1669
1671
  });
1670
1672
  });
1673
+
1674
+ describe('generateSdkPackageJson', () => {
1675
+ it('emits a valid package.json with the given name and standard fields', () => {
1676
+ const pkg = JSON.parse(generateSdkPackageJson({ name: 'my-sdk', deps: { zod: false, luxon: false } }));
1677
+ expect(pkg.name).toBe('my-sdk');
1678
+ expect(pkg.type).toBe('module');
1679
+ expect(pkg.exports['.'].types).toBe('./dist/index.d.ts');
1680
+ expect(pkg.scripts.build).toContain('tsc');
1681
+ expect(pkg.devDependencies.typescript).toBeDefined();
1682
+ });
1683
+
1684
+ it('omits the dependencies block entirely when neither zod nor luxon is used', () => {
1685
+ const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false } }));
1686
+ expect(pkg.dependencies).toBeUndefined();
1687
+ expect(pkg.devDependencies['@types/luxon']).toBeUndefined();
1688
+ });
1689
+
1690
+ it('adds zod as a runtime dependency when zod output is enabled', () => {
1691
+ const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: true, luxon: false } }));
1692
+ expect(pkg.dependencies.zod).toBeDefined();
1693
+ expect(pkg.dependencies.luxon).toBeUndefined();
1694
+ });
1695
+
1696
+ it('adds luxon (runtime) and @types/luxon (dev) when a date/time scalar is used', () => {
1697
+ const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: true } }));
1698
+ expect(pkg.dependencies.luxon).toBeDefined();
1699
+ expect(pkg.devDependencies['@types/luxon']).toBeDefined();
1700
+ });
1701
+
1702
+ it('ends with a trailing newline', () => {
1703
+ expect(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false } }).endsWith('}\n')).toBe(true);
1704
+ });
1705
+ });
1706
+
1707
+ describe('generateSdkTsconfig', () => {
1708
+ it('emits a self-contained tsconfig with no workspace extends', () => {
1709
+ const raw = generateSdkTsconfig();
1710
+ const cfg = JSON.parse(raw);
1711
+ expect(cfg.extends).toBeUndefined();
1712
+ expect(cfg.compilerOptions.declaration).toBe(true);
1713
+ expect(cfg.compilerOptions.outDir).toBe('./dist');
1714
+ expect(cfg.compilerOptions.strict).toBe(true);
1715
+ expect(cfg.include).toEqual(['src']);
1716
+ expect(raw.endsWith('}\n')).toBe(true);
1717
+ });
1718
+ });
@@ -303,3 +303,110 @@ describe('createTypescriptPlugin (sdk) — area / subarea grouping', () => {
303
303
  expect(sdk).not.toMatch(/class \w+Client \{/m);
304
304
  });
305
305
  });
306
+
307
+ describe('createTypescriptPlugin (sdk) — scaffold', () => {
308
+ // ctx variant that records the ifAbsent flag passed to emitFile.
309
+ function makeScaffoldCtx(rootDir = '/project', options: Record<string, unknown> = {}): PluginContext & {
310
+ emitted: Map<string, { content: string; ifAbsent?: boolean }>;
311
+ } {
312
+ const emitted = new Map<string, { content: string; ifAbsent?: boolean }>();
313
+ return {
314
+ rootDir,
315
+ options,
316
+ cacheEnabled: true,
317
+ cacheDir: `${rootDir}/.contractkit/cache`,
318
+ emitFile: (outPath: string, content: string, opts?: { ifAbsent?: boolean }) => {
319
+ emitted.set(outPath, { content, ifAbsent: opts?.ifAbsent });
320
+ },
321
+ emitted,
322
+ };
323
+ }
324
+
325
+ function find(emitted: Map<string, { content: string; ifAbsent?: boolean }>, suffix: string) {
326
+ for (const [path, value] of emitted) if (path.endsWith(suffix)) return { path, ...value };
327
+ return undefined;
328
+ }
329
+
330
+ function eventInputs() {
331
+ // An op that responds with the Event model (has a datetime field) so the type
332
+ // is publicly reachable and surfaces into the SDK.
333
+ const root = opRoot(
334
+ [opRoute('/events', [opOperation('get', { sdk: 'getEvent', responses: [opResponse(200, 'Event', 'application/json')] })])],
335
+ '/project/contracts/events.ck',
336
+ );
337
+ return {
338
+ contractRoots: [contractRoot([model('Event', [field('id', scalarType('uuid')), field('at', scalarType('datetime'))])], '/project/contracts/events.ck')],
339
+ opRoots: [root],
340
+ modelOutPaths: new Map<string, string>(),
341
+ modelsWithInput: new Set<string>(),
342
+ modelsWithOutput: new Set<string>(),
343
+ };
344
+ }
345
+
346
+ it('does not emit scaffold files unless scaffold is enabled', async () => {
347
+ const plugin = createTypescriptPlugin(
348
+ { sdk: { baseDir: 'packages/sdk', output: { sdk: 'src/sdk.ts', clients: 'src/{filename}.client.ts', types: 'src/types/{filename}.ts' } } },
349
+ '/project',
350
+ );
351
+ const ctx = makeScaffoldCtx();
352
+ await plugin.generateTargets!(eventInputs(), ctx);
353
+ expect(find(ctx.emitted, 'package.json')).toBeUndefined();
354
+ expect(find(ctx.emitted, 'tsconfig.json')).toBeUndefined();
355
+ });
356
+
357
+ it('emits package.json and tsconfig.json at the SDK baseDir as ifAbsent files', async () => {
358
+ const plugin = createTypescriptPlugin(
359
+ {
360
+ sdk: {
361
+ baseDir: 'packages/sdk',
362
+ name: 'my-sdk',
363
+ zod: true,
364
+ scaffold: true,
365
+ output: { sdk: 'src/sdk.ts', clients: 'src/{filename}.client.ts', types: 'src/types/{filename}.ts' },
366
+ },
367
+ },
368
+ '/project',
369
+ );
370
+ const ctx = makeScaffoldCtx();
371
+ await plugin.generateTargets!(eventInputs(), ctx);
372
+
373
+ const pkg = find(ctx.emitted, 'packages/sdk/package.json');
374
+ const tsconfig = find(ctx.emitted, 'packages/sdk/tsconfig.json');
375
+ expect(pkg).toBeDefined();
376
+ expect(tsconfig).toBeDefined();
377
+ // Both are write-once scaffold files.
378
+ expect(pkg!.ifAbsent).toBe(true);
379
+ expect(tsconfig!.ifAbsent).toBe(true);
380
+ // Name carried from sdk.name; zod (enabled) and luxon (datetime field) detected.
381
+ const parsed = JSON.parse(pkg!.content);
382
+ expect(parsed.name).toBe('my-sdk');
383
+ expect(parsed.dependencies.zod).toBeDefined();
384
+ expect(parsed.dependencies.luxon).toBeDefined();
385
+ });
386
+
387
+ it('omits luxon when no covered model uses a date/time scalar', async () => {
388
+ const root = opRoot(
389
+ [opRoute('/things', [opOperation('get', { sdk: 'getThing', responses: [opResponse(200, 'Thing', 'application/json')] })])],
390
+ '/project/contracts/things.ck',
391
+ );
392
+ const plugin = createTypescriptPlugin(
393
+ { sdk: { baseDir: 'packages/sdk', scaffold: true, output: { sdk: 'src/sdk.ts', clients: 'src/{filename}.client.ts', types: 'src/types/{filename}.ts' } } },
394
+ '/project',
395
+ );
396
+ const ctx = makeScaffoldCtx();
397
+ await plugin.generateTargets!(
398
+ {
399
+ contractRoots: [contractRoot([model('Thing', [field('id', scalarType('uuid'))])], '/project/contracts/things.ck')],
400
+ opRoots: [root],
401
+ modelOutPaths: new Map<string, string>(),
402
+ modelsWithInput: new Set<string>(),
403
+ modelsWithOutput: new Set<string>(),
404
+ },
405
+ ctx,
406
+ );
407
+ const pkg = JSON.parse(find(ctx.emitted, 'packages/sdk/package.json')!.content);
408
+ expect(pkg.dependencies?.luxon).toBeUndefined();
409
+ // zod also absent (zod not enabled) → no dependencies block at all.
410
+ expect(pkg.dependencies).toBeUndefined();
411
+ });
412
+ });