ts-ag 1.0.18 → 1.0.20

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.
@@ -1,3 +1,3 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  export {};
3
3
  //# sourceMappingURL=ts-alias.d.ts.map
@@ -1,16 +1,20 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  import console from 'console';
3
3
  // NOTE: dont use aliases here cause this file needs to be compiled first
4
- import { existsSync, statSync, readFileSync } from 'fs';
5
- import { dirname, join, basename } from 'path';
4
+ import { existsSync } from 'fs';
5
+ import { dirname, join, basename, relative } from 'path';
6
6
  import { watch } from 'chokidar';
7
7
  import { glob } from 'glob';
8
8
  import { replaceTscAliasPaths } from 'tsc-alias';
9
- // Cache for tsconfig.json files
10
- const tsconfigCache = new Map();
9
+ import { colorText } from '../utils/cli.js';
11
10
  // Parse command-line arguments
12
11
  const args = process.argv.slice(2);
13
12
  const watchMode = args.includes('-w') || args.includes('--watch');
13
+ const LABEL = colorText('cyan', '[ts-alias]');
14
+ const formatPath = (filePath) => colorText('dim', relative(process.cwd(), filePath));
15
+ const logInfo = (message) => console.log(`${LABEL} ${message}`);
16
+ const logWarn = (message) => console.warn(`${LABEL} ${colorText('yellow', message)}`);
17
+ const logError = (message) => console.error(`${LABEL} ${colorText('red', message)}`);
14
18
  /**
15
19
  * Find all dist folders in the project directory, excluding certain patterns.
16
20
  */
@@ -27,31 +31,6 @@ async function findDistFolders(baseDir) {
27
31
  const distFolders = await glob('**/dist', { cwd: baseDir, ignore: ignorePatterns, absolute: true });
28
32
  return distFolders;
29
33
  }
30
- /**
31
- * Get the tsconfig.json file for a given dist folder.
32
- * This function caches the tsconfig file to avoid reading it multiple times.
33
- */
34
- function getTsconfig(distFolder) {
35
- const projectRoot = dirname(distFolder);
36
- const tsconfigPath = join(projectRoot, 'tsconfig.json');
37
- try {
38
- const stats = statSync(tsconfigPath);
39
- const mtime = stats.mtimeMs;
40
- // Check cache
41
- const cached = tsconfigCache.get(tsconfigPath);
42
- if (cached && cached.mtime === mtime) {
43
- return cached.config;
44
- }
45
- // Read and cache the config
46
- const config = JSON.parse(readFileSync(tsconfigPath, 'utf8'));
47
- tsconfigCache.set(tsconfigPath, { config, mtime });
48
- return config;
49
- }
50
- catch (error) {
51
- console.error(`Error reading tsconfig at ${tsconfigPath}:`, error);
52
- return null;
53
- }
54
- }
55
34
  /**
56
35
  * Process the dist folder by replacing TypeScript alias paths with relative paths.
57
36
  */
@@ -59,42 +38,44 @@ async function processDistFolder(distFolder) {
59
38
  const projectRoot = dirname(distFolder);
60
39
  const tsconfigPath = join(projectRoot, 'tsconfig.json');
61
40
  if (!existsSync(tsconfigPath)) {
62
- console.warn(`No tsconfig.json found at ${tsconfigPath}`);
63
- return;
64
- }
65
- const tsconfig = getTsconfig(distFolder);
66
- if (!tsconfig) {
67
- console.warn(`Invalid tsconfig.json found for ${distFolder}`);
41
+ logWarn(`No tsconfig.json at ${formatPath(tsconfigPath)}`);
68
42
  return;
69
43
  }
70
44
  try {
71
45
  await replaceTscAliasPaths({ configFile: tsconfigPath, outDir: distFolder });
72
- console.log(`Successfully processed aliases in ${distFolder}`);
46
+ logInfo(`${colorText('green', 'updated')} ${formatPath(distFolder)}`);
73
47
  }
74
48
  catch (error) {
75
- console.error(`Error processing aliases in ${distFolder}:`, error);
49
+ logError(`Failed processing ${formatPath(distFolder)}`);
50
+ console.error(error);
76
51
  }
77
52
  }
78
53
  /**
79
54
  * Watch the dist folder for changes and process it when files are added or changed.
80
55
  */
