ttsc 0.4.0-dev.20260424 → 0.4.0-dev.20260425

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.
@@ -0,0 +1,286 @@
1
+ import * as fs from "node:fs";
2
+ import Module = require("node:module");
3
+ import { spawnSync } from "node:child_process";
4
+ import * as path from "node:path";
5
+
6
+ import { version } from "../api";
7
+ import {
8
+ prepareExecution,
9
+ register,
10
+ type PreparedExecution,
11
+ type RegisterOptions,
12
+ } from "./register";
13
+
14
+ interface ParsedCLI extends RegisterOptions {
15
+ entry: string;
16
+ passthrough: string[];
17
+ preload: string[];
18
+ }
19
+
20
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
21
+ try {
22
+ return run(argv);
23
+ } catch (error) {
24
+ process.stderr.write(`${formatError(error)}\n`);
25
+ return 2;
26
+ }
27
+ }
28
+
29
+ function run(argv: readonly string[]): number {
30
+ const parsed = parseCLI(argv);
31
+ if (parsed === "help") {
32
+ printHelp();
33
+ return 0;
34
+ }
35
+ if (parsed === "version") {
36
+ process.stdout.write(`${version().replace(/^ttsc\b/, "ttsx")}\n`);
37
+ return 0;
38
+ }
39
+
40
+ const cwd = path.resolve(parsed.cwd ?? process.cwd());
41
+ const entry = path.resolve(cwd, parsed.entry);
42
+ if (!fs.existsSync(entry)) {
43
+ process.stderr.write(`ttsx: entry not found: ${entry}\n`);
44
+ return 2;
45
+ }
46
+
47
+ const prepared = prepareExecution(entry, {
48
+ binary: parsed.binary,
49
+ cacheDir: parsed.cacheDir,
50
+ cwd,
51
+ env: parsed.env,
52
+ extensions: parsed.extensions,
53
+ project: parsed.project,
54
+ });
55
+ if (prepared.moduleKind === "esm") {
56
+ return runEsmEntry(parsed, prepared, cwd);
57
+ }
58
+
59
+ const unregister = register({
60
+ binary: parsed.binary,
61
+ cacheDir: parsed.cacheDir,
62
+ cwd,
63
+ env: parsed.env,
64
+ extensions: parsed.extensions,
65
+ project: parsed.project,
66
+ });
67
+ process.once("exit", unregister);
68
+ for (const preload of parsed.preload) {
69
+ require(resolvePreload(cwd, preload));
70
+ }
71
+ process.argv = [process.execPath, entry, ...parsed.passthrough];
72
+ Module.runMain();
73
+ return 0;
74
+ }
75
+
76
+ function formatError(error: unknown): string {
77
+ if (error instanceof Error) {
78
+ return error.message;
79
+ }
80
+ return String(error);
81
+ }
82
+
83
+ function parseCLI(argv: readonly string[]): ParsedCLI | "help" | "version" {
84
+ const preload: string[] = [];
85
+ const passthroughIndex = argv.indexOf("--");
86
+ const head = passthroughIndex === -1 ? [...argv] : [...argv.slice(0, passthroughIndex)];
87
+ const passthrough = passthroughIndex === -1 ? [] : [...argv.slice(passthroughIndex + 1)];
88
+
89
+ let binary: string | undefined;
90
+ let cacheDir: string | undefined;
91
+ let cwd: string | undefined;
92
+ let entry: string | undefined;
93
+ let project: string | undefined;
94
+
95
+ while (head.length !== 0) {
96
+ const current = head.shift()!;
97
+ if (entry) {
98
+ passthrough.push(current, ...head);
99
+ break;
100
+ }
101
+ switch (current) {
102
+ case "-h":
103
+ case "--help":
104
+ return "help";
105
+ case "-v":
106
+ case "--version":
107
+ return "version";
108
+ case "-P":
109
+ case "--project":
110
+ project = takeValue(current, head);
111
+ break;
112
+ case "--cwd":
113
+ cwd = takeValue(current, head);
114
+ break;
115
+ case "--cache-dir":
116
+ cacheDir = takeValue(current, head);
117
+ break;
118
+ case "-r":
119
+ case "--require":
120
+ preload.push(takeValue(current, head));
121
+ break;
122
+ case "--binary":
123
+ binary = takeValue(current, head);
124
+ break;
125
+ default:
126
+ if (current.startsWith("--project=")) {
127
+ project = current.slice("--project=".length);
128
+ } else if (current.startsWith("--cwd=")) {
129
+ cwd = current.slice("--cwd=".length);
130
+ } else if (current.startsWith("--cache-dir=")) {
131
+ cacheDir = current.slice("--cache-dir=".length);
132
+ } else if (current.startsWith("--binary=")) {
133
+ binary = current.slice("--binary=".length);
134
+ } else if (current.startsWith("-")) {
135
+ throw new Error(`ttsx: unknown option ${current}`);
136
+ } else {
137
+ entry = current;
138
+ }
139
+ break;
140
+ }
141
+ }
142
+
143
+ if (!entry) {
144
+ throw new Error("ttsx: entry file is required");
145
+ }
146
+
147
+ return {
148
+ binary,
149
+ cacheDir,
150
+ cwd,
151
+ entry,
152
+ passthrough,
153
+ preload,
154
+ project,
155
+ };
156
+ }
157
+
158
+ function printHelp(): void {
159
+ process.stdout.write(
160
+ [
161
+ "ttsx — TypeScript runner provided by ttsc.",
162
+ "",
163
+ "Usage:",
164
+ " ttsx [options] <entry.ts> [-- <argv...>]",
165
+ "",
166
+ "Options:",
167
+ " -P, --project <file> Use an explicit tsconfig.json",
168
+ " --cwd <dir> Resolve entry/project relative to this directory",
169
+ " --cache-dir <dir> Override the compiled JS cache directory",
170
+ " --binary <path> Use an explicit ttsc native binary",
171
+ " -r, --require <file> Preload a module before the entrypoint",
172
+ " -h, --help Show this help",
173
+ " -v, --version Print the runner version",
174
+ "",
175
+ "Runtime:",
176
+ " CommonJS projects execute in-process via the require hook.",
177
+ " ESM projects execute the cached emitted entry in a child Node process.",
178
+ "",
179
+ "Examples:",
180
+ " ttsx src/index.ts",
181
+ " ttsx --project tsconfig.json src/index.ts -- --port 3000",
182
+ ].join("\n"),
183
+ );
184
+ process.stdout.write("\n");
185
+ }
186
+
187
+ function resolvePreload(cwd: string, preload: string): string {
188
+ if (preload.startsWith(".") || preload.startsWith("/") || preload.includes(path.sep)) {
189
+ return path.resolve(cwd, preload);
190
+ }
191
+ return preload;
192
+ }
193
+
194
+ function takeValue(flag: string, rest: string[]): string {
195
+ const value = rest.shift();
196
+ if (!value) {
197
+ throw new Error(`ttsx: ${flag} requires a value`);
198
+ }
199
+ return value;
200
+ }
201
+
202
+ function runEsmEntry(parsed: ParsedCLI, execution: PreparedExecution, cwd: string): number {
203
+ fs.mkdirSync(execution.emitDir, { recursive: true });
204
+ rewriteEsmSpecifiers(execution.emitDir);
205
+ fs.writeFileSync(
206
+ path.join(execution.emitDir, "package.json"),
207
+ JSON.stringify({ type: "module" }),
208
+ "utf8",
209
+ );
210
+ const args = [
211
+ ...parsed.preload.flatMap((preload) => ["-r", resolvePreload(cwd, preload)]),
212
+ execution.entryFile,
213
+ ...parsed.passthrough,
214
+ ];
215
+ const result = spawnSync(process.execPath, args, {
216
+ stdio: "inherit",
217
+ env: process.env,
218
+ windowsHide: true,
219
+ });
220
+ if (result.error) {
221
+ process.stderr.write(`${result.error.message}\n`);
222
+ return 1;
223
+ }
224
+ return result.status ?? 1;
225
+ }
226
+
227
+ function rewriteEsmSpecifiers(root: string): void {
228
+ const stack = [root];
229
+ while (stack.length !== 0) {
230
+ const current = stack.pop()!;
231
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
232
+ const next = path.join(current, entry.name);
233
+ if (entry.isDirectory()) {
234
+ stack.push(next);
235
+ continue;
236
+ }
237
+ if (!entry.isFile() || !isJavaScriptOutput(next)) {
238
+ continue;
239
+ }
240
+ const before = fs.readFileSync(next, "utf8");
241
+ const after = before
242
+ .replace(
243
+ /\bfrom\s+(['"])(\.[^'"]+)\1/g,
244
+ (_, quote: string, specifier: string) =>
245
+ `from ${quote}${withResolvableExtension(next, specifier)}${quote}`,
246
+ )
247
+ .replace(
248
+ /\bimport\(\s*(['"])(\.[^'"]+)\1\s*\)/g,
249
+ (_, quote: string, specifier: string) =>
250
+ `import(${quote}${withResolvableExtension(next, specifier)}${quote})`,
251
+ );
252
+ if (after !== before) {
253
+ fs.writeFileSync(next, after, "utf8");
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ function withResolvableExtension(fromFile: string, specifier: string): string {
260
+ if (!specifier.startsWith(".")) {
261
+ return specifier;
262
+ }
263
+ if (/\.(?:[cm]?js|json|node)$/i.test(specifier)) {
264
+ return specifier;
265
+ }
266
+ const [pathname, suffix = ""] = splitSpecifierSuffix(specifier);
267
+ const fromDir = path.dirname(fromFile);
268
+ for (const extension of [".js", ".mjs", ".cjs"]) {
269
+ if (fs.existsSync(path.resolve(fromDir, pathname + extension))) {
270
+ return pathname + extension + suffix;
271
+ }
272
+ }
273
+ return `${specifier}.js`;
274
+ }
275
+
276
+ function splitSpecifierSuffix(specifier: string): [string, string?] {
277
+ const index = specifier.search(/[?#]/);
278
+ if (index === -1) {
279
+ return [specifier];
280
+ }
281
+ return [specifier.slice(0, index), specifier.slice(index)];
282
+ }
283
+
284
+ function isJavaScriptOutput(filename: string): boolean {
285
+ return /\.(?:[cm]?js)$/i.test(filename);
286
+ }