@bb-labs/bldr 0.0.5 → 0.0.6

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # bldr
1
+ ## Introduction
2
2
 
3
3
  A TypeScript build tool with watch mode, automatic dist synchronization, and split-terminal UI.
4
4
 
@@ -21,9 +21,17 @@ bun add -d @bb-labs/builder typescript tsc-alias
21
21
 
22
22
  ## Options
23
23
 
24
- - `--watch`, `-w`: Watch mode with split-terminal UI showing tsc, tsc-alias, and bldr outputs side by side
24
+ - `--watch`, `-w`: Watch mode with split-terminal UI showing tsc, tsc-alias, and bldr outputs
25
25
  - `--project`, `-p <path>`: Custom tsconfig.json path
26
26
 
27
+ ## Controls (Watch Mode)
28
+
29
+ - **Mouse Wheel**: Scroll individual panels when hovering over them
30
+ - **Arrow Keys**: Scroll the main panel up/down
31
+ - **Page Up/Down**: Scroll the main panel by larger increments
32
+ - **Ctrl+L**: Jump all panels to the bottom
33
+ - **Ctrl+C/Escape/Q**: Exit the application
34
+
27
35
  ## Testing
28
36
 
29
37
  Run `bun run test:dev` to test the build tool with the included test files in `test-src/`. This will:
