@bb-labs/bldr 0.0.5 → 0.0.7
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 +11 -3
- package/dist/core/builder.d.ts +2 -0
- package/dist/core/builder.js +200 -0
- package/dist/core/types.d.ts +18 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +3 -456
- package/dist/ui/terminal.d.ts +10 -0
- package/dist/ui/terminal.js +129 -0
- package/dist/utils/args.d.ts +5 -0
- package/dist/utils/args.js +40 -0
- package/dist/utils/fs.d.ts +32 -0
- package/dist/utils/fs.js +105 -0
- package/dist/utils/process.d.ts +10 -0
- package/dist/utils/process.js +49 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
## Introduction
|
|
2
2
|
|
|
3
3
|
A TypeScript build tool with watch mode, automatic dist synchronization, and split-terminal UI.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
bun add -d @bb-labs/
|
|
8
|
+
bun add -d @bb-labs/bldr
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
@@ -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
|
|
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,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 {
|
|
3
|
-
import
|
|
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
|
-
|
|
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,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
|
+
};
|
package/dist/utils/fs.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bldr": "./dist/index.js"
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"@types/node": "latest"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
+
"tsc-alias": "^1.8.8",
|
|
23
24
|
"typescript": "^5"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|