81
56
  function watchDistFolder(distFolder) {
82
- console.log(`Setting up watcher for: ${distFolder}`);
57
+ let pending = null;
83
58
  const watcher = watch(distFolder, {
84
59
  persistent: true,
85
60
  ignoreInitial: true,
86
61
  awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
87
62
  });
63
+ const scheduleProcess = () => {
64
+ if (pending)
65
+ clearTimeout(pending);
66
+ pending = setTimeout(() => {
67
+ pending = null;
68
+ void processDistFolder(distFolder);
69
+ }, 200);
70
+ };
88
71
  watcher.on('add', (filePath) => {
89
72
  if (filePath.endsWith('.js') || filePath.endsWith('.jsx')) {
90
- console.log(`File added: ${filePath}`);
91
- processDistFolder(distFolder);
73
+ scheduleProcess();
92
74
  }
93
75
  });
94
76
  watcher.on('change', (filePath) => {
95
77
  if (filePath.endsWith('.js') || filePath.endsWith('.jsx')) {
96
- console.log(`File changed: ${filePath}`);
97
- processDistFolder(distFolder);
78
+ scheduleProcess();
98
79
  }
99
80
  });
100
81
  return watcher;
@@ -102,26 +83,25 @@ function watchDistFolder(distFolder) {
102
83
  // Main function
103
84
  async function main() {
104
85
  const baseDir = process.cwd();
105
- console.log(`Searching for dist folders in: ${baseDir}`);
86
+ logInfo(`searching ${colorText('cyan', 'dist')} folders in ${formatPath(baseDir)}`);
106
87
  const distFolders = await findDistFolders(baseDir);
107
88
  // Process all folders initially if any exist
108
89
  if (distFolders.length > 0) {
109
- console.log(`Found ${distFolders.length} dist folders:`);
110
- distFolders.forEach((folder) => console.log(` - ${folder}`));
111
- for (const folder of distFolders) {
112
- await processDistFolder(folder);
113
- }
90
+ logInfo(`found ${distFolders.length} dist folder(s)`);
91
+ await Promise.all(distFolders.map(async (folder) => {
92
+ return await processDistFolder(folder);
93
+ }));
114
94
  }
115
95
  else {
116
- console.log('No dist folders found initially');
96
+ logInfo('no dist folders found');
117
97
  }
118
98
  // Set up watchers if in watch mode
119
99
  if (watchMode) {
120
- console.log('Watch mode enabled, monitoring for changes...');
100
+ logInfo('watch mode enabled');
121
101
  // Set up watchers for existing dist folders
122
102
  distFolders.forEach(watchDistFolder);
123
103
  // Watch for new dist folders being created
124
- console.log('Watching for new dist folders...');
104
+ logInfo('watching for new dist folders...');
125
105
  const dirWatcher = watch(baseDir, {
126
106
  persistent: true,
127
107
  ignoreInitial: true,
@@ -143,7 +123,7 @@ async function main() {
143
123
  if (basename(dirPath) === 'dist') {
144
124
  // Make sure it's not already being watched
145
125
  if (!distFolders.includes(dirPath)) {
146
- console.log(`New dist folder detected: ${dirPath}`);
126
+ logInfo(`${colorText('cyan', 'new dist')} ${formatPath(dirPath)}`);
147
127
  distFolders.push(dirPath);
148
128
  await processDistFolder(dirPath);
149
129
  watchDistFolder(dirPath);
@@ -153,6 +133,7 @@ async function main() {
153
133
  }
154
134
  }
155
135
  main().catch((error) => {
156
- console.error('Error in ts-alias script:', error);
136
+ logError('Unhandled error in ts-alias script');
137
+ console.error(error);
157
138
  process.exit(1);
158
139
  });
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ export {};
3
+ //# sourceMappingURL=ts-build-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ts-build-config.d.ts","sourceRoot":"","sources":["../../src/scripts/ts-build-config.ts"],"names":[],"mappings":""}
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bun
2
+ import console from 'console';
3
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs';
4
+ import { dirname, join, resolve, relative } from 'path';
5
+ import { parseArgs } from 'util';
6
+ import { watch } from 'chokidar';
7
+ import { getTsconfig } from 'get-tsconfig';
8
+ import { colorText } from '../utils/cli.js';
9
+ const DEFAULT_TEST_EXCLUDES = [
10
+ '**/*.test.ts',
11
+ '**/*.test.tsx',
12
+ '**/*.spec.ts',
13
+ '**/*.spec.tsx',
14
+ '**/__tests__/**',
15
+ '**/tests/**'
16
+ ];
17
+ const GENERATED_FILE_HEADER = '// generated by ts-build-config';
18
+ const LABEL = colorText('cyan', '[ts-build-config]');
19
+ const formatPath = (filePath) => colorText('dim', relative(process.cwd(), filePath));
20
+ const logInfo = (message) => console.log(`${LABEL} ${message}`);
21
+ const logWarn = (message) => console.warn(`${LABEL} ${colorText('yellow', message)}`);
22
+ const logError = (message) => console.error(`${LABEL} ${colorText('red', message)}`);
23
+ function asArray(v) {
24
+ if (!v)
25
+ return [];
26
+ return Array.isArray(v) ? v : [v];
27
+ }
28
+ function isObject(v) {
29
+ return !!v && typeof v === 'object' && !Array.isArray(v);
30
+ }
31
+ function uniq(items) {
32
+ return Array.from(new Set(items));
33
+ }
34
+ function normalizeRefPath(baseTsconfigPath, refPath) {
35
+ // TS project references are relative to the tsconfig location
36
+ const baseDir = dirname(baseTsconfigPath);
37
+ // refPath may point to a folder (containing tsconfig.json) or a file
38
+ const abs = resolve(baseDir, refPath);
39
+ return abs;
40
+ }
41
+ function resolveReferencedTsconfigPath(refAbsPath) {
42
+ // If they referenced a file directly, use it
43
+ if (refAbsPath.endsWith('.json')) {
44
+ return existsSync(refAbsPath) ? refAbsPath : null;
45
+ }
46
+ // Otherwise assume it's a directory containing tsconfig.json
47
+ const candidate = join(refAbsPath, 'tsconfig.json');
48
+ if (existsSync(candidate))
49
+ return candidate;
50
+ // Some repos use tsconfig.base.json or similar; we won't guess.
51
+ return null;
52
+ }
53
+ function readTsconfig(tsconfigPath) {
54
+ const res = getTsconfig(tsconfigPath);
55
+ if (!res) {
56
+ throw new Error(`get-tsconfig could not load: ${tsconfigPath}`);
57
+ }
58
+ // get-tsconfig returns a parsed config object (includes extends resolved).
59
+ // We want to write a build tsconfig that extends the original file path,
60
+ // so we mostly only need top-level fields we change (exclude/compilerOptions).
61
+ return res.config;
62
+ }
63
+ function computeExtraExcludes(config) {
64
+ const extra = [...DEFAULT_TEST_EXCLUDES];
65
+ // If the tsconfig includes ./tests (or tests) explicitly, ensure it is excluded in build config.
66
+ const includes = asArray(config.include);
67
+ for (const inc of includes) {
68
+ const norm = inc.replace(/\\/g, '/').replace(/\/+$/, '');
69
+ if (norm === './tests' || norm === 'tests' || norm.startsWith('./tests/') || norm.startsWith('tests/')) {
70
+ extra.push('./tests/**');
71
+ break;
72
+ }
73
+ }
74
+ return uniq(extra);
75
+ }
76
+ function writeBuildTsconfig(tsconfigPath, config, dryRun) {
77
+ const dir = dirname(tsconfigPath);
78
+ const outPath = join(dir, 'tsconfig.build.json');
79
+ const existingExclude = asArray(config.exclude);
80
+ const mergedExclude = uniq([...existingExclude, ...computeExtraExcludes(config)]);
81
+ const buildConfig = { extends: './tsconfig.json', compilerOptions: {}, exclude: mergedExclude };
82
+ // If original compilerOptions exists, keep build-specific overrides minimal.
83
+ // But ensure emit is enabled if base tsconfig has noEmit: true (common for editor configs).
84
+ const baseCO = isObject(config.compilerOptions) ? config.compilerOptions : {};
85
+ if (baseCO.noEmit === true) {
86
+ buildConfig.compilerOptions.noEmit = false;
87
+ }
88
+ // Keep outDir/rootDir if already set in base; do not guess.
89
+ // If you want to force them, do it in the base tsconfig.json or pass flags later.
90
+ if (!dryRun) {
91
+ mkdirSync(dir, { recursive: true });
92
+ writeFileSync(outPath, `${GENERATED_FILE_HEADER}\n${JSON.stringify(buildConfig, null, 2)}\n`, 'utf8');
93
+ }
94
+ return outPath;
95
+ }
96
+ function isGeneratedByThisScript(filePath) {
97
+ try {
98
+ const contents = readFileSync(filePath, 'utf8');
99
+ const firstLine = contents.split(/\r?\n/, 1)[0]?.trim() ?? '';
100
+ return firstLine === GENERATED_FILE_HEADER;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ function getProjectRefs(tsconfigPath, config) {
107
+ const refs = asArray(config.references);
108
+ const result = [];
109
+ for (const r of refs) {
110
+ if (!r)
111
+ continue;
112
+ const refPath = typeof r === 'string' ? r : r.path;
113
+ if (!refPath || typeof refPath !== 'string')
114
+ continue;
115
+ const abs = normalizeRefPath(tsconfigPath, refPath);
116
+ const resolved = resolveReferencedTsconfigPath(abs);
117
+ if (resolved)
118
+ result.push(resolved);
119
+ }
120
+ return uniq(result);
121
+ }
122
+ function generateBuildConfigs(entry, options) {
123
+ const { dryRun, force, verbose } = options;
124
+ const visited = new Set();
125
+ const queue = [entry];
126
+ const created = [];
127
+ while (queue.length) {
128
+ const tsconfigPath = queue.shift();
129
+ if (visited.has(tsconfigPath))
130
+ continue;
131
+ visited.add(tsconfigPath);
132
+ let cfg;
133
+ try {
134
+ cfg = readTsconfig(tsconfigPath);
135
+ }
136
+ catch (e) {
137
+ logWarn(`Skipping unreadable config: ${formatPath(tsconfigPath)}`);
138
+ if (verbose)
139
+ console.warn(e);
140
+ continue;
141
+ }
142
+ const outPath = join(dirname(tsconfigPath), 'tsconfig.build.json');
143
+ const outExists = existsSync(outPath);
144
+ const generatedByScript = outExists && isGeneratedByThisScript(outPath);
145
+ if (outExists && !generatedByScript && !force) {
146
+ if (verbose) {
147
+ logInfo(`Skip ${formatPath(outPath)} (manual file, use ${colorText('bold', '--force')})`);
148
+ }
149
+ }
150
+ else {
151
+ const written = writeBuildTsconfig(tsconfigPath, cfg, dryRun);
152
+ created.push({ src: tsconfigPath, out: written });
153
+ if (verbose || dryRun) {
154
+ const verb = dryRun ? colorText('yellow', '[dry-run] write') : colorText('green', 'write');
155
+ logInfo(`${verb} ${formatPath(written)} <- ${formatPath(tsconfigPath)}`);
156
+ }
157
+ }
158
+ const refs = getProjectRefs(tsconfigPath, cfg);
159
+ for (const r of refs)
160
+ queue.push(r);
161
+ }
162
+ if (!verbose && !dryRun) {
163
+ logInfo(`${colorText('green', 'updated')} ${created.length} tsconfig.build.json file(s)`);
164
+ }
165
+ return { created, visitedConfigs: Array.from(visited) };
166
+ }
167
+ function createRegenerator(entry, options) {
168
+ let isRunning = false;
169
+ let rerunRequested = false;
170
+ let watchedConfigs = new Set();
171
+ const syncWatchedConfigs = (watcher) => {
172
+ if (watchedConfigs.size === 0)
173
+ return;
174
+ watcher.add(Array.from(watchedConfigs));
175
+ };
176
+ const run = async (reason) => {
177
+ if (isRunning) {
178
+ rerunRequested = true;
179
+ return;
180
+ }
181
+ isRunning = true;
182
+ try {
183
+ do {
184
+ rerunRequested = false;
185
+ if (reason) {
186
+ logInfo(`${colorText('cyan', 'regenerate')} (${reason})`);
187
+ reason = undefined;
188
+ }
189
+ const result = generateBuildConfigs(entry, options);
190
+ watchedConfigs = new Set(result.visitedConfigs);
191
+ } while (rerunRequested);
192
+ }
193
+ finally {
194
+ isRunning = false;
195
+ }
196
+ };
197
+ return { run, syncWatchedConfigs };
198
+ }
199
+ async function main() {
200
+ const { values } = parseArgs({
201
+ args: process.argv.slice(2),
202
+ options: {
203
+ config: { type: 'string', short: 'c' },
204
+ dryRun: { type: 'boolean' },
205
+ force: { type: 'boolean' }, // overwrite files not generated by this script
206
+ watch: { type: 'boolean', short: 'w' },
207
+ verbose: { type: 'boolean', short: 'v' }
208
+ }
209
+ });
210
+ const entry = values.config ? resolve(values.config) : null;
211
+ if (!entry) {
212
+ logError('Missing required flag: --config <path/to/tsconfig.json>');
213
+ process.exit(1);
214
+ }
215
+ if (!existsSync(entry)) {
216
+ logError(`Config file not found: ${formatPath(entry)}`);
217
+ process.exit(1);
218
+ }
219
+ const dryRun = values.dryRun === true;
220
+ const force = values.force === true;
221
+ const watchMode = values.watch === true;
222
+ const verbose = values.verbose === true;
223
+ const options = { dryRun, force, verbose };
224
+ const regenerator = createRegenerator(entry, options);
225
+ await regenerator.run('initial run');
226
+ if (!watchMode) {
227
+ return;
228
+ }
229
+ logInfo(`watching ${colorText('cyan', 'tsconfig')} changes...`);
230
+ const watcher = watch([], {
231
+ persistent: true,
232
+ ignoreInitial: true,
233
+ awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
234
+ });
235
+ regenerator.syncWatchedConfigs(watcher);
236
+ watcher.on('change', async (filePath) => {
237
+ logInfo(`${colorText('cyan', 'change')} ${formatPath(filePath)}`);
238
+ await regenerator.run(`changed ${relative(process.cwd(), filePath)}`);
239
+ regenerator.syncWatchedConfigs(watcher);
240
+ });
241
+ watcher.on('add', async (filePath) => {
242
+ logInfo(`${colorText('cyan', 'add')} ${formatPath(filePath)}`);
243
+ await regenerator.run(`added ${relative(process.cwd(), filePath)}`);
244
+ regenerator.syncWatchedConfigs(watcher);
245
+ });
246
+ watcher.on('unlink', async (filePath) => {
247
+ logInfo(`${colorText('yellow', 'remove')} ${formatPath(filePath)}`);
248
+ await regenerator.run(`removed ${relative(process.cwd(), filePath)}`);
249
+ regenerator.syncWatchedConfigs(watcher);
250
+ });
251
+ }
252
+ main().catch((err) => {
253
+ logError('Unhandled error');
254
+ console.error(err);
255
+ process.exit(1);
256
+ });
@@ -0,0 +1,2 @@
1
+ export declare const colorText: (format: (("bgBlack" | "bgBlackBright" | "bgBlue" | "bgBlueBright" | "bgCyan" | "bgCyanBright" | "bgGray" | "bgGreen" | "bgGreenBright" | "bgGrey" | "bgMagenta" | "bgMagentaBright" | "bgRed" | "bgRedBright" | "bgWhite" | "bgWhiteBright" | "bgYellow" | "bgYellowBright") | ("black" | "blackBright" | "blue" | "blueBright" | "cyan" | "cyanBright" | "gray" | "green" | "greenBright" | "grey" | "magenta" | "magentaBright" | "red" | "redBright" | "white" | "whiteBright" | "yellow" | "yellowBright") | ("blink" | "bold" | "dim" | "doubleunderline" | "framed" | "hidden" | "inverse" | "italic" | "none" | "overlined" | "reset" | "strikethrough" | "underline"))[] | ("bgBlack" | "bgBlackBright" | "bgBlue" | "bgBlueBright" | "bgCyan" | "bgCyanBright" | "bgGray" | "bgGreen" | "bgGreenBright" | "bgGrey" | "bgMagenta" | "bgMagentaBright" | "bgRed" | "bgRedBright" | "bgWhite" | "bgWhiteBright" | "bgYellow" | "bgYellowBright") | ("black" | "blackBright" | "blue" | "blueBright" | "cyan" | "cyanBright" | "gray" | "green" | "greenBright" | "grey" | "magenta" | "magentaBright" | "red" | "redBright" | "white" | "whiteBright" | "yellow" | "yellowBright") | ("blink" | "bold" | "dim" | "doubleunderline" | "framed" | "hidden" | "inverse" | "italic" | "none" | "overlined" | "reset" | "strikethrough" | "underline"), text: unknown) => string;
2
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/utils/cli.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,SAAS,ozCACsC,CAAC"}
@@ -0,0 +1,2 @@
1
+ import { styleText } from 'util';
2
+ export const colorText = (format, text) => styleText(format, String(text), { validateStream: false });
@@ -1,3 +1,4 @@
1
1
  export * from './browser.js';
2
2
  export * from './fs.js';
3
+ export * from './cli.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAE7B,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAE7B,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC"}
@@ -1,2 +1,3 @@
1
1
  export * from './browser.js';
2
2
  export * from './fs.js';
3
+ export * from './cli.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-ag",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Useful TS stuff",
5
5
  "bugs": "https://github.com/ageorgeh/ts-ag/issues",
6
6
  "author": "Alexander Hornung",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "bin": {
12
12
  "clean-dist": "dist/scripts/clean-dist.js",
13
- "ts-alias": "dist/scripts/ts-alias.js"
13
+ "ts-alias": "dist/scripts/ts-alias.js",
14
+ "ts-build-config": "dist/scripts/ts-build-config.js"
14
15
  },
15
16
  "files": [
16
17
  "./src",
@@ -41,9 +42,9 @@
41
42
  "tsc:watch": "concurrently \" npx tsc -w \" \" npx ts-alias -w \""
42
43
  },
43
44
  "dependencies": {
44
- "@aws-sdk/client-cognito-identity-provider": "3.999.0",
45
- "@aws-sdk/client-s3": "3.999.0",
46
- "@aws-sdk/s3-request-presigner": "3.999.0",
45
+ "@aws-sdk/client-cognito-identity-provider": "3.1000.0",
46
+ "@aws-sdk/client-s3": "3.1000.0",
47
+ "@aws-sdk/s3-request-presigner": "3.1000.0",
47
48
  "@types/aws-lambda": "8.10.161",
48
49
  "@ungap/structured-clone": "1.3.0",
49
50
  "chalk": "5.6.2",
@@ -69,7 +70,7 @@
69
70
  "@types/hast": "3.0.4",
70
71
  "@types/node": "^24.10.15",
71
72
  "@types/ungap__structured-clone": "1.2.0",
72
- "@typescript/native-preview": "7.0.0-dev.20260226.1",
73
+ "@typescript/native-preview": "7.0.0-dev.20260227.1",
73
74
  "concurrently": "^9.2.1",
74
75
  "globals": "^17.3.0",
75
76
  "husky": "^9.1.7",
@@ -1,21 +1,26 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
 
3
3
  import console from 'console';
4
4
  // NOTE: dont use aliases here cause this file needs to be compiled first
5
- import { existsSync, statSync, readFileSync } from 'fs';
6
- import { dirname, join, basename } from 'path';
5
+ import { existsSync } from 'fs';
6
+ import { dirname, join, basename, relative } from 'path';
7
7
 
8
8
  import type { FSWatcher } from 'chokidar';
9
9
  import { watch } from 'chokidar';
10
10
  import { glob } from 'glob';
11
11
  import { replaceTscAliasPaths } from 'tsc-alias';
12
12
 
13
- // Cache for tsconfig.json files
14
- const tsconfigCache: Map<string, { config: any; mtime: number }> = new Map();
13
+ import { colorText } from '../utils/cli.js';
15
14
 
16
15
  // Parse command-line arguments
17
16
  const args = process.argv.slice(2);
18
17
  const watchMode = args.includes('-w') || args.includes('--watch');
18
+ const LABEL = colorText('cyan', '[ts-alias]');
19
+
20
+ const formatPath = (filePath: string): string => colorText('dim', relative(process.cwd(), filePath));
21
+ const logInfo = (message: string): void => console.log(`${LABEL} ${message}`);
22
+ const logWarn = (message: string): void => console.warn(`${LABEL} ${colorText('yellow', message)}`);
23
+ const logError = (message: string): void => console.error(`${LABEL} ${colorText('red', message)}`);
19
24
 
20
25
  /**
21
26
  * Find all dist folders in the project directory, excluding certain patterns.
@@ -36,35 +41,6 @@ async function findDistFolders(baseDir: string): Promise<string[]> {
36
41
  return distFolders;
37
42
  }
38
43
 
39
- /**
40
- * Get the tsconfig.json file for a given dist folder.
41
- * This function caches the tsconfig file to avoid reading it multiple times.
42
- */
43
- function getTsconfig(distFolder: string): any {
44
- const projectRoot = dirname(distFolder);
45
- const tsconfigPath = join(projectRoot, 'tsconfig.json');
46
-
47
- try {
48
- const stats = statSync(tsconfigPath);
49
- const mtime = stats.mtimeMs;
50
-
51
- // Check cache
52
- const cached = tsconfigCache.get(tsconfigPath);
53
- if (cached && cached.mtime === mtime) {
54
- return cached.config;
55
- }
56
-
57
- // Read and cache the config
58
- const config = JSON.parse(readFileSync(tsconfigPath, 'utf8'));
59
- tsconfigCache.set(tsconfigPath, { config, mtime });
60
-
61
- return config;
62
- } catch (error) {
63
- console.error(`Error reading tsconfig at ${tsconfigPath}:`, error);
64
- return null;
65
- }
66
- }
67
-
68
44
  /**
69
45
  * Process the dist folder by replacing TypeScript alias paths with relative paths.
70
46
  */
@@ -73,22 +49,16 @@ async function processDistFolder(distFolder: string): Promise<void> {
73
49
  const tsconfigPath = join(projectRoot, 'tsconfig.json');
74
50
 
75
51
  if (!existsSync(tsconfigPath)) {
76
- console.warn(`No tsconfig.json found at ${tsconfigPath}`);
77
- return;
78
- }
79
-
80
- const tsconfig = getTsconfig(distFolder);
81
-
82
- if (!tsconfig) {
83
- console.warn(`Invalid tsconfig.json found for ${distFolder}`);
52
+ logWarn(`No tsconfig.json at ${formatPath(tsconfigPath)}`);
84
53
  return;
85
54
  }
86
55
 
87
56
  try {
88
57
  await replaceTscAliasPaths({ configFile: tsconfigPath, outDir: distFolder });
89
- console.log(`Successfully processed aliases in ${distFolder}`);
58
+ logInfo(`${colorText('green', 'updated')} ${formatPath(distFolder)}`);
90
59
  } catch (error) {
91
- console.error(`Error processing aliases in ${distFolder}:`, error);
60
+ logError(`Failed processing ${formatPath(distFolder)}`);
61
+ console.error(error);
92
62
  }
93
63
  }
94
64
 
@@ -96,7 +66,7 @@ async function processDistFolder(distFolder: string): Promise<void> {
96
66
  * Watch the dist folder for changes and process it when files are added or changed.
97
67
  */
98
68
  function watchDistFolder(distFolder: string): FSWatcher {
99
- console.log(`Setting up watcher for: ${distFolder}`);
69
+ let pending: ReturnType<typeof setTimeout> | null = null;
100
70
 
101
71
  const watcher = watch(distFolder, {
102
72
  persistent: true,
@@ -104,17 +74,23 @@ function watchDistFolder(distFolder: string): FSWatcher {
104
74
  awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
105
75
  });
106
76
 
77
+ const scheduleProcess = (): void => {
78
+ if (pending) clearTimeout(pending);
79
+ pending = setTimeout(() => {
80
+ pending = null;
81
+ void processDistFolder(distFolder);
82
+ }, 200);
83
+ };
84
+
107
85
  watcher.on('add', (filePath) => {
108
86
  if (filePath.endsWith('.js') || filePath.endsWith('.jsx')) {
109
- console.log(`File added: ${filePath}`);
110
- processDistFolder(distFolder);
87
+ scheduleProcess();
111
88
  }
112
89
  });
113
90
 
114
91
  watcher.on('change', (filePath) => {
115
92
  if (filePath.endsWith('.js') || filePath.endsWith('.jsx')) {
116
- console.log(`File changed: ${filePath}`);
117
- processDistFolder(distFolder);
93
+ scheduleProcess();
118
94
  }
119
95
  });
120
96
 
@@ -124,31 +100,32 @@ function watchDistFolder(distFolder: string): FSWatcher {
124
100
  // Main function
125
101
  async function main(): Promise<void> {
126
102
  const baseDir = process.cwd();
127
- console.log(`Searching for dist folders in: ${baseDir}`);
103
+ logInfo(`searching ${colorText('cyan', 'dist')} folders in ${formatPath(baseDir)}`);
128
104
 
129
105
  const distFolders = await findDistFolders(baseDir);
130
106
 
131
107
  // Process all folders initially if any exist
132
108
  if (distFolders.length > 0) {
133
- console.log(`Found ${distFolders.length} dist folders:`);
134
- distFolders.forEach((folder) => console.log(` - ${folder}`));
109
+ logInfo(`found ${distFolders.length} dist folder(s)`);
135
110
 
136
- for (const folder of distFolders) {
137
- await processDistFolder(folder);
138
- }
111
+ await Promise.all(
112
+ distFolders.map(async (folder) => {
113
+ return await processDistFolder(folder);
114
+ })
115
+ );
139
116
  } else {
140
- console.log('No dist folders found initially');
117
+ logInfo('no dist folders found');
141
118
  }
142
119
 
143
120
  // Set up watchers if in watch mode
144
121
  if (watchMode) {
145
- console.log('Watch mode enabled, monitoring for changes...');
122
+ logInfo('watch mode enabled');
146
123
 
147
124
  // Set up watchers for existing dist folders
148
125
  distFolders.forEach(watchDistFolder);
149
126
 
150
127
  // Watch for new dist folders being created
151
- console.log('Watching for new dist folders...');
128
+ logInfo('watching for new dist folders...');
152
129
  const dirWatcher = watch(baseDir, {
153
130
  persistent: true,
154
131
  ignoreInitial: true,
@@ -171,7 +148,7 @@ async function main(): Promise<void> {
171
148
  if (basename(dirPath) === 'dist') {
172
149
  // Make sure it's not already being watched
173
150
  if (!distFolders.includes(dirPath)) {
174
- console.log(`New dist folder detected: ${dirPath}`);
151
+ logInfo(`${colorText('cyan', 'new dist')} ${formatPath(dirPath)}`);
175
152
  distFolders.push(dirPath);
176
153
  await processDistFolder(dirPath);
177
154
  watchDistFolder(dirPath);
@@ -182,6 +159,7 @@ async function main(): Promise<void> {
182
159
  }
183
160
 
184
161
  main().catch((error) => {
185
- console.error('Error in ts-alias script:', error);
162
+ logError('Unhandled error in ts-alias script');
163
+ console.error(error);
186
164
  process.exit(1);
187
165
  });
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import console from 'console';
4
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'fs';
5
+ import { dirname, join, resolve, relative } from 'path';
6
+ import { parseArgs } from 'util';
7
+
8
+ import type { FSWatcher } from 'chokidar';
9
+ import { watch } from 'chokidar';
10
+ import { getTsconfig } from 'get-tsconfig';
11
+
12
+ import { colorText } from '../utils/cli.js';
13
+
14
+ type TsConfigJson = Record<string, any>;
15
+
16
+ const DEFAULT_TEST_EXCLUDES = [
17
+ '**/*.test.ts',
18
+ '**/*.test.tsx',
19
+ '**/*.spec.ts',
20
+ '**/*.spec.tsx',
21
+ '**/__tests__/**',
22
+ '**/tests/**'
23
+ ];
24
+ const GENERATED_FILE_HEADER = '// generated by ts-build-config';
25
+
26
+ const LABEL = colorText('cyan', '[ts-build-config]');
27
+ const formatPath = (filePath: string): string => colorText('dim', relative(process.cwd(), filePath));
28
+ const logInfo = (message: string): void => console.log(`${LABEL} ${message}`);
29
+ const logWarn = (message: string): void => console.warn(`${LABEL} ${colorText('yellow', message)}`);
30
+ const logError = (message: string): void => console.error(`${LABEL} ${colorText('red', message)}`);
31
+
32
+ function asArray<T>(v: unknown): T[] {
33
+ if (!v) return [];
34
+ return Array.isArray(v) ? (v as T[]) : [v as T];
35
+ }
36
+
37
+ function isObject(v: unknown): v is Record<string, any> {
38
+ return !!v && typeof v === 'object' && !Array.isArray(v);
39
+ }
40
+
41
+ function uniq(items: string[]): string[] {
42
+ return Array.from(new Set(items));
43
+ }
44
+
45
+ function normalizeRefPath(baseTsconfigPath: string, refPath: string): string {
46
+ // TS project references are relative to the tsconfig location
47
+ const baseDir = dirname(baseTsconfigPath);
48
+ // refPath may point to a folder (containing tsconfig.json) or a file
49
+ const abs = resolve(baseDir, refPath);
50
+ return abs;
51
+ }
52
+
53
+ function resolveReferencedTsconfigPath(refAbsPath: string): string | null {
54
+ // If they referenced a file directly, use it
55
+ if (refAbsPath.endsWith('.json')) {
56
+ return existsSync(refAbsPath) ? refAbsPath : null;
57
+ }
58
+
59
+ // Otherwise assume it's a directory containing tsconfig.json
60
+ const candidate = join(refAbsPath, 'tsconfig.json');
61
+ if (existsSync(candidate)) return candidate;
62
+
63
+ // Some repos use tsconfig.base.json or similar; we won't guess.
64
+ return null;
65
+ }
66
+
67
+ function readTsconfig(tsconfigPath: string): TsConfigJson {
68
+ const res = getTsconfig(tsconfigPath);
69
+ if (!res) {
70
+ throw new Error(`get-tsconfig could not load: ${tsconfigPath}`);
71
+ }
72
+ // get-tsconfig returns a parsed config object (includes extends resolved).
73
+ // We want to write a build tsconfig that extends the original file path,
74
+ // so we mostly only need top-level fields we change (exclude/compilerOptions).
75
+ return res.config as TsConfigJson;
76
+ }
77
+
78
+ function computeExtraExcludes(config: TsConfigJson): string[] {
79
+ const extra: string[] = [...DEFAULT_TEST_EXCLUDES];
80
+
81
+ // If the tsconfig includes ./tests (or tests) explicitly, ensure it is excluded in build config.
82
+ const includes = asArray<string>(config.include);
83
+ for (const inc of includes) {
84
+ const norm = inc.replace(/\\/g, '/').replace(/\/+$/, '');
85
+ if (norm === './tests' || norm === 'tests' || norm.startsWith('./tests/') || norm.startsWith('tests/')) {
86
+ extra.push('./tests/**');
87
+ break;
88
+ }
89
+ }
90
+
91
+ return uniq(extra);
92
+ }
93
+
94
+ function writeBuildTsconfig(tsconfigPath: string, config: TsConfigJson, dryRun: boolean): string {
95
+ const dir = dirname(tsconfigPath);
96
+ const outPath = join(dir, 'tsconfig.build.json');
97
+
98
+ const existingExclude = asArray<string>(config.exclude);
99
+ const mergedExclude = uniq([...existingExclude, ...computeExtraExcludes(config)]);
100
+
101
+ const buildConfig: TsConfigJson = { extends: './tsconfig.json', compilerOptions: {}, exclude: mergedExclude };
102
+
103
+ // If original compilerOptions exists, keep build-specific overrides minimal.
104
+ // But ensure emit is enabled if base tsconfig has noEmit: true (common for editor configs).
105
+ const baseCO = isObject(config.compilerOptions) ? config.compilerOptions : {};
106
+ if (baseCO.noEmit === true) {
107
+ buildConfig.compilerOptions.noEmit = false;
108
+ }
109
+
110
+ // Keep outDir/rootDir if already set in base; do not guess.
111
+ // If you want to force them, do it in the base tsconfig.json or pass flags later.
112
+
113
+ if (!dryRun) {
114
+ mkdirSync(dir, { recursive: true });
115
+ writeFileSync(outPath, `${GENERATED_FILE_HEADER}\n${JSON.stringify(buildConfig, null, 2)}\n`, 'utf8');
116
+ }
117
+
118
+ return outPath;
119
+ }
120
+
121
+ function isGeneratedByThisScript(filePath: string): boolean {
122
+ try {
123
+ const contents = readFileSync(filePath, 'utf8');
124
+ const firstLine = contents.split(/\r?\n/, 1)[0]?.trim() ?? '';
125
+ return firstLine === GENERATED_FILE_HEADER;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ function getProjectRefs(tsconfigPath: string, config: TsConfigJson): string[] {
132
+ const refs = asArray<any>(config.references);
133
+ const result: string[] = [];
134
+
135
+ for (const r of refs) {
136
+ if (!r) continue;
137
+ const refPath = typeof r === 'string' ? r : r.path;
138
+ if (!refPath || typeof refPath !== 'string') continue;
139
+
140
+ const abs = normalizeRefPath(tsconfigPath, refPath);
141
+ const resolved = resolveReferencedTsconfigPath(abs);
142
+ if (resolved) result.push(resolved);
143
+ }
144
+
145
+ return uniq(result);
146
+ }
147
+
148
+ type GenerateOptions = { dryRun: boolean; force: boolean; verbose: boolean };
149
+
150
+ function generateBuildConfigs(
151
+ entry: string,
152
+ options: GenerateOptions
153
+ ): { created: { src: string; out: string }[]; visitedConfigs: string[] } {
154
+ const { dryRun, force, verbose } = options;
155
+
156
+ const visited = new Set<string>();
157
+ const queue: string[] = [entry];
158
+ const created: { src: string; out: string }[] = [];
159
+
160
+ while (queue.length) {
161
+ const tsconfigPath = queue.shift()!;
162
+ if (visited.has(tsconfigPath)) continue;
163
+ visited.add(tsconfigPath);
164
+
165
+ let cfg: TsConfigJson;
166
+ try {
167
+ cfg = readTsconfig(tsconfigPath);
168
+ } catch (e) {
169
+ logWarn(`Skipping unreadable config: ${formatPath(tsconfigPath)}`);
170
+ if (verbose) console.warn(e);
171
+ continue;
172
+ }
173
+
174
+ const outPath = join(dirname(tsconfigPath), 'tsconfig.build.json');
175
+ const outExists = existsSync(outPath);
176
+ const generatedByScript = outExists && isGeneratedByThisScript(outPath);
177
+ if (outExists && !generatedByScript && !force) {
178
+ if (verbose) {
179
+ logInfo(`Skip ${formatPath(outPath)} (manual file, use ${colorText('bold', '--force')})`);
180
+ }
181
+ } else {
182
+ const written = writeBuildTsconfig(tsconfigPath, cfg, dryRun);
183
+ created.push({ src: tsconfigPath, out: written });
184
+ if (verbose || dryRun) {
185
+ const verb = dryRun ? colorText('yellow', '[dry-run] write') : colorText('green', 'write');
186
+ logInfo(`${verb} ${formatPath(written)} <- ${formatPath(tsconfigPath)}`);
187
+ }
188
+ }
189
+
190
+ const refs = getProjectRefs(tsconfigPath, cfg);
191
+ for (const r of refs) queue.push(r);
192
+ }
193
+
194
+ if (!verbose && !dryRun) {
195
+ logInfo(`${colorText('green', 'updated')} ${created.length} tsconfig.build.json file(s)`);
196
+ }
197
+
198
+ return { created, visitedConfigs: Array.from(visited) };
199
+ }
200
+
201
+ function createRegenerator(
202
+ entry: string,
203
+ options: GenerateOptions
204
+ ): { run: (reason?: string) => Promise<void>; syncWatchedConfigs: (watcher: FSWatcher) => void } {
205
+ let isRunning = false;
206
+ let rerunRequested = false;
207
+ let watchedConfigs = new Set<string>();
208
+
209
+ const syncWatchedConfigs = (watcher: FSWatcher): void => {
210
+ if (watchedConfigs.size === 0) return;
211
+ watcher.add(Array.from(watchedConfigs));
212
+ };
213
+
214
+ const run = async (reason?: string): Promise<void> => {
215
+ if (isRunning) {
216
+ rerunRequested = true;
217
+ return;
218
+ }
219
+ isRunning = true;
220
+
221
+ try {
222
+ do {
223
+ rerunRequested = false;
224
+ if (reason) {
225
+ logInfo(`${colorText('cyan', 'regenerate')} (${reason})`);
226
+ reason = undefined;
227
+ }
228
+ const result = generateBuildConfigs(entry, options);
229
+ watchedConfigs = new Set(result.visitedConfigs);
230
+ } while (rerunRequested);
231
+ } finally {
232
+ isRunning = false;
233
+ }
234
+ };
235
+
236
+ return { run, syncWatchedConfigs };
237
+ }
238
+
239
+ async function main(): Promise<void> {
240
+ const { values } = parseArgs({
241
+ args: process.argv.slice(2),
242
+ options: {
243
+ config: { type: 'string', short: 'c' },
244
+ dryRun: { type: 'boolean' },
245
+ force: { type: 'boolean' }, // overwrite files not generated by this script
246
+ watch: { type: 'boolean', short: 'w' },
247
+ verbose: { type: 'boolean', short: 'v' }
248
+ }
249
+ });
250
+
251
+ const entry = values.config ? resolve(values.config) : null;
252
+ if (!entry) {
253
+ logError('Missing required flag: --config <path/to/tsconfig.json>');
254
+ process.exit(1);
255
+ }
256
+ if (!existsSync(entry)) {
257
+ logError(`Config file not found: ${formatPath(entry)}`);
258
+ process.exit(1);
259
+ }
260
+
261
+ const dryRun = values.dryRun === true;
262
+ const force = values.force === true;
263
+ const watchMode = values.watch === true;
264
+ const verbose = values.verbose === true;
265
+ const options: GenerateOptions = { dryRun, force, verbose };
266
+
267
+ const regenerator = createRegenerator(entry, options);
268
+ await regenerator.run('initial run');
269
+
270
+ if (!watchMode) {
271
+ return;
272
+ }
273
+
274
+ logInfo(`watching ${colorText('cyan', 'tsconfig')} changes...`);
275
+ const watcher = watch([], {
276
+ persistent: true,
277
+ ignoreInitial: true,
278
+ awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
279
+ });
280
+ regenerator.syncWatchedConfigs(watcher);
281
+
282
+ watcher.on('change', async (filePath) => {
283
+ logInfo(`${colorText('cyan', 'change')} ${formatPath(filePath)}`);
284
+ await regenerator.run(`changed ${relative(process.cwd(), filePath)}`);
285
+ regenerator.syncWatchedConfigs(watcher);
286
+ });
287
+
288
+ watcher.on('add', async (filePath) => {
289
+ logInfo(`${colorText('cyan', 'add')} ${formatPath(filePath)}`);
290
+ await regenerator.run(`added ${relative(process.cwd(), filePath)}`);
291
+ regenerator.syncWatchedConfigs(watcher);
292
+ });
293
+
294
+ watcher.on('unlink', async (filePath) => {
295
+ logInfo(`${colorText('yellow', 'remove')} ${formatPath(filePath)}`);
296
+ await regenerator.run(`removed ${relative(process.cwd(), filePath)}`);
297
+ regenerator.syncWatchedConfigs(watcher);
298
+ });
299
+ }
300
+
301
+ main().catch((err) => {
302
+ logError('Unhandled error');
303
+ console.error(err);
304
+ process.exit(1);
305
+ });
@@ -0,0 +1,4 @@
1
+ import { styleText } from 'util';
2
+
3
+ export const colorText = (format: Parameters<typeof styleText>[0], text: unknown) =>
4
+ styleText(format, String(text), { validateStream: false });
@@ -1,3 +1,4 @@
1
1
  export * from './browser.js';
2
2
 
3
3
  export * from './fs.js';
4
+ export * from './cli.js';