@@ -0,0 +1,2 @@
1
+ import { type Args } from "./types.js";
2
+ export declare function build(args: Args): Promise<void>;
@@ -0,0 +1,200 @@
1
+ import path from "node:path";
2
+ import chokidar from "chokidar";
3
+ import {} from "./types.js";
4
+ import { findClosestTsconfig, readTsConfig, rel, rmIfExists, isTsLike, makeDistCleaner } from "../utils/fs.js";
5
+ import { run, spawnLongRunning } from "../utils/process.js";
6
+ import { createTerminalUI } from "../ui/terminal.js";
7
+ export async function build(args) {
8
+ const cwd = process.cwd();
9
+ const tsconfigPath = args.project ? path.resolve(cwd, args.project) : findClosestTsconfig(cwd);
10
+ const cfg = readTsConfig(tsconfigPath);
11
+ if (!cfg.outDir || !cfg.rootDir) {
12
+ throw new Error([
13
+ `tsconfig must specify both "compilerOptions.outDir" and "compilerOptions.rootDir" for dist sync.`,
14
+ `Found: rootDir=${String(cfg.rootDir)} outDir=${String(cfg.outDir)}`,
15
+ `File: ${tsconfigPath}`,
16
+ ].join("\n"));
17
+ }
18
+ const rootDirAbs = path.isAbsolute(cfg.rootDir) ? cfg.rootDir : path.resolve(cfg.configDir, cfg.rootDir);
19
+ const outDirAbs = path.isAbsolute(cfg.outDir) ? cfg.outDir : path.resolve(cfg.configDir, cfg.outDir);
20
+ const state = {};
21
+ const shutdown = async (code = 0) => {
22
+ // Prevent multiple shutdown calls
23
+ if (shutdown.inProgress)
24
+ return;
25
+ shutdown.inProgress = true;
26
+ if (state.ui) {
27
+ state.ui.destroy();
28
+ state.ui = undefined;
29
+ }
30
+ console.log("\n■ shutting down...");
31
+ try {
32
+ if (state.watcher) {
33
+ await state.watcher.close();
34
+ }
35
+ console.log("✓ src watcher closed");
36
+ }
37
+ catch (error) {
38
+ console.error(`✗ error closing src watcher: ${error}`);
39
+ }
40
+ const killProcess = (name, p) => {
41
+ if (p && !p.killed) {
42
+ console.log(`↻ killing ${name}...`);
43
+ p.kill();
44
+ return new Promise((resolve) => {
45
+ const timeout = setTimeout(() => {
46
+ if (!p.killed)
47
+ p.kill("SIGKILL");
48
+ resolve();
49
+ }, 2000);
50
+ p.on("exit", () => {
51
+ clearTimeout(timeout);
52
+ resolve();
53
+ });
54
+ });
55
+ }
56
+ return Promise.resolve();
57
+ };
58
+ await Promise.all([killProcess("tsc", state.tsc), killProcess("tsc-alias", state.alias)]);
59
+ console.log("✓ all processes cleaned up");
60
+ process.exit(code);
61
+ };
62
+ // Register shutdown handlers
63
+ process.on("SIGINT", () => shutdown(0));
64
+ process.on("SIGTERM", () => shutdown(0));
65
+ if (args.watch) {
66
+ state.ui = createTerminalUI(() => shutdown(0));
67
+ console.log = (...args) => {
68
+ state.ui?.logToBldr(args.join(" "));
69
+ };
70
+ console.error = (...args) => {
71
+ state.ui?.logToBldr(`{red-fg}✗ ${args.join(" ")}{/red-fg}`);
72
+ };
73
+ }
74
+ const prefix = args.watch ? "" : "[bldr] ";
75
+ console.log([
76
+ `\n${prefix}• project: ${rel(cwd, tsconfigPath)}`,
77
+ `${prefix}→ rootDir : ${rel(cwd, rootDirAbs)}`,
78
+ `${prefix}→ outDir : ${rel(cwd, outDirAbs)}`,
79
+ `${prefix}▶ mode : ${args.watch ? "watch" : "build"}\n`,
80
+ ].join("\n"));
81
+ console.log(`⌫ cleaning output directory...`);
82
+ await rmIfExists(outDirAbs);
83
+ console.log(`✓ output directory cleaned`);
84
+ const tscArgs = ["-p", tsconfigPath, "--pretty"];
85
+ const aliasArgs = ["-p", tsconfigPath];
86
+ if (!args.watch) {
87
+ try {
88
+ console.log(`▶ starting build...`);
89
+ await run("tsc", tscArgs, cwd);
90
+ await run("tsc-alias", aliasArgs, cwd);
91
+ console.log(`✓ [bldr] build completed successfully!`);
92
+ }
93
+ catch (error) {
94
+ console.error(`build failed, cleaning output directory...`);
95
+ await rmIfExists(outDirAbs);
96
+ throw error;
97
+ }
98
+ return;
99
+ }
100
+ // Watch mode initial pass
101
+ try {
102
+ console.log(`▶ starting initial build...`);
103
+ await run("tsc", tscArgs, cwd);
104
+ await run("tsc-alias", aliasArgs, cwd);
105
+ console.log(`✓ initial build completed`);
106
+ }
107
+ catch (error) {
108
+ if (state.ui) {
109
+ state.ui.tscBox.insertBottom(`{red-fg}✗ initial build failed: ${error.message}{/red-fg}`);
110
+ state.ui.tscBox.setScrollPerc(100);
111
+ state.ui.screen.render();
112
+ }
113
+ }
114
+ // Start processes
115
+ state.tsc = spawnLongRunning("tsc", [...tscArgs, "-w"], cwd);
116
+ state.alias = spawnLongRunning("tsc-alias", [...aliasArgs, "-w"], cwd);
117
+ const setupOutputPipe = (p, box) => {
118
+ if (!p)
119
+ return;
120
+ const handleData = (data) => {
121
+ const text = data.toString().trim();
122
+ if (text && state.ui) {
123
+ box.insertBottom(text);
124
+ box.setScrollPerc(100);
125
+ state.ui.screen.render();
126
+ }
127
+ };
128
+ p.stdout?.on("data", handleData);
129
+ p.stderr?.on("data", handleData);
130
+ p.on("error", (error) => {
131
+ if (state.ui) {
132
+ box.insertBottom(`{red-fg}✗ ${error}{/red-fg}`);
133
+ box.setScrollPerc(100);
134
+ state.ui.screen.render();
135
+ }
136
+ });
137
+ p.on("exit", (code) => {
138
+ if (code !== 0 && code !== null) {
139
+ console.error(`${p === state.tsc ? "tsc" : "tsc-alias"} exited with code ${code}`);
140
+ }
141
+ // If one of them exits unexpectedly in watch mode, we might want to shut down or restart
142
+ // For now, let's just shut down if it's not a normal exit
143
+ if (code !== 0 && code !== null)
144
+ shutdown(code);
145
+ });
146
+ };
147
+ if (state.ui) {
148
+ setupOutputPipe(state.tsc, state.ui.tscBox);
149
+ setupOutputPipe(state.alias, state.ui.aliasBox);
150
+ }
151
+ const cleaner = makeDistCleaner(rootDirAbs, outDirAbs);
152
+ const srcWatcher = chokidar.watch(rootDirAbs, {
153
+ ignoreInitial: true,
154
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
155
+ atomic: true,
156
+ interval: 100,
157
+ binaryInterval: 300,
158
+ });
159
+ state.watcher = srcWatcher;
160
+ const operationQueue = new Map();
161
+ const MAX_CONCURRENT_OPERATIONS = 10;
162
+ async function queuedOperation(key, operation) {
163
+ const existing = operationQueue.get(key);
164
+ if (existing)
165
+ await existing;
166
+ while (operationQueue.size >= MAX_CONCURRENT_OPERATIONS) {
167
+ await Promise.race(operationQueue.values());
168
+ }
169
+ const promise = operation().finally(() => operationQueue.delete(key));
170
+ operationQueue.set(key, promise);
171
+ return promise;
172
+ }
173
+ srcWatcher.on("unlink", async (absPath) => {
174
+ try {
175
+ await queuedOperation(absPath, async () => {
176
+ if (isTsLike(absPath)) {
177
+ await cleaner.removeEmittedForSource(absPath);
178
+ console.log(`- sync: removed output for ${path.relative(rootDirAbs, absPath)}`);
179
+ }
180
+ else {
181
+ const outPath = path.join(outDirAbs, path.relative(rootDirAbs, absPath));
182
+ await rmIfExists(outPath);
183
+ console.log(`- sync: removed asset ${path.relative(rootDirAbs, absPath)}`);
184
+ }
185
+ });
186
+ }
187
+ catch (error) {
188
+ console.error(`error handling file deletion ${absPath}: ${error}`);
189
+ }
190
+ });
191
+ srcWatcher.on("unlinkDir", async (absDir) => {
192
+ try {
193
+ await cleaner.removeOutDirForSourceDir(absDir);
194
+ console.log(`- sync: removed directory ${path.relative(rootDirAbs, absDir)}`);
195
+ }
196
+ catch (error) {
197
+ console.error(`error handling directory deletion ${absDir}: ${error}`);
198
+ }
199
+ });
200
+ }
@@ -0,0 +1,18 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ export type Args = {
3
+ watch: boolean;
4
+ project?: string;
5
+ };
6
+ export interface Config {
7
+ tsconfigPath: string;
8
+ configDir: string;
9
+ outDir: string;
10
+ rootDir: string;
11
+ }
12
+ export interface BuildProcess {
13
+ tsc?: ChildProcess;
14
+ alias?: ChildProcess;
15
+ watcher?: {
16
+ close: () => Promise<void>;
17
+ };
18
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js CHANGED
@@ -1,462 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { spawn } from "node:child_process";
3
- import path from "node:path";
4
- import fs from "node:fs";
5
- import chokidar from "chokidar";
6
- import blessed from "blessed";
7
- // We use the TypeScript API to correctly resolve tsconfig + "extends".
8
- import ts from "typescript";
9
- function parseArgs(argv) {
10
- const args = { watch: false };
11
- for (let i = 0; i < argv.length; i++) {
12
- const a = argv[i];
13
- if (a === "-w" || a === "--watch")
14
- args.watch = true;
15
- else if (a === "-p" || a === "--project")
16
- args.project = argv[++i];
17
- }
18
- return args;
19
- }
20
- function findClosestTsconfig(startDir) {
21
- let dir = startDir;
22
- while (true) {
23
- const candidate = path.join(dir, "tsconfig.json");
24
- if (fs.existsSync(candidate))
25
- return candidate;
26
- const parent = path.dirname(dir);
27
- if (parent === dir)
28
- break;
29
- dir = parent;
30
- }
31
- throw new Error(`Could not find tsconfig.json starting from ${startDir}. Pass -p <path-to-tsconfig>.`);
32
- }
33
- function readTsConfig(tsconfigPath) {
34
- const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
35
- if (configFile.error) {
36
- const msg = ts.formatDiagnosticsWithColorAndContext([configFile.error], {
37
- getCanonicalFileName: (f) => f,
38
- getCurrentDirectory: ts.sys.getCurrentDirectory,
39
- getNewLine: () => ts.sys.newLine,
40
- });
41
- throw new Error(msg);
42
- }
43
- const configDir = path.dirname(tsconfigPath);
44
- // parseJsonConfigFileContent resolves "extends" and normalizes options.
45
- const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, configDir,
46
- /*existingOptions*/ undefined, tsconfigPath);
47
- if (parsed.errors?.length) {
48
- const msg = ts.formatDiagnosticsWithColorAndContext(parsed.errors, {
49
- getCanonicalFileName: (f) => f,
50
- getCurrentDirectory: ts.sys.getCurrentDirectory,
51
- getNewLine: () => ts.sys.newLine,
52
- });
53
- throw new Error(msg);
54
- }
55
- // These are absolute paths in parsed.options (when provided).
56
- const outDir = parsed.options.outDir;
57
- const rootDir = parsed.options.rootDir;
58
- return {
59
- tsconfigPath,
60
- configDir,
61
- outDir,
62
- rootDir,
63
- };
64
- }
65
- function run(cmd, args, cwd) {
66
- return new Promise((resolve, reject) => {
67
- const p = spawn(cmd, args, {
68
- cwd,
69
- stdio: ["inherit", "pipe", "pipe"], // pipe both stdout and stderr to capture all output
70
- shell: process.platform === "win32",
71
- });
72
- let stdout = "";
73
- let stderr = "";
74
- if (p.stdout) {
75
- p.stdout.on("data", (data) => {
76
- stdout += data.toString();
77
- });
78
- }
79
- if (p.stderr) {
80
- p.stderr.on("data", (data) => {
81
- stderr += data.toString();
82
- });
83
- }
84
- p.on("exit", (code) => {
85
- if (code === 0)
86
- resolve();
87
- else {
88
- // Give a small delay to ensure all output data is captured
89
- setTimeout(() => {
90
- const allOutput = (stdout + stderr).trim();
91
- const errorMsg = allOutput || `${cmd} ${args.join(" ")} failed with exit code ${code}`;
92
- reject(new Error(errorMsg));
93
- }, 100);
94
- }
95
- });
96
- });
97
- }
98
- function spawnLongRunning(cmd, args, cwd) {
99
- const p = spawn(cmd, args, {
100
- cwd,
101
- stdio: ["inherit", "pipe", "pipe"], // pipe stdout and stderr
102
- shell: process.platform === "win32",
103
- });
104
- return p;
105
- }
106
- function rel(from, to) {
107
- const r = path.relative(from, to);
108
- return r.startsWith(".") ? r : `./${r}`;
109
- }
110
- async function rmIfExists(p) {
111
- try {
112
- await fs.promises.rm(p, { force: true, recursive: true });
113
- }
114
- catch {
115
- // ignore
116
- }
117
- }
118
- function isTsLike(filePath) {
119
- return (filePath.endsWith(".ts") || filePath.endsWith(".tsx") || filePath.endsWith(".mts") || filePath.endsWith(".cts"));
120
- }
121
- function stripTsExt(p) {
122
- return p.replace(/\.(ts|tsx|mts|cts)$/i, "");
123
- }
124
- function makeDistCleaner(rootDirAbs, outDirAbs) {
125
- const EMITTED_SUFFIXES = [".js", ".js.map", ".d.ts", ".d.ts.map"];
126
- function toOutPath(srcPathAbs) {
127
- const relPath = path.relative(rootDirAbs, srcPathAbs);
128
- return path.join(outDirAbs, relPath);
129
- }
130
- async function removeEmittedForSource(srcFileAbs) {
131
- const outLike = toOutPath(srcFileAbs); // dist/foo.tsx
132
- const base = stripTsExt(outLike); // dist/foo
133
- await Promise.all(EMITTED_SUFFIXES.map((s) => rmIfExists(base + s)));
134
- }
135
- async function removeOutDirForSourceDir(srcDirAbs) {
136
- const outDir = toOutPath(srcDirAbs);
137
- await rmIfExists(outDir);
138
- }
139
- return { removeEmittedForSource, removeOutDirForSourceDir };
140
- }
2
+ import { parseArgs } from "./utils/args.js";
3
+ import { build } from "./core/builder.js";
141
4
  async function main() {
142
5
  const args = parseArgs(process.argv.slice(2));
143
- const cwd = process.cwd();
144
- const tsconfigPath = args.project ? path.resolve(cwd, args.project) : findClosestTsconfig(cwd);
145
- const cfg = readTsConfig(tsconfigPath);
146
- if (!cfg.outDir || !cfg.rootDir) {
147
- throw new Error([
148
- `tsconfig must specify both "compilerOptions.outDir" and "compilerOptions.rootDir" for dist sync.`,
149
- `Found: rootDir=${String(cfg.rootDir)} outDir=${String(cfg.outDir)}`,
150
- `File: ${tsconfigPath}`,
151
- ].join("\n"));
152
- }
153
- const rootDirAbs = path.isAbsolute(cfg.rootDir) ? cfg.rootDir : path.resolve(cfg.configDir, cfg.rootDir);
154
- const outDirAbs = path.isAbsolute(cfg.outDir) ? cfg.outDir : path.resolve(cfg.configDir, cfg.outDir);
155
- // Create terminal UI only in watch mode
156
- let screen, bldrBox, tscBox, aliasBox, logToBldr;
157
- if (args.watch) {
158
- screen = blessed.screen({
159
- smartCSR: true,
160
- title: "bldr - TypeScript Build Tool",
161
- });
162
- bldrBox = blessed.box({
163
- top: 0,
164
- left: 0,
165
- width: "33%",
166
- height: "100%",
167
- label: " bldr ",
168
- border: { type: "line" },
169
- scrollable: true,
170
- alwaysScroll: true,
171
- scrollbar: { ch: " " },
172
- });
173
- tscBox = blessed.box({
174
- top: 0,
175
- left: "33%",
176
- width: "34%",
177
- height: "100%",
178
- label: " tsc ",
179
- border: { type: "line" },
180
- scrollable: true,
181
- alwaysScroll: true,
182
- scrollbar: { ch: " " },
183
- });
184
- aliasBox = blessed.box({
185
- top: 0,
186
- left: "67%",
187
- width: "33%",
188
- height: "100%",
189
- label: " tsc-alias ",
190
- border: { type: "line" },
191
- scrollable: true,
192
- alwaysScroll: true,
193
- scrollbar: { ch: " " },
194
- });
195
- screen.append(bldrBox);
196
- screen.append(tscBox);
197
- screen.append(aliasBox);
198
- // Handle screen events
199
- screen.key(["escape", "q", "C-c"], () => {
200
- shutdown();
201
- });
202
- screen.key(["C-l"], () => {
203
- bldrBox.setScrollPerc(100);
204
- tscBox.setScrollPerc(100);
205
- aliasBox.setScrollPerc(100);
206
- screen.render();
207
- });
208
- // Create a function to log to the bldr box
209
- logToBldr = (text) => {
210
- if (screen && bldrBox) {
211
- bldrBox.insertBottom(text);
212
- bldrBox.setScrollPerc(100);
213
- screen.render();
214
- }
215
- };
216
- // Override console.log and console.error for bldr messages
217
- const originalLog = console.log;
218
- const originalError = console.error;
219
- console.log = (...args) => {
220
- logToBldr(args.join(" "));
221
- // Don't call originalLog to avoid double output
222
- };
223
- console.error = (...args) => {
224
- logToBldr(args.join(" "));
225
- // Don't call originalError to avoid double output
226
- };
227
- screen.render();
228
- }
229
- // Initial messages will now go to the bldr box via overridden console.log
230
- console.log([
231
- `\n[bldr] project: ${rel(cwd, tsconfigPath)}`,
232
- `[bldr] rootDir : ${rel(cwd, rootDirAbs)}`,
233
- `[bldr] outDir : ${rel(cwd, outDirAbs)}`,
234
- `[bldr] mode : ${args.watch ? "watch" : "build"}\n`,
235
- ].join("\n"));
236
- // Clean the output directory first
237
- console.log(`[bldr] cleaning output directory...`);
238
- await rmIfExists(outDirAbs);
239
- const tscArgs = ["-p", tsconfigPath];
240
- const aliasArgs = ["-p", tsconfigPath];
241
- // Error handling function for watch mode
242
- const handleFatalError = async (errorMessage) => {
243
- // Always restore normal console logging for watch mode
244
- if (args.watch) {
245
- // Destroy UI first to avoid conflicts
246
- if (screen) {
247
- screen.destroy();
248
- screen = null;
249
- bldrBox = null;
250
- tscBox = null;
251
- aliasBox = null;
252
- }
253
- // Write directly to stderr to ensure visibility
254
- process.stderr.write(`[bldr] ${errorMessage}\n`);
255
- }
256
- else {
257
- // In build mode, just print normally
258
- console.error(`[bldr] ${errorMessage}`);
259
- }
260
- // Kill all processes
261
- if (tscWatch && !tscWatch.killed) {
262
- tscWatch.kill();
263
- }
264
- if (aliasWatch && !aliasWatch.killed) {
265
- aliasWatch.kill();
266
- }
267
- if (srcWatcher) {
268
- try {
269
- await srcWatcher.close();
270
- }
271
- catch (e) {
272
- // ignore
273
- }
274
- }
275
- process.exit(1);
276
- };
277
- if (!args.watch) {
278
- // One-shot build: tsc -> tsc-alias
279
- try {
280
- await run("tsc", tscArgs, cwd);
281
- await run("tsc-alias", aliasArgs, cwd);
282
- }
283
- catch (error) {
284
- // Clean up partial output on build failure
285
- console.error(`[bldr] build failed, cleaning output directory...`);
286
- await rmIfExists(outDirAbs);
287
- throw error;
288
- }
289
- console.log(`[bldr] build completed successfully!`);
290
- return;
291
- }
292
- // Watch mode:
293
- // 2) Do a clean initial pass so dist is correct immediately.
294
- try {
295
- await run("tsc", tscArgs, cwd);
296
- await run("tsc-alias", aliasArgs, cwd);
297
- }
298
- catch (error) {
299
- await handleFatalError(`[bldr] initial build failed: ${error}`);
300
- }
301
- // 3) Start watchers
302
- const tscWatch = spawnLongRunning("tsc", [...tscArgs, "-w"], cwd);
303
- const aliasWatch = spawnLongRunning("tsc-alias", [...aliasArgs, "-w"], cwd);
304
- // Pipe output to boxes
305
- if (tscWatch.stdout) {
306
- tscWatch.stdout.on("data", (data) => {
307
- const text = data.toString().trim();
308
- if (text) {
309
- tscBox.insertBottom(text);
310
- tscBox.setScrollPerc(100);
311
- screen.render();
312
- }
313
- });
314
- }
315
- if (tscWatch.stderr) {
316
- tscWatch.stderr.on("data", (data) => {
317
- const text = data.toString().trim();
318
- if (text) {
319
- tscBox.insertBottom(text);
320
- tscBox.setScrollPerc(100);
321
- screen.render();
322
- }
323
- });
324
- }
325
- if (aliasWatch.stdout) {
326
- aliasWatch.stdout.on("data", (data) => {
327
- const text = data.toString().trim();
328
- if (text) {
329
- aliasBox.insertBottom(text);
330
- aliasBox.setScrollPerc(100);
331
- screen.render();
332
- }
333
- });
334
- }
335
- if (aliasWatch.stderr) {
336
- aliasWatch.stderr.on("data", (data) => {
337
- const text = data.toString().trim();
338
- if (text) {
339
- aliasBox.insertBottom(text);
340
- aliasBox.setScrollPerc(100);
341
- screen.render();
342
- }
343
- });
344
- }
345
- // If any child process errors or exits, shut down all processes
346
- tscWatch.on("error", (error) => {
347
- handleFatalError(`[bldr] tsc watcher error: ${error}`);
348
- });
349
- aliasWatch.on("error", (error) => {
350
- handleFatalError(`[bldr] tsc-alias watcher error: ${error}`);
351
- });
352
- // 3) dist sync watcher: remove stale outputs on deletes/dir deletes
353
- const cleaner = makeDistCleaner(rootDirAbs, outDirAbs);
354
- const srcWatcher = chokidar.watch(rootDirAbs, {
355
- ignoreInitial: true,
356
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
357
- atomic: true, // Handle atomic writes (renames/moves) properly
358
- interval: 100, // Poll interval for most files
359
- binaryInterval: 300, // Poll interval for binary files
360
- });
361
- // Queue for handling rapid operations to prevent race conditions
362
- const operationQueue = new Map();
363
- const MAX_CONCURRENT_OPERATIONS = 10;
364
- async function queuedOperation(key, operation) {
365
- // Wait for any existing operation on this key
366
- const existing = operationQueue.get(key);
367
- if (existing) {
368
- await existing;
369
- }
370
- // Limit concurrent operations
371
- while (operationQueue.size >= MAX_CONCURRENT_OPERATIONS) {
372
- await Promise.race(operationQueue.values());
373
- }
374
- const promise = operation().finally(() => operationQueue.delete(key));
375
- operationQueue.set(key, promise);
376
- return promise;
377
- }
378
- srcWatcher.on("unlink", async (absPath) => {
379
- try {
380
- await queuedOperation(absPath, async () => {
381
- if (isTsLike(absPath)) {
382
- await cleaner.removeEmittedForSource(absPath);
383
- }
384
- else {
385
- // Mirror non-TS deletes too (assets)
386
- await rmIfExists(path.join(outDirAbs, path.relative(rootDirAbs, absPath)));
387
- }
388
- });
389
- }
390
- catch (error) {
391
- console.error(`[bldr] error handling file deletion ${absPath}: ${error}`);
392
- }
393
- });
394
- srcWatcher.on("unlinkDir", async (absDir) => {
395
- try {
396
- await cleaner.removeOutDirForSourceDir(absDir);
397
- }
398
- catch (error) {
399
- console.error(`[bldr] error handling directory deletion ${absDir}: ${error}`);
400
- }
401
- });
402
- // TypeScript compiler handles additions and changes automatically
403
- const shutdown = async () => {
404
- console.log("\n[bldr] shutting down...");
405
- // Destroy screen if it exists
406
- if (screen) {
407
- screen.destroy();
408
- screen = null;
409
- }
410
- // Close file watcher
411
- try {
412
- if (srcWatcher) {
413
- await srcWatcher.close();
414
- }
415
- console.log("[bldr] src watcher closed");
416
- }
417
- catch (error) {
418
- console.error(`[bldr] error closing src watcher: ${error}`);
419
- }
420
- // Kill child processes
421
- const killPromises = [];
422
- if (tscWatch && !tscWatch.killed) {
423
- killPromises.push(new Promise((resolve) => {
424
- tscWatch.on("exit", () => resolve());
425
- tscWatch.kill();
426
- // Force kill after 5 seconds
427
- setTimeout(() => {
428
- if (!tscWatch.killed) {
429
- tscWatch.kill("SIGKILL");
430
- }
431
- resolve();
432
- }, 5000);
433
- }));
434
- console.log("[bldr] killing tsc watcher...");
435
- }
436
- if (aliasWatch && !aliasWatch.killed) {
437
- killPromises.push(new Promise((resolve) => {
438
- aliasWatch.on("exit", () => resolve());
439
- aliasWatch.kill();
440
- // Force kill after 5 seconds
441
- setTimeout(() => {
442
- if (!aliasWatch.killed) {
443
- aliasWatch.kill("SIGKILL");
444
- }
445
- resolve();
446
- }, 5000);
447
- }));
448
- console.log("[bldr] killing tsc-alias watcher...");
449
- }
450
- // Wait for all child processes to exit
451
- await Promise.all(killPromises);
452
- console.log("[bldr] all processes cleaned up");
453
- process.exit(0);
454
- };
455
- process.on("SIGINT", shutdown);
456
- process.on("SIGTERM", shutdown);
457
- // If any child exits, shut down.
458
- tscWatch.on("exit", () => shutdown());
459
- aliasWatch.on("exit", () => shutdown());
6
+ await build(args);
460
7
  }
