ts-node-pack 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Turadg Aleahmad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # ts-node-pack
2
+
3
+ Pack a TypeScript package into a Node-compatible npm tarball — without modifying the source tree, without bundling, and without changing module resolution semantics.
4
+
5
+ Given a TypeScript package whose sources use `.ts` files and `.ts` in relative import specifiers, `ts-node-pack` produces a `.tgz` whose contents are plain `.js` + `.d.ts` with correct `.js` specifiers, ready to `npm install` into any Node ESM project.
6
+
7
+ ## Why
8
+
9
+ TypeScript 5.7 introduced [`rewriteRelativeImportExtensions`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths), which lets you author `import './foo.ts'` and have `tsc` emit `import './foo.js'` in the compiled `.js`. But:
10
+
11
+ - `tsc` does **not** rewrite `.ts` specifiers inside emitted `.d.ts` files.
12
+ - Your `package.json` (`main`, `module`, `exports`, `types`) still points at `.ts`.
13
+ - You probably don't want the `.ts` sources in the published tarball at all.
14
+
15
+ `ts-node-pack` wraps `tsc` + `npm pack` and fills in exactly those gaps.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ npm install --save-dev ts-node-pack
21
+ ```
22
+
23
+ Requires Node ≥ 20 and TypeScript ≥ 5.7 available in the package being packed (resolved via `npx tsc`).
24
+
25
+ ## Usage
26
+
27
+ ```sh
28
+ ts-node-pack <packageDir> [--tsconfig <path>] [--emit-only] [--keep-temp] [--verbose]
29
+ ```
30
+
31
+ | Flag | Description |
32
+ | ------------------- | -------------------------------------------------------------------------------------------- |
33
+ | `--tsconfig <path>` | tsconfig to extend. Defaults to `tsconfig.build.json` if present, otherwise `tsconfig.json`. |
34
+ | `--emit-only` | Run emit + rewrites + validation, but skip `npm pack`. Prints the staging directory. |
35
+ | `--keep-temp` | Do not delete the temporary staging directory on exit. |
36
+ | `-v`, `--verbose` | Log each pipeline phase to stderr. |
37
+ | `-h`, `--help` | Show help. |
38
+
39
+ The resulting `<name>-<version>.tgz` is written to the current working directory.
40
+
41
+ ### Example
42
+
43
+ ```sh
44
+ cd my-project
45
+ ts-node-pack ./packages/core --verbose
46
+ npm install ./my-core-1.2.3.tgz
47
+ ```
48
+
49
+ ## Pipeline
50
+
51
+ 1. **Resolve package** — read `package.json`, pick tsconfig.
52
+ 2. **Stage** — create `mkdtemp()/package/`.
53
+ 3. **Derived tsconfig** — write `tsconfig.emit.json` _inside the temp dir_ that `extends` the chosen tsconfig (by absolute path) and forces `outDir`, `declaration`, `rewriteRelativeImportExtensions: true`, `noEmit: false`. If the base tsconfig enables `sourceMap`, `inlineSourceMap`, or `declarationMap`, `inlineSources: true` is also set so debuggers get full source-level fidelity without any `.ts` files in the tarball.
54
+ 4. **Emit** — run `tsc -p` against the derived config.
55
+ 5. **Rewrite `.d.ts`** — for each emitted `.d.ts`, rewrite `./foo.ts` → `./foo.js` in `import` / `export from` / dynamic `import()` specifiers. Non-relative specifiers are left alone.
56
+ 6. **Rewrite `package.json`** — rewrite `.ts` → `.js` (and → `.d.ts` under `types` conditions) in `main`, `module`, `types`, `typings`, `bin`, `exports`, and the `files` array. Strip `devDependencies` and `scripts`.
57
+ 7. **Copy assets** — `README*`, `LICENSE*`, `CHANGELOG*`, `NOTICE*`. Source `.ts` files are never copied.
58
+ 8. **Validate** — fail if any `.ts` specifier remains in emitted `.js` / `.d.ts` / `package.json`, or if a referenced entry point does not exist.
59
+ 9. **Pack** — `npm pack` in the staging directory; move the tarball to the original CWD.
60
+ 10. **Cleanup** — remove `.ts-node-pack/` and the temp directory (unless `--keep-temp`).
61
+
62
+ The source tree is never mutated. All intermediate artifacts (derived tsconfig, staging dir, tarball) live under a single `mkdtemp()` directory that is removed on exit.
63
+
64
+ ### Sourcemaps
65
+
66
+ If your tsconfig has `sourceMap` (or `inlineSourceMap` / `declarationMap`) enabled, `ts-node-pack` automatically forces `inlineSources: true` so each emitted `.map` embeds its source text via `sourcesContent`. This gives full source-level debugging and "Go to Definition" without shipping `.ts` files — sidestepping dual-resolution hazards where a downstream bundler or TS project might pick `./foo.ts` over `./foo.js`. Detection is a shallow read of the chosen tsconfig; if you inherit `sourceMap` from a base config via `extends`, set it explicitly at the leaf.
67
+
68
+ ## Non-goals
69
+
70
+ - No bundling.
71
+ - No AST transforms.
72
+ - No custom module resolution.
73
+ - No `npm publish` logic.
74
+
75
+ ## License
76
+
77
+ MIT
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "ts-node-pack",
3
+ "version": "0.1.0",
4
+ "description": "Pack a TypeScript package into a Node-compatible npm tarball without modifying the source tree",
5
+ "keywords": [
6
+ "cli",
7
+ "esm",
8
+ "npm",
9
+ "pack",
10
+ "typescript"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Turadg Aleahmad <turadg@aleahmad.net>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/turadg/ts-node-pack.git"
17
+ },
18
+ "bin": {
19
+ "ts-node-pack": "./src/cli.js"
20
+ },
21
+ "files": [
22
+ "src"
23
+ ],
24
+ "type": "module",
25
+ "main": "./src/index.js",
26
+ "exports": {
27
+ ".": "./src/index.js"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "packageManager": "pnpm@10.33.0",
33
+ "dependencies": {
34
+ "npm-packlist": "^10.0.4",
35
+ "ts-blank-space": "^0.8.0"
36
+ },
37
+ "types": "./src/index.d.ts"
38
+ }
package/src/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/src/cli.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { resolve } from "node:path";
5
+ import { tsNodePack } from "./index.js";
6
+
7
+ const { values, positionals } = parseArgs({
8
+ allowPositionals: true,
9
+ options: {
10
+ tsconfig: { type: "string" },
11
+ "emit-only": { type: "boolean", default: false },
12
+ "keep-temp": { type: "boolean", default: false },
13
+ verbose: { type: "boolean", short: "v", default: false },
14
+ help: { type: "boolean", short: "h", default: false },
15
+ },
16
+ });
17
+
18
+ if (values.help || positionals.length === 0) {
19
+ console.log(`Usage: ts-node-pack <packageDir> [options]
20
+
21
+ Options:
22
+ --tsconfig <path> Path to tsconfig (default: tsconfig.build.json or tsconfig.json)
23
+ --emit-only Emit compiled files without packing
24
+ --keep-temp Keep temporary staging directory
25
+ -v, --verbose Verbose output
26
+ -h, --help Show this help message
27
+ `);
28
+ process.exit(values.help ? 0 : 1);
29
+ }
30
+
31
+ const packageDir = resolve(positionals[0]);
32
+
33
+ try {
34
+ const result = await tsNodePack(packageDir, {
35
+ tsconfig: values.tsconfig,
36
+ emitOnly: values["emit-only"],
37
+ keepTemp: values["keep-temp"],
38
+ verbose: values.verbose,
39
+ });
40
+ console.log(result);
41
+ } catch (err) {
42
+ console.error(err.message);
43
+ process.exit(1);
44
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ export interface TsNodePackOptions {
2
+ tsconfig?: string;
3
+ emitOnly?: boolean;
4
+ keepTemp?: boolean;
5
+ verbose?: boolean;
6
+ }
7
+ /**
8
+ * Main pipeline: pack a TypeScript package into a Node-compatible tarball.
9
+ * Returns the path to the .tgz file (or the staging dir if --emit-only).
10
+ */
11
+ export declare function tsNodePack(packageDir: string, options?: TsNodePackOptions): Promise<string>;
12
+ /**
13
+ * Rewrite .ts/.tsx extensions to .js in relative import/export specifiers.
14
+ *
15
+ * Why regex is sound here:
16
+ *
17
+ * The input is exclusively tsc-generated .d.ts files, not arbitrary source.
18
+ * tsc emits a highly constrained subset of syntax in declarations:
19
+ *
20
+ * - Import/export specifiers are always single-line string literals.
21
+ * - Specifiers are always wrapped in matching quotes (' or ").
22
+ * - Relative paths always start with ./ or ../ (tsc never normalizes these).
23
+ * - No template literals, concatenation, or computed specifiers appear.
24
+ * - The only keywords introducing module specifiers are `from`, `import`,
25
+ * and `import()`.
26
+ *
27
+ * Because the grammar of specifiers in .d.ts output is regular (a finite
28
+ * set of keyword prefixes + a quoted string literal), regex matches it
29
+ * exactly — no ambiguity, no context-sensitivity, no false positives.
30
+ *
31
+ * If tsc ever adds native .d.ts rewriting (TypeScript#56556), this
32
+ * function becomes a no-op: the `if (rewritten !== original)` guard
33
+ * in rewriteDtsFiles means no file is touched.
34
+ *
35
+ * Handles:
36
+ * - Named/default/type imports: import { x } from './foo.js'
37
+ * - Re-exports: export { x } from './foo.js'
38
+ * - Star re-exports: export * from './foo.js'
39
+ * - Side-effect imports: import './foo.js'
40
+ * - Dynamic imports: import('./foo.js')
41
+ * - .tsx extensions: import './component.js'
42
+ *
43
+ * Does NOT rewrite (by design):
44
+ * - Non-relative specifiers: import 'lodash'
45
+ * - Already-.js specifiers: import './foo.js'
46
+ * - import.meta.url: not a module specifier
47
+ * - require(): not valid in ESM .d.ts
48
+ * - Bare string literals: const x = './foo.ts'
49
+ */
50
+ export declare const TS_SPECIFIER_PATTERNS: RegExp[];
51
+ export declare function rewriteTsSpecifiers(content: any): any;
52
+ /**
53
+ * Resolve `workspace:` protocol specifiers in the package's dependency
54
+ * fields to concrete versions, matching yarn-berry and pnpm behavior.
55
+ *
56
+ * Translation rules:
57
+ * workspace:* → <version>
58
+ * workspace:^ → ^<version>
59
+ * workspace:~ → ~<version>
60
+ * workspace:<range> → <range>
61
+ *
62
+ * Walks upward from packageDir to find the workspace root (the first
63
+ * ancestor whose package.json has a `workspaces` field), expands its
64
+ * workspace globs to locate sibling packages, and builds a
65
+ * `name → version` map.
66
+ *
67
+ * Throws if the package has `workspace:` specifiers but no workspace
68
+ * root can be found, or if a referenced sibling package is unknown.
69
+ * These failure modes indicate a broken workspace, not a ts-node-pack
70
+ * bug — surface them loudly rather than shipping an unpublishable
71
+ * tarball.
72
+ */
73
+ export declare function resolveWorkspaceDependencies(pkg: any, packageDir: any, log: any): Promise<any>;
74
+ /**
75
+ * Translate a single `workspace:` specifier into the published form.
76
+ * workspace:* → <version>
77
+ * workspace:^ → ^<version>
78
+ * workspace:~ → ~<version>
79
+ * workspace:<range> → <range>
80
+ */
81
+ export declare function resolveWorkspaceSpec(spec: any, version: any): any;
82
+ export declare function rewritePackageJson(pkg: any): any;
package/src/index.js ADDED
@@ -0,0 +1,771 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ copyFile,
5
+ mkdir,
6
+ mkdtemp,
7
+ readdir,
8
+ readFile,
9
+ rm,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { basename, dirname, join, relative, resolve } from "node:path";
15
+ import { promisify } from "node:util";
16
+ import packlist from "npm-packlist";
17
+ import tsBlankSpace from "ts-blank-space";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+ /**
29
+ * Main pipeline: pack a TypeScript package into a Node-compatible tarball.
30
+ * Returns the path to the .tgz file (or the staging dir if --emit-only).
31
+ */
32
+ export async function tsNodePack(
33
+ packageDir ,
34
+ options = {},
35
+ ) {
36
+ const { tsconfig, emitOnly, keepTemp, verbose } = options;
37
+ const log = verbose ? (...args ) => console.error("[ts-node-pack]", ...args) : () => {};
38
+
39
+ packageDir = resolve(packageDir);
40
+
41
+ // ── Phase 1: Resolve package ──────────────────────────────────────────
42
+ log("Phase 1: Resolving package...");
43
+ const pkgJsonPath = join(packageDir, "package.json");
44
+ const pkgJson = JSON.parse(await readFile(pkgJsonPath, "utf8"));
45
+ log(`Package: ${pkgJson.name}@${pkgJson.version}`);
46
+
47
+ const tsconfigPath = resolveTsconfig(packageDir, tsconfig);
48
+ log(`Using tsconfig: ${tsconfigPath}`);
49
+
50
+ // ── Phase 2: Create staging directory ─────────────────────────────────
51
+ log("Phase 2: Creating staging directory...");
52
+ const tmpDir = await mkdtemp(join(tmpdir(), "ts-node-pack-"));
53
+ const stagingDir = join(tmpDir, "package");
54
+ await mkdir(stagingDir, { recursive: true });
55
+ log(`Staging: ${stagingDir}`);
56
+
57
+ try {
58
+ // ── Phase 3: Generate derived tsconfig ────────────────────────────────
59
+ // Written inside the temp dir — never touches the source tree.
60
+ // `extends` takes an absolute path; paths defined by the base tsconfig
61
+ // (include/files/exclude/rootDir/paths) resolve relative to the base
62
+ // config's own location, which is still inside packageDir, so globs
63
+ // continue to work.
64
+ log("Phase 3: Generating derived tsconfig...");
65
+ const emitConfigPath = join(tmpDir, "tsconfig.emit.json");
66
+
67
+ // Because the emit config lives outside packageDir, tsc's typeRoots
68
+ // walk-up (which starts at the config's directory) can't reach
69
+ // packageDir/node_modules/@types. Pin typeRoots explicitly when that
70
+ // directory exists so entries named in the base config's `types`
71
+ // field — e.g. `types: ["node"]` — can be resolved. TS 6.0 made this
72
+ // explicit opt-in (no more auto-inclusion of every @types package),
73
+ // which surfaces the missing resolution as a clear TS2688 error
74
+ // rather than silent "Cannot find module 'node:util'".
75
+ const atTypesDir = join(packageDir, "node_modules", "@types");
76
+ const hasAtTypes = existsSync(atTypesDir);
77
+
78
+ const emitConfig = {
79
+ extends: tsconfigPath,
80
+ compilerOptions: {
81
+ // Force rootDir so emit preserves the source layout — otherwise
82
+ // tsc infers the common ancestor and strips the `src/` prefix,
83
+ // breaking `main`/`exports` that reference `./src/...`.
84
+ rootDir: packageDir,
85
+ outDir: stagingDir,
86
+ declaration: true,
87
+ // ts-blank-space handles .js emit (Phase 6); tsc only emits .d.ts.
88
+ emitDeclarationOnly: true,
89
+ // Extract types from JS+JSDoc sources in mixed packages.
90
+ allowJs: true,
91
+ noEmit: false,
92
+ // Incremental/composite would try to write a .tsbuildinfo whose
93
+ // path tsc computes relative to the base config, producing
94
+ // garbled paths when our outDir crosses directory trees.
95
+ incremental: false,
96
+ composite: false,
97
+ tsBuildInfoFile: null,
98
+ ...(hasAtTypes ? { typeRoots: [atTypesDir] } : {}),
99
+ },
100
+ };
101
+ await writeFile(emitConfigPath, JSON.stringify(emitConfig, null, 2) + "\n");
102
+
103
+ // ── Phase 4: Copy the npm-packlist into staging ──────────────────────
104
+ // `npm-packlist` is the same file-enumeration engine `npm pack` uses
105
+ // internally. It applies `files`, `.npmignore`, the npm default
106
+ // includes (package.json, README*, LICENSE*) and default excludes
107
+ // (.git, node_modules, ...) — so the staging directory ends up as a
108
+ // faithful mirror of whatever `npm pack` would have produced from
109
+ // the source tree, minus our downstream transforms (ts-blank-space,
110
+ // declaration emit, specifier rewrite).
111
+ log("Phase 4: Enumerating package files via npm-packlist...");
112
+ // npm-packlist's tree-based API (v7+) expects a node with `path` +
113
+ // parsed `package.json`. We fabricate a minimal tree rather than
114
+ // pulling in @npmcli/arborist — we don't need bundled-dependency
115
+ // resolution, workspace walking, or the rest of arborist's
116
+ // machinery, just the pack-file list.
117
+ //
118
+ // `isProjectRoot: true` sends npm-packlist down the
119
+ // "root package" branch of `gatherBundles`, which looks only at
120
+ // bundleDependencies (missing here, so no-op). Without this, it
121
+ // takes the "bundled dep of some other project" branch and tries
122
+ // to walk `edgesOut` — which doesn't exist on our hand-built tree.
123
+ const packFiles = await packlist({
124
+ path: packageDir,
125
+ package: pkgJson,
126
+ isProjectRoot: true,
127
+ });
128
+ // Pre-create every parent directory serially (mkdir on the same
129
+ // path from multiple concurrent calls can race), then copy files
130
+ // in parallel.
131
+ const dirs = new Set (
132
+ packFiles.map((rel) => dirname(join(stagingDir, rel))),
133
+ );
134
+ for (const d of dirs) await mkdir(d, { recursive: true });
135
+ await Promise.all(
136
+ packFiles.map((rel) =>
137
+ copyFile(join(packageDir, rel), join(stagingDir, rel)),
138
+ ),
139
+ );
140
+ log(`Copied ${packFiles.length} file(s) into staging`);
141
+
142
+ // ── Phase 5: Emit declarations via tsc ──────────────────────────────
143
+ // tsc reads sources from packageDir (the base config's include
144
+ // resolves there) and writes .d.ts files into stagingDir via outDir.
145
+ // The copies from Phase 4 are left alone — tsc's outputs land
146
+ // alongside them. tsc may emit declarations for files outside the
147
+ // `files` array (e.g. test/); those won't make it into the final
148
+ // tarball because npm pack only ships what `files` references.
149
+ log("Phase 5: Emitting .d.ts files via tsc...");
150
+ await runTsc(emitConfigPath, packageDir, log);
151
+
152
+ // ── Phase 6: Strip types and rewrite specifiers ─────────────────────
153
+ // Single-pass transform of every staging file that could contain a
154
+ // relative module specifier: .ts/.mts get ts-blank-space'd then
155
+ // specifier-rewritten (one read, one write, original unlinked);
156
+ // .js/.mjs/.d.ts/.d.mts just get specifier-rewritten in place.
157
+ // ts-blank-space preserves line and column positions, so no
158
+ // sourcemaps are needed for debugging.
159
+ log("Phase 6: Stripping types and rewriting specifiers...");
160
+ const { strippedCount, rewrittenCount } = await processStagingFiles(
161
+ stagingDir,
162
+ log,
163
+ );
164
+ log(
165
+ `Stripped ${strippedCount} type-annotated file(s); rewrote ${rewrittenCount} other file(s)`,
166
+ );
167
+
168
+ // ── Phase 7: Rewrite package.json ───────────────────────────────────
169
+ // README, LICENSE, and any other npm-always-included files already
170
+ // landed in staging via Phase 4. We overwrite the copy of
171
+ // package.json with a rewritten one: first resolve any
172
+ // `workspace:*` protocol specifiers (required for monorepo
173
+ // packages to publish), then flip entry paths and strip dev-only
174
+ // fields.
175
+ log("Phase 7: Rewriting package.json...");
176
+ const resolvedPkg = await resolveWorkspaceDependencies(
177
+ pkgJson,
178
+ packageDir,
179
+ log,
180
+ );
181
+ const rewrittenPkg = rewritePackageJson(resolvedPkg);
182
+ await writeFile(
183
+ join(stagingDir, "package.json"),
184
+ JSON.stringify(rewrittenPkg, null, 2) + "\n",
185
+ );
186
+
187
+ // ── Phase 8: Validate ─────────────────────────────────────────────────
188
+ log("Phase 8: Validating...");
189
+ await validate(stagingDir, rewrittenPkg, log);
190
+
191
+ // ── Phase 9: Pack ─────────────────────────────────────────────────────
192
+ if (!emitOnly) {
193
+ log("Phase 9: Packing...");
194
+ const tgzPath = await pack(stagingDir, log);
195
+ const tgzName = basename(tgzPath);
196
+ const dest = join(process.cwd(), tgzName);
197
+ await copyFile(tgzPath, dest);
198
+ log(`Created: ${dest}`);
199
+ return dest;
200
+ }
201
+
202
+ log(`Emit-only mode. Staging directory: ${stagingDir}`);
203
+ return stagingDir;
204
+ } finally {
205
+ // ── Phase 10: Cleanup ───────────────────────────────────────────────
206
+ if (!emitOnly && !keepTemp) {
207
+ log("Phase 10: Cleaning up temp directory...");
208
+ await rm(tmpDir, { recursive: true, force: true });
209
+ } else if (keepTemp) {
210
+ log(`Keeping temp directory: ${tmpDir}`);
211
+ }
212
+ }
213
+ }
214
+
215
+ // ── Phase helpers ──────────────────────────────────────────────────────────
216
+
217
+ function resolveTsconfig(packageDir, tsconfigOption) {
218
+ if (tsconfigOption) {
219
+ const p = resolve(packageDir, tsconfigOption);
220
+ if (!existsSync(p)) {
221
+ throw new Error(`tsconfig not found: ${p}`);
222
+ }
223
+ return p;
224
+ }
225
+
226
+ const buildConfig = join(packageDir, "tsconfig.build.json");
227
+ if (existsSync(buildConfig)) return buildConfig;
228
+
229
+ const defaultConfig = join(packageDir, "tsconfig.json");
230
+ if (existsSync(defaultConfig)) return defaultConfig;
231
+
232
+ throw new Error(`No tsconfig found in ${packageDir}. Provide one with --tsconfig.`);
233
+ }
234
+
235
+ /**
236
+ * Walk upward from `startDir`, returning the first `node_modules/.bin/<name>`
237
+ * that exists. Mirrors how npm composes `$PATH` for scripts in a workspace.
238
+ * Returns null when nothing is found before the filesystem root.
239
+ */
240
+ function findLocalBin(startDir, name) {
241
+ let dir = startDir;
242
+ while (true) {
243
+ const candidate = join(dir, "node_modules", ".bin", name);
244
+ if (existsSync(candidate)) return candidate;
245
+ const parent = dirname(dir);
246
+ if (parent === dir) return null;
247
+ dir = parent;
248
+ }
249
+ }
250
+
251
+ async function runTsc(emitConfigPath, cwd, log) {
252
+ // Prefer a local tsc so users control the compiler version (and to avoid
253
+ // npx resolving to macOS's /usr/bin/tsc — the TeX/Smalltalk compiler —
254
+ // when no local install exists). In a monorepo, the package's own
255
+ // node_modules/.bin may be empty while the workspace root has the
256
+ // binary, so walk upward the same way npm's `$PATH` composition does.
257
+ const binName = process.platform === "win32" ? "tsc.cmd" : "tsc";
258
+ const localTsc = findLocalBin(cwd, binName);
259
+ const useLocal = localTsc !== null;
260
+ const [cmd, argv] = useLocal
261
+ ? [localTsc, ["-p", emitConfigPath]]
262
+ : ["npx", ["--yes", "tsc", "-p", emitConfigPath]];
263
+ log(useLocal ? `Using local tsc: ${localTsc}` : "Using npx tsc (no local install found)");
264
+ try {
265
+ const { stdout, stderr } = await execFileAsync(cmd, argv, {
266
+ cwd,
267
+ maxBuffer: 10 * 1024 * 1024,
268
+ });
269
+ if (stdout.trim()) log(stdout.trim());
270
+ if (stderr.trim()) log(stderr.trim());
271
+ } catch (err) {
272
+ const output = [err.stdout, err.stderr].filter(Boolean).join("\n").trim();
273
+ if (output) console.error(output);
274
+ throw new Error("TypeScript compilation failed");
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Walk stagingDir for every file that might contain a relative module
280
+ * specifier — .ts/.mts (strip types then rewrite), and .js/.mjs/.d.ts/
281
+ * .d.mts (rewrite specifiers only). Each file is read once and, if
282
+ * changed, written once. Sourcemap files (.d.ts.map, .d.mts.map) are
283
+ * skipped because they're binary-encoded JSON with no specifiers.
284
+ *
285
+ * Throws if ts-blank-space rejects a .ts/.mts file (non-erasable
286
+ * syntax like `enum`, `namespace`, or parameter properties).
287
+ */
288
+ async function processStagingFiles(stagingDir, log) {
289
+ const files = await findFiles(
290
+ stagingDir,
291
+ (name) =>
292
+ (name.endsWith(".ts") && !name.endsWith(".d.ts")) ||
293
+ (name.endsWith(".mts") && !name.endsWith(".d.mts")) ||
294
+ name.endsWith(".js") ||
295
+ name.endsWith(".mjs") ||
296
+ (name.endsWith(".d.ts") && !name.endsWith(".d.ts.map")) ||
297
+ (name.endsWith(".d.mts") && !name.endsWith(".d.mts.map")),
298
+ );
299
+ let strippedCount = 0;
300
+ let rewrittenCount = 0;
301
+ for (const srcPath of files) {
302
+ const rel = relative(stagingDir, srcPath);
303
+ const isTs = srcPath.endsWith(".ts") && !srcPath.endsWith(".d.ts");
304
+ const isMts = srcPath.endsWith(".mts") && !srcPath.endsWith(".d.mts");
305
+ const source = await readFile(srcPath, "utf8");
306
+
307
+ let content = source;
308
+ if (isTs || isMts) {
309
+ const errors = [];
310
+ content = tsBlankSpace(source, (node) => {
311
+ errors.push(
312
+ `${rel}: unsupported non-erasable syntax: ${String(node.getText?.() ?? node).slice(0, 80)}`,
313
+ );
314
+ });
315
+ if (errors.length > 0) {
316
+ throw new Error(
317
+ "ts-blank-space rejected non-erasable TypeScript:\n " +
318
+ errors.join("\n "),
319
+ );
320
+ }
321
+ }
322
+ content = rewriteTsSpecifiers(content);
323
+
324
+ if (isTs || isMts) {
325
+ const destPath = srcPath.replace(
326
+ isMts ? /\.mts$/ : /\.ts$/,
327
+ isMts ? ".mjs" : ".js",
328
+ );
329
+ await writeFile(destPath, content);
330
+ await unlink(srcPath);
331
+ strippedCount++;
332
+ log(` Stripped: ${rel} → ${basename(destPath)}`);
333
+ } else if (content !== source) {
334
+ await writeFile(srcPath, content);
335
+ rewrittenCount++;
336
+ log(` Rewrote: ${rel}`);
337
+ }
338
+ }
339
+ return { strippedCount, rewrittenCount };
340
+ }
341
+
342
+ /**
343
+ * Rewrite .ts/.tsx extensions to .js in relative import/export specifiers.
344
+ *
345
+ * Why regex is sound here:
346
+ *
347
+ * The input is exclusively tsc-generated .d.ts files, not arbitrary source.
348
+ * tsc emits a highly constrained subset of syntax in declarations:
349
+ *
350
+ * - Import/export specifiers are always single-line string literals.
351
+ * - Specifiers are always wrapped in matching quotes (' or ").
352
+ * - Relative paths always start with ./ or ../ (tsc never normalizes these).
353
+ * - No template literals, concatenation, or computed specifiers appear.
354
+ * - The only keywords introducing module specifiers are `from`, `import`,
355
+ * and `import()`.
356
+ *
357
+ * Because the grammar of specifiers in .d.ts output is regular (a finite
358
+ * set of keyword prefixes + a quoted string literal), regex matches it
359
+ * exactly — no ambiguity, no context-sensitivity, no false positives.
360
+ *
361
+ * If tsc ever adds native .d.ts rewriting (TypeScript#56556), this
362
+ * function becomes a no-op: the `if (rewritten !== original)` guard
363
+ * in rewriteDtsFiles means no file is touched.
364
+ *
365
+ * Handles:
366
+ * - Named/default/type imports: import { x } from './foo.js'
367
+ * - Re-exports: export { x } from './foo.js'
368
+ * - Star re-exports: export * from './foo.js'
369
+ * - Side-effect imports: import './foo.js'
370
+ * - Dynamic imports: import('./foo.js')
371
+ * - .tsx extensions: import './component.js'
372
+ *
373
+ * Does NOT rewrite (by design):
374
+ * - Non-relative specifiers: import 'lodash'
375
+ * - Already-.js specifiers: import './foo.js'
376
+ * - import.meta.url: not a module specifier
377
+ * - require(): not valid in ESM .d.ts
378
+ * - Bare string literals: const x = './foo.ts'
379
+ */
380
+ // Three shapes for a relative TypeScript specifier in a string literal:
381
+ // 1. `from './foo.js'` — named/default/type imports, re-exports
382
+ // 2. `import './foo.js'` — bare side-effect imports
383
+ // 3. `import('./foo.js')` — dynamic imports
384
+ //
385
+ // The extension capture group matches `ts`, `tsx`, or `mts`. Rewriting
386
+ // turns `.ts`/`.tsx` into `.js` and `.mts` into `.mjs`, matching the
387
+ // output extensions ts-blank-space produces in Phase 6.
388
+ //
389
+ // Used both to rewrite specifiers and (in the validator) to detect any
390
+ // specifier that slipped through. Must stay in sync with each other.
391
+ export const TS_SPECIFIER_PATTERNS = [
392
+ /(from\s*['"])(\.\.?\/[^'"]*?)\.(tsx?|mts)(['"])/g,
393
+ /(import\s+['"])(\.\.?\/[^'"]*?)\.(tsx?|mts)(['"])/g,
394
+ /(import\s*\(\s*['"])(\.\.?\/[^'"]*?)\.(tsx?|mts)(['"]\s*\))/g,
395
+ ];
396
+
397
+ export function rewriteTsSpecifiers(content) {
398
+ for (const pattern of TS_SPECIFIER_PATTERNS) {
399
+ content = content.replace(pattern, (_m, pre, path, ext, post) =>
400
+ pre + path + (ext === "mts" ? ".mjs" : ".js") + post,
401
+ );
402
+ }
403
+ return content;
404
+ }
405
+
406
+ const DEP_FIELDS_WITH_WORKSPACE = [
407
+ "dependencies",
408
+ "peerDependencies",
409
+ "optionalDependencies",
410
+ "devDependencies",
411
+ ];
412
+
413
+ /**
414
+ * Resolve `workspace:` protocol specifiers in the package's dependency
415
+ * fields to concrete versions, matching yarn-berry and pnpm behavior.
416
+ *
417
+ * Translation rules:
418
+ * workspace:* → <version>
419
+ * workspace:^ → ^<version>
420
+ * workspace:~ → ~<version>
421
+ * workspace:<range> → <range>
422
+ *
423
+ * Walks upward from packageDir to find the workspace root (the first
424
+ * ancestor whose package.json has a `workspaces` field), expands its
425
+ * workspace globs to locate sibling packages, and builds a
426
+ * `name → version` map.
427
+ *
428
+ * Throws if the package has `workspace:` specifiers but no workspace
429
+ * root can be found, or if a referenced sibling package is unknown.
430
+ * These failure modes indicate a broken workspace, not a ts-node-pack
431
+ * bug — surface them loudly rather than shipping an unpublishable
432
+ * tarball.
433
+ */
434
+ export async function resolveWorkspaceDependencies(pkg, packageDir, log) {
435
+ if (!hasWorkspaceDeps(pkg)) return pkg;
436
+
437
+ const workspaceRoot = await findWorkspaceRoot(packageDir);
438
+ if (!workspaceRoot) {
439
+ throw new Error(
440
+ `${pkg.name || "package"}: found 'workspace:' dependencies but no workspace root ancestor has a 'workspaces' field`,
441
+ );
442
+ }
443
+ log(`Resolving workspace: deps against workspace root ${workspaceRoot}`);
444
+
445
+ const versionMap = await buildWorkspaceVersionMap(workspaceRoot);
446
+
447
+ const result = { ...pkg };
448
+ for (const field of DEP_FIELDS_WITH_WORKSPACE) {
449
+ const deps = result[field];
450
+ if (!deps || typeof deps !== "object") continue;
451
+ const updated = { ...deps };
452
+ for (const [name, spec] of Object.entries(updated)) {
453
+ if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue;
454
+ const version = versionMap.get(name);
455
+ if (!version) {
456
+ throw new Error(
457
+ `${pkg.name || "package"}: ${field}["${name}"] = "${spec}" but no workspace package named "${name}" was found under ${workspaceRoot}`,
458
+ );
459
+ }
460
+ const resolved = resolveWorkspaceSpec(spec, version);
461
+ updated[name] = resolved;
462
+ log(` ${field}["${name}"]: ${spec} → ${resolved}`);
463
+ }
464
+ result[field] = updated;
465
+ }
466
+ return result;
467
+ }
468
+
469
+ function hasWorkspaceDeps(pkg) {
470
+ for (const field of DEP_FIELDS_WITH_WORKSPACE) {
471
+ const deps = pkg[field];
472
+ if (!deps || typeof deps !== "object") continue;
473
+ for (const spec of Object.values(deps)) {
474
+ if (typeof spec === "string" && spec.startsWith("workspace:")) return true;
475
+ }
476
+ }
477
+ return false;
478
+ }
479
+
480
+ async function findWorkspaceRoot(startDir) {
481
+ let dir = startDir;
482
+ while (true) {
483
+ try {
484
+ const pkg = JSON.parse(
485
+ await readFile(join(dir, "package.json"), "utf8"),
486
+ );
487
+ if (pkg.workspaces) return dir;
488
+ } catch {
489
+ // Missing or unreadable package.json — not the root, keep going.
490
+ }
491
+ const parent = dirname(dir);
492
+ if (parent === dir) return null;
493
+ dir = parent;
494
+ }
495
+ }
496
+
497
+ async function buildWorkspaceVersionMap(workspaceRoot) {
498
+ const rootPkg = JSON.parse(
499
+ await readFile(join(workspaceRoot, "package.json"), "utf8"),
500
+ );
501
+ const patterns = Array.isArray(rootPkg.workspaces)
502
+ ? rootPkg.workspaces
503
+ : rootPkg.workspaces?.packages || [];
504
+
505
+ const map = new Map ();
506
+ for (const pattern of patterns) {
507
+ const dirs = await expandWorkspacePattern(workspaceRoot, pattern);
508
+ for (const d of dirs) {
509
+ try {
510
+ const pkg = JSON.parse(
511
+ await readFile(join(d, "package.json"), "utf8"),
512
+ );
513
+ if (pkg.name && pkg.version) map.set(pkg.name, pkg.version);
514
+ } catch {
515
+ // Missing or malformed sibling package.json — skip.
516
+ }
517
+ }
518
+ }
519
+ return map;
520
+ }
521
+
522
+ /**
523
+ * Expand a workspace pattern (e.g. "packages/*", "apps/*", "solo") into
524
+ * concrete directory paths. Supports `*` as a single-segment wildcard.
525
+ * `**` is not supported — no real-world agoric-sdk-style workspace uses
526
+ * it, and implementing recursive globs without a library is more code
527
+ * than it's worth for the common case.
528
+ */
529
+ async function expandWorkspacePattern(workspaceRoot, pattern) {
530
+ if (pattern.includes("**")) {
531
+ throw new Error(`Unsupported workspace pattern: "${pattern}" (globstar ** is not supported)`);
532
+ }
533
+ const parts = pattern.split("/").filter((p) => p.length > 0);
534
+ let currentDirs = [workspaceRoot];
535
+ for (const part of parts) {
536
+ const next = [];
537
+ for (const base of currentDirs) {
538
+ if (part === "*") {
539
+ try {
540
+ const entries = await readdir(base, { withFileTypes: true });
541
+ for (const e of entries) {
542
+ if (e.isDirectory()) next.push(join(base, e.name));
543
+ }
544
+ } catch {
545
+ // base doesn't exist — skip.
546
+ }
547
+ } else if (part.includes("*")) {
548
+ const regex = new RegExp(
549
+ "^" +
550
+ part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") +
551
+ "$",
552
+ );
553
+ try {
554
+ const entries = await readdir(base, { withFileTypes: true });
555
+ for (const e of entries) {
556
+ if (e.isDirectory() && regex.test(e.name)) next.push(join(base, e.name));
557
+ }
558
+ } catch {
559
+ // base doesn't exist — skip.
560
+ }
561
+ } else {
562
+ next.push(join(base, part));
563
+ }
564
+ }
565
+ currentDirs = next;
566
+ }
567
+ return currentDirs;
568
+ }
569
+
570
+ /**
571
+ * Translate a single `workspace:` specifier into the published form.
572
+ * workspace:* → <version>
573
+ * workspace:^ → ^<version>
574
+ * workspace:~ → ~<version>
575
+ * workspace:<range> → <range>
576
+ */
577
+ export function resolveWorkspaceSpec(spec, version) {
578
+ if (typeof spec !== "string" || !spec.startsWith("workspace:")) return spec;
579
+ const rest = spec.slice("workspace:".length);
580
+ if (rest === "" || rest === "*") return version;
581
+ if (rest === "^") return `^${version}`;
582
+ if (rest === "~") return `~${version}`;
583
+ return rest;
584
+ }
585
+
586
+ export function rewritePackageJson(pkg) {
587
+ const result = { ...pkg };
588
+
589
+ // Remove development-only fields that aren't needed in the published package
590
+ delete result.devDependencies;
591
+ delete result.scripts;
592
+
593
+ // Rewrite entry points
594
+ if (result.main) result.main = rewriteTsToJs(result.main);
595
+ if (result.module) result.module = rewriteTsToJs(result.module);
596
+
597
+ // Rewrite or derive types
598
+ if (result.types) {
599
+ result.types = rewriteTsToDts(result.types);
600
+ } else if (result.typings) {
601
+ result.typings = rewriteTsToDts(result.typings);
602
+ } else if (result.main) {
603
+ result.types = result.main.replace(/\.js$/, ".d.ts");
604
+ }
605
+
606
+ // Rewrite bin
607
+ if (result.bin) {
608
+ if (typeof result.bin === "string") {
609
+ result.bin = rewriteTsToJs(result.bin);
610
+ } else if (typeof result.bin === "object") {
611
+ for (const [key, value] of Object.entries(result.bin)) {
612
+ result.bin[key] = rewriteTsToJs(value);
613
+ }
614
+ }
615
+ }
616
+
617
+ // Rewrite exports
618
+ if (result.exports) {
619
+ result.exports = rewriteExportsValue(result.exports, false);
620
+ }
621
+
622
+ // Rewrite files array
623
+ if (Array.isArray(result.files)) {
624
+ result.files = result.files.flatMap((f) => {
625
+ if (typeof f === "string" && /\.tsx?$/.test(f)) {
626
+ return [rewriteTsToJs(f), rewriteTsToDts(f)];
627
+ }
628
+ return [f];
629
+ });
630
+ }
631
+
632
+ return result;
633
+ }
634
+
635
+ function rewriteTsToJs(p) {
636
+ return typeof p === "string" ? p.replace(/\.tsx?$/, ".js") : p;
637
+ }
638
+
639
+ function rewriteTsToDts(p) {
640
+ return typeof p === "string" ? p.replace(/\.tsx?$/, ".d.ts") : p;
641
+ }
642
+
643
+ function rewriteExportsValue(value, isTypesKey) {
644
+ if (typeof value === "string") {
645
+ return isTypesKey ? rewriteTsToDts(value) : rewriteTsToJs(value);
646
+ }
647
+ if (Array.isArray(value)) {
648
+ return value.map((v) => rewriteExportsValue(v, isTypesKey));
649
+ }
650
+ if (typeof value === "object" && value !== null) {
651
+ const result = {};
652
+ for (const [key, val] of Object.entries(value)) {
653
+ result[key] = rewriteExportsValue(val, key === "types");
654
+ }
655
+ return result;
656
+ }
657
+ return value;
658
+ }
659
+
660
+ async function validate(stagingDir, pkg, log) {
661
+ const errors = [];
662
+
663
+ // Check .js and .d.ts files for remaining .ts specifiers
664
+ const allFiles = await findFiles(
665
+ stagingDir,
666
+ (name) => name.endsWith(".js") || name.endsWith(".d.ts"),
667
+ );
668
+
669
+ for (const filePath of allFiles) {
670
+ const raw = await readFile(filePath, "utf8");
671
+ // Strip block and line comments before scanning so JSDoc examples that
672
+ // literally contain strings like `import './foo.js'` don't register as
673
+ // false positives. Comment stripping here is intentionally coarse
674
+ // (doesn't understand strings-containing-comment-markers) — good enough
675
+ // for tsc-emitted output, where comments are well-behaved.
676
+ const content = raw
677
+ .replace(/\/\*[\s\S]*?\*\//g, "")
678
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
679
+ const relPath = relative(stagingDir, filePath);
680
+
681
+ for (const pattern of TS_SPECIFIER_PATTERNS) {
682
+ for (const m of content.matchAll(pattern)) {
683
+ errors.push(`${relPath}: remaining .ts specifier: ${m[0]}`);
684
+ }
685
+ }
686
+ }
687
+
688
+ // Check package.json for .ts references in entry points
689
+ const entryFields = ["main", "module", "types", "typings"];
690
+ for (const field of entryFields) {
691
+ if (typeof pkg[field] === "string" && /(?<!\.d)\.tsx?$/.test(pkg[field])) {
692
+ errors.push(`package.json "${field}" still references .ts: ${pkg[field]}`);
693
+ }
694
+ }
695
+
696
+ // Non-fatal: yarn/npm tolerate `bin`/`main` pointing at missing
697
+ // files. Real transformation bugs are caught by the specifier
698
+ // checks above.
699
+ const referencedFiles = collectReferencedFiles(pkg);
700
+ for (const ref of referencedFiles) {
701
+ if (!existsSync(join(stagingDir, ref))) {
702
+ log(` Warning: referenced file missing from tarball: ${ref}`);
703
+ }
704
+ }
705
+
706
+ if (errors.length > 0) {
707
+ const msg = "Validation failed:\n " + errors.join("\n ");
708
+ throw new Error(msg);
709
+ }
710
+
711
+ log(`Validated ${allFiles.length} file(s), no issues found`);
712
+ }
713
+
714
+ function collectReferencedFiles(pkg) {
715
+ const refs = new Set ();
716
+
717
+ for (const field of ["main", "module", "types", "typings"]) {
718
+ if (typeof pkg[field] === "string") {
719
+ refs.add(pkg[field].replace(/^\.\//, ""));
720
+ }
721
+ }
722
+
723
+ if (typeof pkg.bin === "string") {
724
+ refs.add(pkg.bin.replace(/^\.\//, ""));
725
+ } else if (typeof pkg.bin === "object" && pkg.bin !== null) {
726
+ for (const v of Object.values(pkg.bin)) {
727
+ if (typeof v === "string") refs.add(v.replace(/^\.\//, ""));
728
+ }
729
+ }
730
+
731
+ if (pkg.exports) {
732
+ collectExportsRefs(pkg.exports, refs);
733
+ }
734
+
735
+ return refs;
736
+ }
737
+
738
+ function collectExportsRefs(value, refs) {
739
+ if (typeof value === "string") {
740
+ refs.add(value.replace(/^\.\//, ""));
741
+ return;
742
+ }
743
+ if (Array.isArray(value)) {
744
+ for (const v of value) collectExportsRefs(v, refs);
745
+ return;
746
+ }
747
+ if (typeof value === "object" && value !== null) {
748
+ for (const v of Object.values(value)) {
749
+ collectExportsRefs(v, refs);
750
+ }
751
+ }
752
+ }
753
+
754
+ async function pack(stagingDir, log) {
755
+ const { stdout } = await execFileAsync("npm", ["pack"], {
756
+ cwd: stagingDir,
757
+ });
758
+ const tgzName = stdout.trim().split("\n").pop();
759
+ return join(stagingDir, tgzName);
760
+ }
761
+
762
+ async function findFiles(dir, test) {
763
+ const results = [];
764
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true });
765
+ for (const entry of entries) {
766
+ if (entry.isFile() && test(entry.name)) {
767
+ results.push(join(entry.parentPath, entry.name));
768
+ }
769
+ }
770
+ return results;
771
+ }