Skip to content

Commit afb19b0

Browse files
committed
tsdk: Add watch mode
1 parent fb1aaaa commit afb19b0

File tree

7 files changed

+373
-56
lines changed

7 files changed

+373
-56
lines changed

examples/server/package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
"start:dev": "nest start --watch",
1313
"start:debug": "nest start --debug --watch",
1414
"start:prod": "node dist/main",
15-
"sync-sdk": "node ../../packages/tsdk/bin/tsdk.js --sync",
16-
"sync": "node ../../packages/tsdk/bin/tsdk.js --sync",
17-
"bun:sync": "bun ../../packages/tsdk/bin/tsdk.js --sync",
15+
"sync-sdk": "node ../../packages/tsdk/bin/tsdk.js --sync --build",
16+
"sync": "node ../../packages/tsdk/bin/tsdk.js --sync --build",
17+
"bun:sync": "bun ../../packages/tsdk/bin/tsdk.js --sync --build",
1818
"test": "vitest --run ./src/",
1919
"lint": "eslint . --ext .ts,.tsx --fix ./src",
20-
"checktype": "tsc --noEmit"
20+
"checktype": "tsc --noEmit",
21+
"build-sdk": "tsdk --sync --build"
2122
},
2223
"dependencies": {
2324
"tsdk-server-adapters": "workspace:*",

examples/server/src/modules/todo/Todo.entity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ export class Todo extends CreatedUpdatedAt {
2424
/** title */
2525
title: string;
2626

27-
@Column()
27+
@Column({ type: 'text' })
2828
/** status */
29-
status: string;
29+
status: TodoStatus;
3030

3131
@Column({
3232
type: 'text',

packages/tsdk/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@
5252
"dependencies": {
5353
"fast-glob": "^3.3.3",
5454
"fs-extra": "^11.3.0",
55-
"js-yaml": "^4.1.1"
55+
"js-yaml": "^4.1.1",
56+
"@parcel/watcher": "^2.5.4"
5657
},
5758
"devDependencies": {
5859
"@types/js-yaml": "^4.0.9",

packages/tsdk/src/cli.ts

Lines changed: 210 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,71 @@ import { runNestCommand } from './run-nest-command';
88
import symbols from './symbols';
99
import { copyPermissionsJSON, deleteSDKFolder, syncAPI } from './sync-api';
1010
import { addDepsIfNone, copyTsdkConfig, syncFiles } from './sync-files';
11-
import { measureExecutionTime } from './utils';
11+
import { measureExecutionTime, replaceWindowsPath } from './utils';
12+
import * as watcher from '@parcel/watcher';
13+
import * as path from 'path';
14+
15+
// CLI command definitions
16+
// Purpose: Define all CLI commands, their help text, and usage examples
17+
// Each command should have clear, structured documentation
1218

1319
const CLI_COMMANDS = {
1420
help: `
15-
Usage
16-
$ tsdk
17-
18-
Options
19-
--help Help
20-
--init Initialize \`tsdk\` config file
21-
--init --no-zod Don't add \`zod\` to deps
22-
--sync Sync files and generate api
23-
--sync --no-vscode Skip copy \`.vscode/\`
24-
--sync --no-zod Don't add \`zod\` to deps
25-
--sync --no-overwrite Default is overwrite with template files(no overwrite for create custom files)
26-
--nest Run nest command, only support build
27-
--openapi Translate openapi.yaml or openapi.json to openapi.apiconf.ts
28-
--version The version info
29-
30-
Examples
21+
Usage:
22+
$ tsdk [command] [options]
23+
24+
Commands:
25+
--help Show this help message
26+
--version Show version information
27+
--init Initialize tsdk configuration file
28+
--sync Sync files and generate API
29+
--watch Watch mode for continuous sync
30+
--nest <command> Run NestJS build commands
31+
--openapi <file> Convert OpenAPI spec to apiconf.ts (Better use with -o <output dir>)
32+
33+
Options:
34+
--build Run tsc build after sync (with --sync)
35+
--no-zod Skip adding zod to dependencies (with --init or --sync)
36+
--no-vscode Skip copying .vscode/ directory (with --sync)
37+
--no-overwrite Preserve existing files, only create new ones (with --sync)
38+
39+
Examples:
3140
$ tsdk --version
3241
$ tsdk --help
3342
$ tsdk --init
43+
$ tsdk --init --no-zod
3444
$ tsdk --sync
45+
$ tsdk --sync --build
3546
$ tsdk --sync --no-overwrite
36-
$
47+
$ tsdk --sync --no-vscode --no-zod
48+
$ tsdk --watch
3749
$ tsdk --nest build
38-
$ tsdk --nest build [name] [name]
50+
$ tsdk --nest build <app-name>
3951
$ tsdk --nest build all
40-
$
41-
$ tsdk --openapi openapi.yaml
42-
$ tsdk --openapi openapi.json
52+
$ tsdk --openapi openapi.yaml -o <ouput-dir>
53+
$ tsdk --openapi openapi.json -o <ouput-dir>
4354
`,
44-
init: `init \`tsdk\` config file`,
45-
sync: `generate api`,
46-
nest: `@nestjs/cli enchance`,
47-
};
55+
56+
// Short descriptions for programmatic use
57+
init: 'Initialize tsdk configuration file',
58+
sync: 'Sync files and generate API code from configuration',
59+
watch: 'Watch for changes and auto-sync',
60+
nest: 'Run NestJS CLI commands (currently supports: build)',
61+
openapi: 'Convert OpenAPI specification (YAML or JSON) to TypeScript API configuration',
62+
version: 'Display tsdk version information',
63+
} as const;
64+
65+
// Validate that a command exists
66+
function isValidCommand(cmd: string): cmd is keyof typeof CLI_COMMANDS {
67+
return cmd in CLI_COMMANDS;
68+
}
69+
70+
// Get command description
71+
function getCommandDescription(cmd: keyof typeof CLI_COMMANDS): string {
72+
return CLI_COMMANDS[cmd];
73+
}
74+
75+
export { CLI_COMMANDS, isValidCommand, getCommandDescription };
4876

4977
const VALID_PROJECT_MSG = `Please run \`tsdk\` in a valid TypeScript project! Check: https://tsdk.dev/docs/start-a-typescript-project`;
5078

@@ -73,18 +101,25 @@ run();
73101
/**
74102
* Handles sync command with parallelization where possible
75103
* @param noOverwrite Whether to use no-overwrite mode
104+
* @param needBuild Run build
76105
*/
77-
async function handleSyncCommand(noOverwrite: boolean): Promise<void> {
106+
async function handleSyncCommand(
107+
noOverwrite: boolean,
108+
needBuild = false,
109+
prettier = true
110+
): Promise<void> {
78111
try {
79-
await measureExecutionTime('Delete SDK folder', () => deleteSDKFolder());
112+
await measureExecutionTime(`Delete SDK ${config.baseDir} folder`, () => deleteSDKFolder());
80113
await measureExecutionTime('Add dependencies if none', () => addDepsIfNone());
81114

82115
const result = await measureExecutionTime('Sync files', () => syncFiles(noOverwrite));
83116

84117
await measureExecutionTime('Generating API', () =>
85118
syncAPI(result?.apiconfs || [], result?.types || [])
86119
);
87-
await measureExecutionTime('Build SDK', () => buildSDK(true));
120+
if (needBuild) {
121+
await measureExecutionTime('Build SDK', () => buildSDK(true));
122+
}
88123

89124
const removeFieldsValue = config.removeFields ?? ['needAuth'];
90125
// Execute these tasks in parallel
@@ -97,15 +132,150 @@ async function handleSyncCommand(noOverwrite: boolean): Promise<void> {
97132
]);
98133
});
99134

100-
const prettierSuccess = await measureExecutionTime('Run Prettier', () => runPrettier());
101-
if (prettierSuccess) console.log(`${symbols.success} Prettier files\n`);
135+
if (prettier) {
136+
const prettierSuccess = await measureExecutionTime('Run Prettier', () => runPrettier());
137+
if (prettierSuccess) console.log(`${symbols.success} Prettier files\n`);
138+
}
102139
} catch (error) {
103140
console.error(`\n${symbols.error} Sync command failed:`);
104141
console.error(error);
105142
process.exit(1);
106143
}
107144
}
108145

146+
/**
147+
* Watch mode implementation using @parcel/watcher
148+
* Monitors apiconf files and triggers sync on changes
149+
* @param noOverwrite Whether to use no-overwrite mode
150+
* @param needBuild Run build after each sync
151+
*/
152+
async function handleWatchCommand(noOverwrite: boolean, needBuild = false): Promise<void> {
153+
console.log(`${symbols.info} Starting watch mode...`);
154+
155+
// Run initial sync
156+
console.log(`${symbols.info} Running initial sync...\n`);
157+
await handleSyncCommand(noOverwrite, needBuild, false);
158+
const pattern = path.join(...config.baseDir.split('/'));
159+
// Determine watch directories from config
160+
const watchDirs: string[] = [pattern];
161+
162+
// Add sharedDirs
163+
if (config.sharedDirs && config.sharedDirs.length > 0) {
164+
config.sharedDirs.forEach((dir) => {
165+
const absoluteDir = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
166+
watchDirs.push(absoluteDir);
167+
});
168+
}
169+
170+
console.log(`\n${symbols.info} Watching for changes in:`);
171+
watchDirs.forEach((dir) => console.log(` - ${dir}`));
172+
console.log(`${symbols.info} Press Ctrl+C to stop\n`);
173+
174+
// Build file extension patterns from config
175+
const { apiconfExt, entityExt, shareExt } = config;
176+
177+
// Track last sync time to debounce rapid changes
178+
let lastSyncTime = Date.now();
179+
let syncTimeout: NodeJS.Timeout | null = null;
180+
const DEBOUNCE_MS = 500;
181+
182+
/**
183+
* Check if file matches watched extensions
184+
* Matches patterns like: *.apiconf.ts, *.entity.ts, *.shared.ts
185+
* shareExt uses pattern: *.{shareExt}.* (e.g., *.shared.ts, *.shared.json)
186+
*/
187+
function isRelevantFile(filePath: string): boolean {
188+
const fileName = path.basename(filePath);
189+
190+
// Check apiconf pattern: *.apiconf.ts
191+
const apiconfPattern = `.${apiconfExt}.ts`;
192+
if (fileName.endsWith(apiconfPattern)) return true;
193+
194+
// Check entity pattern: *.entity.ts
195+
const entityPattern = `.${entityExt}.ts`;
196+
if (fileName.endsWith(entityPattern)) return true;
197+
198+
// Check shareExt pattern: *.{shareExt}.{ext}
199+
// Match files like: *.shared.ts, *.shared.json, *.shared.jpg
200+
// Split on last dot to get the final extension
201+
const parts = fileName.split('.');
202+
if (parts.length >= 3) {
203+
// Check if second-to-last part matches shareExt
204+
const secondToLast = parts[parts.length - 2];
205+
if (secondToLast === shareExt) return true;
206+
}
207+
208+
return false;
209+
}
210+
211+
try {
212+
// Subscribe to all watch directories
213+
const subscriptions = await Promise.all(
214+
watchDirs.map(async (watchDir) => {
215+
return watcher.subscribe(watchDir, async (err, events) => {
216+
if (err) {
217+
console.error(`\n${symbols.error} Watch error in ${watchDir}:`, err);
218+
return;
219+
}
220+
221+
// Filter for relevant file changes based on configured extensions
222+
const relevantChanges = events.filter((event) => isRelevantFile(event.path));
223+
224+
if (relevantChanges.length === 0) return;
225+
226+
// Log detected changes
227+
console.log(`\n${symbols.info} Detected changes:`);
228+
relevantChanges.forEach((event) => {
229+
const relativePath = path.relative(process.cwd(), event.path);
230+
console.log(` ${event.type}: ${relativePath}`);
231+
});
232+
233+
// Debounce: wait for changes to settle before syncing
234+
if (syncTimeout) clearTimeout(syncTimeout);
235+
236+
syncTimeout = setTimeout(async () => {
237+
const now = Date.now();
238+
const timeSinceLastSync = now - lastSyncTime;
239+
240+
if (timeSinceLastSync < DEBOUNCE_MS) return;
241+
242+
lastSyncTime = now;
243+
console.log(`\n${symbols.info} Syncing changes...\n`);
244+
245+
try {
246+
await handleSyncCommand(noOverwrite, needBuild, false);
247+
console.log(
248+
`\n${symbols.success} Sync complete in ${Date.now() - lastSyncTime}ms. Watching for changes...\n`
249+
);
250+
} catch (error) {
251+
console.error(`\n${symbols.error} Sync failed:`, error);
252+
console.log(`\n${symbols.info} Continuing to watch for changes...\n`);
253+
}
254+
}, DEBOUNCE_MS);
255+
});
256+
})
257+
);
258+
259+
// Handle graceful shutdown
260+
const cleanup = async () => {
261+
console.log(`\n\n${symbols.info} Shutting down watch mode...`);
262+
if (syncTimeout) clearTimeout(syncTimeout);
263+
await Promise.all(subscriptions.map((sub) => sub.unsubscribe()));
264+
console.log(`${symbols.success} Watch mode stopped\n`);
265+
process.exit(0);
266+
};
267+
268+
process.on('SIGINT', cleanup);
269+
process.on('SIGTERM', cleanup);
270+
271+
// Keep process alive
272+
await new Promise(() => {});
273+
} catch (error) {
274+
console.error(`\n${symbols.error} Failed to start watch mode:`, error);
275+
process.exit(1);
276+
}
277+
}
278+
109279
/**
110280
* Handles CLI commands
111281
* @param params Command line parameters
@@ -143,7 +313,15 @@ async function handleCommand(params: string[]): Promise<void> {
143313

144314
case '--sync': {
145315
const noOverwrite = params.includes('--no-overwrite');
146-
await handleSyncCommand(noOverwrite);
316+
const withBuild = params.includes('--build');
317+
await handleSyncCommand(noOverwrite, withBuild);
318+
break;
319+
}
320+
321+
case '--watch': {
322+
const noOverwrite = params.includes('--no-overwrite');
323+
const withBuild = params.includes('--build');
324+
await handleWatchCommand(noOverwrite, withBuild);
147325
break;
148326
}
149327

packages/tsdk/src/sync-api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import {
1515
export const baseDir = path.join(path.relative(path.dirname(__filename), process.cwd()), ensureDir);
1616

1717
export function deleteSDKFolder() {
18-
return fsExtra.remove(path.resolve(process.cwd(), config.packageDir, packageFolder));
18+
const dir = path.resolve(process.cwd(), config.packageDir, packageFolder, config.baseDir);
19+
console.info(` Deleting ${dir}`);
20+
return fsExtra.remove(dir);
1921
}
2022

2123
export async function syncAPI(

packages/tsdk/src/sync-files.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ async function reconfigPkg() {
218218
pkgJSON.scripts = {
219219
...(pkgJSON.scripts || {}),
220220
'sync-sdk': pkgJSON.scripts?.['sync-sdk'] || `tsdk --sync`,
221+
'build-sdk': pkgJSON.scripts?.['build-sdk'] || `tsdk --sync --build`,
221222
};
222223
await fs.promises.writeFile('./package.json', JSON.stringify(pkgJSON, null, 2));
223224
}

0 commit comments

Comments
 (0)