461
8
  main().catch((e) => {
462
9
  console.error(String(e?.stack || e));
@@ -0,0 +1,10 @@
1
+ import blessed from "blessed";
2
+ export interface TerminalUI {
3
+ screen: blessed.Widgets.Screen;
4
+ bldrBox: blessed.Widgets.BoxElement;
5
+ tscBox: blessed.Widgets.BoxElement;
6
+ aliasBox: blessed.Widgets.BoxElement;
7
+ logToBldr: (text: string) => void;
8
+ destroy: () => void;
9
+ }
10
+ export declare function createTerminalUI(onShutdown: () => void): TerminalUI;
@@ -0,0 +1,129 @@
1
+ import blessed from "blessed";
2
+ export function createTerminalUI(onShutdown) {
3
+ const screen = blessed.screen({
4
+ smartCSR: true,
5
+ title: "bldr - TypeScript Build Tool",
6
+ fullUnicode: true,
7
+ });
8
+ // Common styles
9
+ const boxStyle = {
10
+ border: {
11
+ fg: "cyan",
12
+ },
13
+ };
14
+ // Left column: tsc (65% width, full height)
15
+ const tscBox = blessed.box({
16
+ top: 0,
17
+ left: 0,
18
+ width: "65%",
19
+ height: "100%",
20
+ label: " tsc ",
21
+ border: { type: "line" },
22
+ style: boxStyle,
23
+ scrollable: true,
24
+ alwaysScroll: true,
25
+ scrollbar: {
26
+ ch: " ",
27
+ track: { bg: "grey" },
28
+ style: { inverse: true },
29
+ },
30
+ tags: true, // Enable colors/formatting
31
+ });
32
+ // Right column: tsc-alias (top 50%) and bldr (bottom 50%), 35% width, full height
33
+ const aliasBox = blessed.box({
34
+ top: 0,
35
+ left: "65%",
36
+ width: "35%",
37
+ height: "50%",
38
+ label: " tsc alias ",
39
+ border: { type: "line" },
40
+ style: boxStyle,
41
+ scrollable: true,
42
+ alwaysScroll: true,
43
+ scrollbar: {
44
+ ch: " ",
45
+ track: { bg: "grey" },
46
+ style: { inverse: true },
47
+ },
48
+ tags: true,
49
+ });
50
+ const bldrBox = blessed.box({
51
+ top: "50%",
52
+ left: "65%",
53
+ width: "35%",
54
+ height: "50%",
55
+ label: " bldr ",
56
+ border: { type: "line" },
57
+ style: boxStyle,
58
+ scrollable: true,
59
+ alwaysScroll: true,
60
+ scrollbar: {
61
+ ch: " ",
62
+ track: { bg: "grey" },
63
+ style: { inverse: true },
64
+ },
65
+ tags: true,
66
+ });
67
+ screen.append(tscBox);
68
+ screen.append(aliasBox);
69
+ screen.append(bldrBox);
70
+ // Enable mouse events for scrolling
71
+ screen.enableMouse();
72
+ screen.enableKeys();
73
+ // Handle screen events
74
+ screen.key(["escape", "q", "C-c"], () => {
75
+ onShutdown();
76
+ });
77
+ screen.key(["C-l"], () => {
78
+ bldrBox.setScrollPerc(100);
79
+ tscBox.setScrollPerc(100);
80
+ aliasBox.setScrollPerc(100);
81
+ screen.render();
82
+ });
83
+ // Handle mouse wheel scrolling for each box
84
+ screen.on("mouse", (data) => {
85
+ // Determine which box the mouse is over
86
+ const x = data.x;
87
+ const y = data.y;
88
+ const width = screen.width;
89
+ const height = screen.height;
90
+ const tscWidth = Math.floor(width * 0.65);
91
+ const midHeight = Math.floor(height * 0.5);
92
+ let targetBox = null;
93
+ if (x < tscWidth) {
94
+ targetBox = tscBox;
95
+ }
96
+ else {
97
+ if (y < midHeight) {
98
+ targetBox = aliasBox;
99
+ }
100
+ else {
101
+ targetBox = bldrBox;
102
+ }
103
+ }
104
+ if (!targetBox)
105
+ return;
106
+ if (data.action === "wheelup") {
107
+ targetBox.scroll(-3);
108
+ screen.render();
109
+ }
110
+ else if (data.action === "wheeldown") {
111
+ targetBox.scroll(3);
112
+ screen.render();
113
+ }
114
+ });
115
+ const logToBldr = (text) => {
116
+ if (bldrBox) {
117
+ // Trim empty lines but keep content
118
+ const lines = text.split("\n").filter((l) => l.trim().length > 0 || l === "");
119
+ lines.forEach((line) => bldrBox.insertBottom(line));
120
+ bldrBox.setScrollPerc(100);
121
+ screen.render();
122
+ }
123
+ };
124
+ const destroy = () => {
125
+ screen.destroy();
126
+ };
127
+ screen.render();
128
+ return { screen, bldrBox, tscBox, aliasBox, logToBldr, destroy };
129
+ }
@@ -0,0 +1,5 @@
1
+ import type { Args } from "../core/types.js";
2
+ /**
3
+ * Simple CLI argument parser.
4
+ */
5
+ export declare function parseArgs(argv: string[]): Args;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Simple CLI argument parser.
3
+ */
4
+ export function parseArgs(argv) {
5
+ const args = { watch: false };
6
+ for (let i = 0; i < argv.length; i++) {
7
+ const a = argv[i];
8
+ if (a === "-w" || a === "--watch") {
9
+ args.watch = true;
10
+ }
11
+ else if (a === "-p" || a === "--project") {
12
+ const next = argv[i + 1];
13
+ if (next && !next.startsWith("-")) {
14
+ args.project = next;
15
+ i++;
16
+ }
17
+ else {
18
+ throw new Error("The -p/--project flag requires a path to a tsconfig.json file.");
19
+ }
20
+ }
21
+ else if (a === "-h" || a === "--help") {
22
+ printHelp();
23
+ process.exit(0);
24
+ }
25
+ }
26
+ return args;
27
+ }
28
+ function printHelp() {
29
+ console.log(`
30
+ bldr - A simple TypeScript build tool with dist synchronization.
31
+
32
+ Usage:
33
+ bldr [options]
34
+
35
+ Options:
36
+ -w, --watch Watch for changes and sync deletions.
37
+ -p, --project Path to tsconfig.json (defaults to closest tsconfig.json).
38
+ -h, --help Show this help message.
39
+ `);
40
+ }
@@ -0,0 +1,32 @@
1
+ import type { Config } from "../core/types.js";
2
+ /**
3
+ * Finds the closest tsconfig.json by walking up the directory tree.
4
+ */
5
+ export declare function findClosestTsconfig(startDir: string): string;
6
+ /**
7
+ * Reads and parses a tsconfig.json file, resolving "extends".
8
+ */
9
+ export declare function readTsConfig(tsconfigPath: string): Config;
10
+ /**
11
+ * Returns a relative path, ensuring it starts with ./ or ../
12
+ */
13
+ export declare function rel(from: string, to: string): string;
14
+ /**
15
+ * Safely removes a file or directory if it exists.
16
+ */
17
+ export declare function rmIfExists(p: string): Promise<void>;
18
+ /**
19
+ * Checks if a file is a TypeScript-like source file.
20
+ */
21
+ export declare function isTsLike(filePath: string): boolean;
22
+ /**
23
+ * Strips TypeScript extensions from a path.
24
+ */
25
+ export declare function stripTsExt(p: string): string;
26
+ /**
27
+ * Creates a cleaner utility for synchronizing deletions from src to dist.
28
+ */
29
+ export declare function makeDistCleaner(rootDirAbs: string, outDirAbs: string): {
30
+ removeEmittedForSource: (srcFileAbs: string) => Promise<void>;
31
+ removeOutDirForSourceDir: (srcDirAbs: string) => Promise<void>;
32
+ };
@@ -0,0 +1,105 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import ts from "typescript";
4
+ /**
5
+ * Finds the closest tsconfig.json by walking up the directory tree.
6
+ */
7
+ export function findClosestTsconfig(startDir) {
8
+ let dir = path.resolve(startDir);
9
+ while (true) {
10
+ const candidate = path.join(dir, "tsconfig.json");
11
+ if (fs.existsSync(candidate))
12
+ return candidate;
13
+ const parent = path.dirname(dir);
14
+ if (parent === dir)
15
+ break;
16
+ dir = parent;
17
+ }
18
+ throw new Error(`Could not find tsconfig.json starting from ${startDir}. Pass -p <path-to-tsconfig>.`);
19
+ }
20
+ /**
21
+ * Reads and parses a tsconfig.json file, resolving "extends".
22
+ */
23
+ export function readTsConfig(tsconfigPath) {
24
+ const absolutePath = path.resolve(tsconfigPath);
25
+ const configFile = ts.readConfigFile(absolutePath, ts.sys.readFile);
26
+ if (configFile.error) {
27
+ throw new Error(formatTsDiagnostics([configFile.error]));
28
+ }
29
+ const configDir = path.dirname(absolutePath);
30
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, configDir, undefined, absolutePath);
31
+ if (parsed.errors?.length) {
32
+ throw new Error(formatTsDiagnostics(parsed.errors));
33
+ }
34
+ const outDir = parsed.options.outDir;
35
+ const rootDir = parsed.options.rootDir;
36
+ if (!outDir || !rootDir) {
37
+ throw new Error(`tsconfig at ${absolutePath} must specify both "compilerOptions.outDir" and "compilerOptions.rootDir".`);
38
+ }
39
+ return {
40
+ tsconfigPath: absolutePath,
41
+ configDir,
42
+ outDir: path.isAbsolute(outDir) ? outDir : path.resolve(configDir, outDir),
43
+ rootDir: path.isAbsolute(rootDir) ? rootDir : path.resolve(configDir, rootDir),
44
+ };
45
+ }
46
+ function formatTsDiagnostics(diagnostics) {
47
+ return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
48
+ getCanonicalFileName: (f) => f,
49
+ getCurrentDirectory: ts.sys.getCurrentDirectory,
50
+ getNewLine: () => ts.sys.newLine,
51
+ });
52
+ }
53
+ /**
54
+ * Returns a relative path, ensuring it starts with ./ or ../
55
+ */
56
+ export function rel(from, to) {
57
+ const r = path.relative(from, to);
58
+ return r.startsWith(".") ? r : `./${r}`;
59
+ }
60
+ /**
61
+ * Safely removes a file or directory if it exists.
62
+ */
63
+ export async function rmIfExists(p) {
64
+ try {
65
+ await fs.promises.rm(p, { force: true, recursive: true });
66
+ }
67
+ catch (error) {
68
+ // Ignore if not found, otherwise rethrow or log
69
+ if (error.code !== "ENOENT") {
70
+ throw error;
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Checks if a file is a TypeScript-like source file.
76
+ */
77
+ export function isTsLike(filePath) {
78
+ return /\.(ts|tsx|mts|cts)$/i.test(filePath);
79
+ }
80
+ /**
81
+ * Strips TypeScript extensions from a path.
82
+ */
83
+ export function stripTsExt(p) {
84
+ return p.replace(/\.(ts|tsx|mts|cts)$/i, "");
85
+ }
86
+ /**
87
+ * Creates a cleaner utility for synchronizing deletions from src to dist.
88
+ */
89
+ export function makeDistCleaner(rootDirAbs, outDirAbs) {
90
+ const EMITTED_SUFFIXES = [".js", ".js.map", ".d.ts", ".d.ts.map", ".mjs", ".mjs.map", ".cjs", ".cjs.map"];
91
+ function toOutPath(srcPathAbs) {
92
+ const relPath = path.relative(rootDirAbs, srcPathAbs);
93
+ return path.join(outDirAbs, relPath);
94
+ }
95
+ async function removeEmittedForSource(srcFileAbs) {
96
+ const outLike = toOutPath(srcFileAbs);
97
+ const base = stripTsExt(outLike);
98
+ await Promise.all(EMITTED_SUFFIXES.map((s) => rmIfExists(base + s)));
99
+ }
100
+ async function removeOutDirForSourceDir(srcDirAbs) {
101
+ const outDir = toOutPath(srcDirAbs);
102
+ await rmIfExists(outDir);
103
+ }
104
+ return { removeEmittedForSource, removeOutDirForSourceDir };
105
+ }
@@ -0,0 +1,10 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ /**
3
+ * Runs a command and returns a promise that resolves when the command finishes.
4
+ * Captures output for error messages if the command fails.
5
+ */
6
+ export declare function run(cmd: string, args: string[], cwd: string): Promise<void>;
7
+ /**
8
+ * Spawns a long-running process (like a watcher).
9
+ */
10
+ export declare function spawnLongRunning(cmd: string, args: string[], cwd: string): ChildProcess;
@@ -0,0 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Runs a command and returns a promise that resolves when the command finishes.
4
+ * Captures output for error messages if the command fails.
5
+ */
6
+ export function run(cmd, args, cwd) {
7
+ return new Promise((resolve, reject) => {
8
+ const p = spawn(cmd, args, {
9
+ cwd,
10
+ stdio: ["inherit", "pipe", "pipe"],
11
+ shell: process.platform === "win32",
12
+ env: { ...process.env, FORCE_COLOR: "1" },
13
+ });
14
+ let output = "";
15
+ p.stdout?.on("data", (data) => {
16
+ output += data.toString();
17
+ });
18
+ p.stderr?.on("data", (data) => {
19
+ output += data.toString();
20
+ });
21
+ p.on("error", (error) => {
22
+ reject(new Error(`Failed to start ${cmd}: ${error.message}`));
23
+ });
24
+ p.on("exit", (code) => {
25
+ if (code === 0) {
26
+ resolve();
27
+ }
28
+ else {
29
+ // Give a small delay to ensure all output data is captured
30
+ setTimeout(() => {
31
+ const cleanOutput = output.trim();
32
+ const errorMsg = cleanOutput || `${cmd} ${args.join(" ")} failed with exit code ${code}`;
33
+ reject(new Error(errorMsg));
34
+ }, 100);
35
+ }
36
+ });
37
+ });
38
+ }
39
+ /**
40
+ * Spawns a long-running process (like a watcher).
41
+ */
42
+ export function spawnLongRunning(cmd, args, cwd) {
43
+ return spawn(cmd, args, {
44
+ cwd,
45
+ stdio: ["inherit", "pipe", "pipe"],
46
+ shell: process.platform === "win32",
47
+ env: { ...process.env, FORCE_COLOR: "1" },
48
+ });
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bb-labs/bldr",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bldr": "./dist/index.js"