@contractkit/plugin-typescript 0.26.0 → 0.27.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.27.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,7 +26,7 @@
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
32
  "@repo/config-typescript": "0.1.0",
@@ -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 ─────────────────────────────────────────────────────
@@ -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
+ });