@fexd/toolchain 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/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # @fexd/toolchain
2
+
3
+ `@fexd/toolchain` 是一个面向前端项目的工具链运行器,用于让项目脚本固定使用指定版本的 Node.js 和 pnpm。
4
+
5
+ 它适合这种场景:不同项目依赖不同的 Node.js / pnpm 版本,但开发者希望自己的全局 shell 环境保持不变。通过 `tc` 执行的命令只会在当前子进程中使用项目指定的工具链,不会切换或污染全局 `node` / `pnpm`。
6
+
7
+ ## 特性
8
+
9
+ - 按项目固定 Node.js 版本。
10
+ - 按项目固定 pnpm 版本。
11
+ - 不依赖 `nvm`、`fnm` 或 Corepack。
12
+ - 不修改当前 shell 的全局 `node` / `pnpm`。
13
+ - 支持 Windows、macOS 和 Linux。
14
+ - 支持嵌套脚本中的 `pnpm` 命令继续使用项目指定版本。
15
+ - 自动下载并缓存所需的 Node.js 和 pnpm。
16
+
17
+ ## 安装
18
+
19
+ ```bash
20
+ pnpm add -D @fexd/toolchain
21
+ ```
22
+
23
+ 也可以使用 npm:
24
+
25
+ ```bash
26
+ npm install -D @fexd/toolchain
27
+ ```
28
+
29
+ ## 配置
30
+
31
+ 在项目根目录的 `.npmrc` 中配置工具链版本:
32
+
33
+ ```ini
34
+ tc-version-node=20.19.5
35
+ tc-version-pnpm=9.15.9
36
+ ```
37
+
38
+ 字段说明:
39
+
40
+ - `tc-version-node`:项目脚本使用的 Node.js 版本。
41
+ - `tc-version-pnpm`:项目脚本使用的 pnpm 版本。
42
+
43
+ ## 使用
44
+
45
+ 在 `package.json` scripts 中通过 `tc` 执行命令:
46
+
47
+ ```json
48
+ {
49
+ "scripts": {
50
+ "toolchain:versions": "tc doctor && npm run toolchain:node && npm run toolchain:pnpm",
51
+ "toolchain:node": "tc node -v",
52
+ "toolchain:pnpm": "tc pnpm -v",
53
+ "install:deps": "tc pnpm install",
54
+ "dev": "tc pnpm dev",
55
+ "build": "tc pnpm build"
56
+ }
57
+ }
58
+ ```
59
+
60
+ 常用命令:
61
+
62
+ ```bash
63
+ tc doctor
64
+ tc node -v
65
+ tc node scripts/build.js
66
+ tc pnpm -v
67
+ tc pnpm install
68
+ tc pnpm run build
69
+ ```
70
+
71
+ ## 嵌套脚本
72
+
73
+ `tc` 会在子进程的 `PATH` 前面放入项目指定版本的 pnpm shim。因此通过 `tc pnpm` 启动的脚本中,如果继续执行 `pnpm`,仍然会使用 `.npmrc` 中配置的 pnpm 版本。
74
+
75
+ 例如:
76
+
77
+ ```json
78
+ {
79
+ "scripts": {
80
+ "dev": "tc pnpm exec concurrently \"pnpm --filter @app/web dev\" \"pnpm --filter @app/server dev\""
81
+ }
82
+ }
83
+ ```
84
+
85
+ 上面两个内层 `pnpm` 命令都会使用项目指定的 pnpm 版本。
86
+
87
+ ## 工作方式
88
+
89
+ 执行 `tc` 时会按以下流程运行:
90
+
91
+ 1. 从当前目录向上查找项目根目录。
92
+ 2. 读取项目 `.npmrc` 中的 `tc-version-node` 和 `tc-version-pnpm`。
93
+ 3. 检查本地缓存中是否已有对应版本的 Node.js 和 pnpm。
94
+ 4. 如果缓存不存在,则自动下载并解压。
95
+ 5. 使用指定 Node.js 启动目标命令。
96
+ 6. 为目标命令注入临时 `PATH`,确保子进程优先使用指定 Node.js 和 pnpm。
97
+
98
+ 命令结束后,当前 shell 的全局 `node` / `pnpm` 版本不会变化。
99
+
100
+ ## 缓存目录
101
+
102
+ 默认缓存位置:
103
+
104
+ - Windows:`%LOCALAPPDATA%\fexd-toolchain`
105
+ - macOS:`~/Library/Caches/fexd-toolchain`
106
+ - Linux:`${XDG_CACHE_HOME:-~/.cache}/fexd-toolchain`
107
+
108
+ 可以通过 `TC_HOME` 指定缓存目录:
109
+
110
+ ```bash
111
+ TC_HOME=/path/to/toolchain-cache tc doctor
112
+ ```
113
+
114
+ ## 下载源
115
+
116
+ 默认下载源:
117
+
118
+ - Node.js:`https://nodejs.org/dist`
119
+ - pnpm:`https://registry.npmjs.org`
120
+
121
+ 可以通过环境变量配置镜像:
122
+
123
+ ```bash
124
+ TC_NODE_MIRROR=https://nodejs.org/dist
125
+ TC_NPM_REGISTRY=https://registry.npmjs.org
126
+ ```
127
+
128
+ ## Roadmap
129
+
130
+ 当前版本聚焦于 Node.js 和 pnpm 的项目级版本管理。后续可以继续扩展到更多项目运行所需的外部工具链:
131
+
132
+ - JDK:支持通过 `.npmrc` 指定项目需要的 Java 版本,并在执行脚本时注入 `JAVA_HOME`。
133
+ - Android SDK:支持管理 Android platform、build-tools、platform-tools 等 SDK 组件。
134
+ - Android NDK:支持为 native 构建固定 NDK 版本。
135
+ - Python:支持为 `node-gyp`、native addon 等构建场景固定 Python 版本,并注入 `PYTHON` 环境变量。
136
+
137
+ 这些能力会继续遵循当前定位:只管理项目脚本运行所需的外部工具链,不替代包管理器,也不管理已经适合放在 `devDependencies` 中的普通前端依赖。
138
+
139
+ ## 注意事项
140
+
141
+ - 启动 `tc` 本身需要本机已有可用的 Node.js。
142
+ - 启动 `tc` 的 Node.js 版本需要满足 `>=14.17`。
143
+ - 第一次使用某个 Node.js / pnpm 版本时需要联网下载。
144
+ - 为了让 scripts 跨平台,建议在脚本中写 `pnpm`,不要写死 `pnpm.cmd`。
package/bin/tc.cjs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ const { runCli } = require('../src/cli.cjs');
3
+
4
+ runCli(process.argv.slice(2)).then((exitCode) => {
5
+ process.exitCode = exitCode;
6
+ }, (error) => {
7
+ console.error(error && error.stack ? error.stack : error);
8
+ process.exitCode = 1;
9
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@fexd/toolchain",
3
+ "version": "0.1.0",
4
+ "description": "Run project scripts with a project-pinned Node.js and pnpm toolchain.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/fexd-team/toolchain.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/fexd-team/toolchain/issues"
11
+ },
12
+ "homepage": "https://github.com/fexd-team/toolchain#readme",
13
+ "bin": {
14
+ "tc": "bin/tc.cjs",
15
+ "fexd-toolchain": "bin/tc.cjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "skills",
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "node test/run-tests.cjs"
25
+ },
26
+ "engines": {
27
+ "node": ">=14.17"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "license": "MIT"
33
+ }
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: migrate-to-fexd-toolchain
3
+ description: Use when converting an existing JavaScript or TypeScript project to @fexd/toolchain, tc, or package.json scripts that pin Node.js and pnpm versions, especially when replacing nvm, fnm, corepack, pnpm.cmd, direct node, or direct pnpm commands while preserving cross-platform behavior.
4
+ ---
5
+
6
+ # Migrate To FEXD Toolchain
7
+
8
+ ## Goal
9
+
10
+ Convert an existing project so its package scripts run through `@fexd/toolchain`:
11
+
12
+ - project scripts use the configured Node.js and pnpm versions;
13
+ - the user's interactive shell keeps its global `node` and `pnpm`;
14
+ - Windows and macOS scripts stay portable;
15
+ - unrelated project changes are preserved.
16
+
17
+ ## Inspect First
18
+
19
+ Before editing, read:
20
+
21
+ - `package.json`
22
+ - `.npmrc`, if present
23
+ - `.node-version` and `.nvmrc`, if present
24
+ - lockfiles and package-manager metadata
25
+ - `git status --short`
26
+
27
+ Do not revert unrelated dirty files. If a dirty file must be edited, inspect it first and preserve the user's changes.
28
+
29
+ ## Version Sources
30
+
31
+ Prefer version sources in this order:
32
+
33
+ 1. existing `.npmrc` values: `tc-version-node`, `tc-version-pnpm`
34
+ 2. user-provided versions
35
+ 3. `packageManager` for the pnpm version
36
+ 4. `.node-version` or `.nvmrc` for the Node.js version
37
+ 5. `engines.node` only when it pins a concrete version
38
+
39
+ If the pnpm version exists in both `.npmrc` and `packageManager`, keep them consistent or ask before choosing one.
40
+
41
+ Update `.npmrc` without removing unrelated npm settings:
42
+
43
+ ```ini
44
+ tc-version-node=20.19.5
45
+ tc-version-pnpm=9.15.9
46
+ ```
47
+
48
+ Keep `packageManager` when it is useful for standard package-manager metadata:
49
+
50
+ ```json
51
+ {
52
+ "packageManager": "pnpm@9.15.9"
53
+ }
54
+ ```
55
+
56
+ ## Script Rewrite Rules
57
+
58
+ Rewrite only commands that need the project-pinned Node.js or pnpm.
59
+
60
+ | Before | After |
61
+ | --- | --- |
62
+ | `node script.js` | `tc node script.js` |
63
+ | `pnpm install` | `tc pnpm install` |
64
+ | `pnpm run build` | `tc pnpm run build` |
65
+ | `pnpm --filter @pkg dev` | `tc pnpm --filter @pkg dev` |
66
+ | `pnpm exec prettier ...` | `tc pnpm exec prettier ...` |
67
+ | `pnpm.cmd ...` | `tc pnpm ...` |
68
+ | `fnm exec node ...` | `tc node ...` |
69
+ | `fnm exec pnpm ...` | `tc pnpm ...` |
70
+ | `fnm exec pnpm.cmd ...` | `tc pnpm ...` |
71
+ | `corepack pnpm@x ...` | `tc pnpm ...` |
72
+
73
+ For nested scripts, keep the outer command under `tc` and use plain `pnpm` inside quoted child commands:
74
+
75
+ ```json
76
+ {
77
+ "dev": "tc pnpm exec concurrently \"pnpm --filter @app/web dev\" \"pnpm --filter @app/server dev\""
78
+ }
79
+ ```
80
+
81
+ Use plain `pnpm`, not `pnpm.cmd`, inside scripts so the same `package.json` works on Windows and macOS. `tc pnpm` injects a pinned pnpm shim into the child process `PATH`.
82
+
83
+ ## What Not To Wrap
84
+
85
+ Do not wrap commands that are intentionally system tools:
86
+
87
+ - `open`
88
+ - `xcrun`
89
+ - `xcodebuild`
90
+ - `ios-deploy`
91
+ - `idevicedebug`
92
+ - `idevicesyslog`
93
+ - `adb`
94
+ - `gradlew` or `./gradlew`, unless the command itself is launched through a pnpm script that needs `tc pnpm`
95
+
96
+ Do not make `tc` manage ordinary npm dependencies such as `vite`, `webpack`, `rollup`, `eslint`, `prettier`, `typescript`, `husky`, or project-specific CLIs. Run them through `tc pnpm exec` or existing package scripts.
97
+
98
+ ## Dependency Handling
99
+
100
+ If `@fexd/toolchain` is not already available in the project, add it as a dev dependency using the project's package manager:
101
+
102
+ ```bash
103
+ pnpm add -D @fexd/toolchain
104
+ ```
105
+
106
+ If the user explicitly asks for a local package link, use the local path they provide instead of adding a registry dependency.
107
+
108
+ ## Validation
109
+
110
+ After migration, run the smallest checks that prove the scripts use `tc` correctly:
111
+
112
+ ```bash
113
+ npm run toolchain:versions
114
+ ```
115
+
116
+ Run one lightweight real project script, such as a small build or package build:
117
+
118
+ ```bash
119
+ npm run build:bridge
120
+ ```
121
+
122
+ Verify nested `pnpm` through a shell command on Windows, because `child_process.execFileSync('pnpm')` does not necessarily resolve `.cmd` files:
123
+
124
+ ```bash
125
+ tc pnpm exec node -e "require('child_process').execSync('pnpm -v', {stdio:'inherit'})"
126
+ ```
127
+
128
+ Optionally confirm the interactive shell is unchanged:
129
+
130
+ ```bash
131
+ node -v
132
+ pnpm -v
133
+ ```
134
+
135
+ Report validation output, remaining warnings, and any unrelated dirty files left untouched.
136
+
137
+ ## Common Mistakes
138
+
139
+ - Replacing inner quoted `pnpm.cmd` with another `pnpm.cmd`; use plain `pnpm`.
140
+ - Wrapping macOS/iOS system commands in `tc`.
141
+ - Removing existing `.npmrc` settings while adding toolchain versions.
142
+ - Treating `engines.node` ranges such as `>=16` as exact install versions.
143
+ - Deleting `.node-version` or `.nvmrc` without confirming they are obsolete for the project.
144
+ - Using global `pnpm` to test a project whose global pnpm version is known to be incompatible.
package/src/cache.cjs ADDED
@@ -0,0 +1,57 @@
1
+ const os = require('os');
2
+ const path = require('path');
3
+ const { getNodeExecutableRelativePath, getPnpmDistribution } = require('./distributions.cjs');
4
+
5
+ const CACHE_DIR_NAME = 'fexd-toolchain';
6
+
7
+ function getDefaultCacheRoot(options) {
8
+ const opts = options || {};
9
+ const platform = opts.platform || process.platform;
10
+ const env = opts.env || process.env;
11
+ const home = opts.home || os.homedir();
12
+
13
+ if (env.TC_HOME) {
14
+ return path.resolve(env.TC_HOME);
15
+ }
16
+
17
+ if (platform === 'win32') {
18
+ return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), CACHE_DIR_NAME);
19
+ }
20
+
21
+ if (platform === 'darwin') {
22
+ return path.join(home, 'Library', 'Caches', CACHE_DIR_NAME);
23
+ }
24
+
25
+ return path.join(env.XDG_CACHE_HOME || path.join(home, '.cache'), CACHE_DIR_NAME);
26
+ }
27
+
28
+ function getNodePaths(cacheRoot, distribution) {
29
+ const installDir = path.join(
30
+ cacheRoot,
31
+ 'node',
32
+ distribution.version,
33
+ distribution.platform + '-' + distribution.arch,
34
+ distribution.folderName
35
+ );
36
+
37
+ return {
38
+ installDir,
39
+ executablePath: path.join(installDir, getNodeExecutableRelativePath(distribution))
40
+ };
41
+ }
42
+
43
+ function getPnpmPaths(cacheRoot, version) {
44
+ const distribution = getPnpmDistribution(version);
45
+ const installDir = path.join(cacheRoot, 'pnpm', distribution.version);
46
+
47
+ return {
48
+ installDir,
49
+ executablePath: path.join(installDir, distribution.executableRelativePath)
50
+ };
51
+ }
52
+
53
+ module.exports = {
54
+ getDefaultCacheRoot,
55
+ getNodePaths,
56
+ getPnpmPaths
57
+ };
package/src/cli.cjs ADDED
@@ -0,0 +1,170 @@
1
+ const path = require('path');
2
+ const childProcess = require('child_process');
3
+ const { readToolchainConfig } = require('./config.cjs');
4
+ const { getDefaultCacheRoot } = require('./cache.cjs');
5
+ const { ensureNode, ensurePnpm } = require('./install.cjs');
6
+ const { ensurePnpmShim, buildToolchainPath } = require('./shims.cjs');
7
+
8
+ async function runCli(argv, dependencies) {
9
+ const deps = dependencies || {};
10
+ const args = Array.isArray(argv) ? argv.slice() : [];
11
+ const command = args.shift();
12
+ const stdout = deps.stdout || process.stdout;
13
+ const stderr = deps.stderr || process.stderr;
14
+ const platform = deps.platform || process.platform;
15
+ const arch = deps.arch || process.arch;
16
+ const env = Object.assign({}, deps.env || process.env);
17
+ const cwd = deps.cwd || process.cwd();
18
+
19
+ if (!command || command === '--help' || command === '-h') {
20
+ stdout.write(usage());
21
+ return 0;
22
+ }
23
+
24
+ if (command !== 'doctor' && command !== 'node' && command !== 'pnpm') {
25
+ stderr.write('Unknown command: ' + command + '\n\n' + usage());
26
+ return 1;
27
+ }
28
+
29
+ let config;
30
+ try {
31
+ config = (deps.readToolchainConfig || readToolchainConfig)(cwd);
32
+ } catch (error) {
33
+ stderr.write(formatError(error) + '\n');
34
+ return 1;
35
+ }
36
+
37
+ if (command === 'doctor') {
38
+ stdout.write([
39
+ 'Project: ' + config.root,
40
+ 'Config: ' + (config.npmrcPath || '(missing .npmrc)'),
41
+ 'Node.js: ' + (config.nodeVersion || '(missing tc-version-node)'),
42
+ 'pnpm: ' + (config.pnpmVersion || '(missing tc-version-pnpm)'),
43
+ ''
44
+ ].join('\n'));
45
+ return config.nodeVersion && config.pnpmVersion ? 0 : 1;
46
+ }
47
+
48
+ if (!config.nodeVersion) {
49
+ stderr.write('Missing tc-version-node in ' + (config.npmrcPath || 'project .npmrc') + '\n');
50
+ return 1;
51
+ }
52
+
53
+ if (command === 'pnpm' && !config.pnpmVersion) {
54
+ stderr.write('Missing tc-version-pnpm in ' + (config.npmrcPath || 'project .npmrc') + '\n');
55
+ return 1;
56
+ }
57
+
58
+ const cacheRoot = (deps.getDefaultCacheRoot || getDefaultCacheRoot)({
59
+ platform,
60
+ env
61
+ });
62
+
63
+ try {
64
+ const node = await (deps.ensureNode || ensureNode)({
65
+ version: config.nodeVersion,
66
+ cacheRoot,
67
+ platform,
68
+ arch
69
+ });
70
+
71
+ if (command === 'node') {
72
+ return spawnAndReturn({
73
+ spawnSync: deps.spawnSync || childProcess.spawnSync,
74
+ command: node.executablePath,
75
+ args,
76
+ cwd,
77
+ env: withPath(env, path.dirname(node.executablePath), null, platform),
78
+ stdio: deps.stdio || 'inherit'
79
+ });
80
+ }
81
+
82
+ const pnpm = await (deps.ensurePnpm || ensurePnpm)({
83
+ version: config.pnpmVersion,
84
+ cacheRoot
85
+ });
86
+ const shim = (deps.ensurePnpmShim || ensurePnpmShim)({
87
+ cacheRoot,
88
+ nodeVersion: config.nodeVersion,
89
+ pnpmVersion: config.pnpmVersion,
90
+ platform,
91
+ arch,
92
+ nodePath: node.executablePath,
93
+ pnpmCliPath: pnpm.executablePath
94
+ });
95
+
96
+ return spawnAndReturn({
97
+ spawnSync: deps.spawnSync || childProcess.spawnSync,
98
+ command: node.executablePath,
99
+ args: [pnpm.executablePath].concat(args),
100
+ cwd,
101
+ env: withPath(env, path.dirname(node.executablePath), shim.dir, platform),
102
+ stdio: deps.stdio || 'inherit'
103
+ });
104
+ } catch (error) {
105
+ stderr.write(formatError(error) + '\n');
106
+ return 1;
107
+ }
108
+ }
109
+
110
+ function spawnAndReturn(options) {
111
+ const result = options.spawnSync(options.command, options.args, {
112
+ cwd: options.cwd,
113
+ env: options.env,
114
+ stdio: options.stdio
115
+ });
116
+
117
+ if (result.error) {
118
+ throw result.error;
119
+ }
120
+
121
+ return typeof result.status === 'number' ? result.status : 0;
122
+ }
123
+
124
+ function withPath(env, nodeBinDir, shimDir, platform) {
125
+ const nextEnv = Object.assign({}, env);
126
+ const pathKey = findPathKey(nextEnv, platform);
127
+ nextEnv[pathKey] = buildToolchainPath({
128
+ currentPath: nextEnv[pathKey],
129
+ shimDir,
130
+ nodeBinDir,
131
+ delimiter: getPathDelimiter(platform)
132
+ });
133
+ return nextEnv;
134
+ }
135
+
136
+ function findPathKey(env, platform) {
137
+ if (platform === 'win32') {
138
+ const existing = Object.keys(env).find((key) => key.toLowerCase() === 'path');
139
+ return existing || 'Path';
140
+ }
141
+
142
+ return 'PATH';
143
+ }
144
+
145
+ function getPathDelimiter(platform) {
146
+ return platform === 'win32' ? ';' : ':';
147
+ }
148
+
149
+ function formatError(error) {
150
+ return error && error.message ? error.message : String(error);
151
+ }
152
+
153
+ function usage() {
154
+ return [
155
+ 'Usage:',
156
+ ' tc doctor',
157
+ ' tc node <args...>',
158
+ ' tc pnpm <args...>',
159
+ '',
160
+ 'Project .npmrc:',
161
+ ' tc-version-node=20.19.5',
162
+ ' tc-version-pnpm=9.15.9',
163
+ ''
164
+ ].join('\n');
165
+ }
166
+
167
+ module.exports = {
168
+ runCli,
169
+ usage
170
+ };
package/src/config.cjs ADDED
@@ -0,0 +1,61 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { parseNpmrcText } = require('./npmrc.cjs');
4
+
5
+ function parentOf(directory) {
6
+ const parent = path.dirname(directory);
7
+ return parent === directory ? null : parent;
8
+ }
9
+
10
+ function hasToolchainConfig(npmrcConfig) {
11
+ return Boolean(npmrcConfig['tc-version-node'] || npmrcConfig['tc-version-pnpm']);
12
+ }
13
+
14
+ function findProjectRoot(startDirectory) {
15
+ let current = path.resolve(startDirectory || process.cwd());
16
+ let nearestPackageRoot = null;
17
+
18
+ while (current) {
19
+ const packageJsonPath = path.join(current, 'package.json');
20
+ if (!nearestPackageRoot && fs.existsSync(packageJsonPath)) {
21
+ nearestPackageRoot = current;
22
+ }
23
+
24
+ const npmrcPath = path.join(current, '.npmrc');
25
+ if (fs.existsSync(npmrcPath)) {
26
+ const npmrcConfig = parseNpmrcText(fs.readFileSync(npmrcPath, 'utf8'));
27
+ if (hasToolchainConfig(npmrcConfig)) {
28
+ return current;
29
+ }
30
+ }
31
+
32
+ current = parentOf(current);
33
+ }
34
+
35
+ return nearestPackageRoot;
36
+ }
37
+
38
+ function readToolchainConfig(startDirectory) {
39
+ const root = findProjectRoot(startDirectory);
40
+
41
+ if (!root) {
42
+ throw new Error('Could not find a package.json or .npmrc from ' + path.resolve(startDirectory || process.cwd()));
43
+ }
44
+
45
+ const npmrcPath = path.join(root, '.npmrc');
46
+ const npmrcConfig = fs.existsSync(npmrcPath)
47
+ ? parseNpmrcText(fs.readFileSync(npmrcPath, 'utf8'))
48
+ : {};
49
+
50
+ return {
51
+ root,
52
+ nodeVersion: npmrcConfig['tc-version-node'] || '',
53
+ pnpmVersion: npmrcConfig['tc-version-pnpm'] || '',
54
+ npmrcPath: fs.existsSync(npmrcPath) ? npmrcPath : null
55
+ };
56
+ }
57
+
58
+ module.exports = {
59
+ findProjectRoot,
60
+ readToolchainConfig
61
+ };
@@ -0,0 +1,81 @@
1
+ const DEFAULT_NODE_MIRROR = 'https://nodejs.org/dist';
2
+ const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
3
+
4
+ function normalizeNodeVersion(version) {
5
+ const clean = String(version || '').trim();
6
+ return clean.startsWith('v') ? clean.slice(1) : clean;
7
+ }
8
+
9
+ function mapNodeArch(arch) {
10
+ if (arch === 'x64' || arch === 'arm64') {
11
+ return arch;
12
+ }
13
+
14
+ throw new Error('Unsupported CPU architecture for Node.js downloads: ' + arch);
15
+ }
16
+
17
+ function getNodeDistribution(options) {
18
+ const version = normalizeNodeVersion(options.version);
19
+ const platform = options.platform || process.platform;
20
+ const arch = mapNodeArch(options.arch || process.arch);
21
+ const mirror = (options.mirror || process.env.TC_NODE_MIRROR || DEFAULT_NODE_MIRROR).replace(/\/+$/, '');
22
+
23
+ if (!version) {
24
+ throw new Error('Node.js version is required');
25
+ }
26
+
27
+ let platformPart;
28
+ let extension;
29
+
30
+ if (platform === 'win32') {
31
+ platformPart = 'win';
32
+ extension = 'zip';
33
+ } else if (platform === 'darwin') {
34
+ platformPart = 'darwin';
35
+ extension = 'tar.gz';
36
+ } else if (platform === 'linux') {
37
+ platformPart = 'linux';
38
+ extension = 'tar.xz';
39
+ } else {
40
+ throw new Error('Unsupported platform for Node.js downloads: ' + platform);
41
+ }
42
+
43
+ const folderName = 'node-v' + version + '-' + platformPart + '-' + arch;
44
+ const fileName = folderName + '.' + extension;
45
+
46
+ return {
47
+ version,
48
+ platform,
49
+ arch,
50
+ fileName,
51
+ folderName,
52
+ url: mirror + '/v' + version + '/' + fileName
53
+ };
54
+ }
55
+
56
+ function getNodeExecutableRelativePath(distribution) {
57
+ return distribution.platform === 'win32' ? 'node.exe' : 'bin/node';
58
+ }
59
+
60
+ function getPnpmDistribution(version, registry) {
61
+ const cleanVersion = String(version || '').trim();
62
+ const cleanRegistry = (registry || process.env.TC_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/+$/, '');
63
+
64
+ if (!cleanVersion) {
65
+ throw new Error('pnpm version is required');
66
+ }
67
+
68
+ return {
69
+ version: cleanVersion,
70
+ fileName: 'pnpm-' + cleanVersion + '.tgz',
71
+ folderName: 'package',
72
+ url: cleanRegistry + '/pnpm/-/pnpm-' + cleanVersion + '.tgz',
73
+ executableRelativePath: 'package/bin/pnpm.cjs'
74
+ };
75
+ }
76
+
77
+ module.exports = {
78
+ getNodeDistribution,
79
+ getNodeExecutableRelativePath,
80
+ getPnpmDistribution
81
+ };
@@ -0,0 +1,134 @@
1
+ const fs = require('fs');
2
+ const http = require('http');
3
+ const https = require('https');
4
+ const path = require('path');
5
+ const childProcess = require('child_process');
6
+ const { getNodeDistribution, getPnpmDistribution } = require('./distributions.cjs');
7
+ const { getNodePaths, getPnpmPaths } = require('./cache.cjs');
8
+
9
+ async function ensureNode(options) {
10
+ const opts = options || {};
11
+ const distribution = getNodeDistribution({
12
+ version: opts.version,
13
+ platform: opts.platform,
14
+ arch: opts.arch
15
+ });
16
+ const paths = getNodePaths(opts.cacheRoot, distribution);
17
+
18
+ if (fs.existsSync(paths.executablePath)) {
19
+ return {
20
+ distribution,
21
+ executablePath: paths.executablePath,
22
+ installDir: paths.installDir,
23
+ reused: true
24
+ };
25
+ }
26
+
27
+ const download = opts.downloadFile || downloadFile;
28
+ const extract = opts.extractArchive || extractArchive;
29
+ const archivePath = path.join(opts.cacheRoot, 'downloads', distribution.fileName);
30
+
31
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
32
+ fs.mkdirSync(path.dirname(paths.installDir), { recursive: true });
33
+
34
+ await download(distribution.url, archivePath);
35
+ await extract(archivePath, path.dirname(paths.installDir));
36
+
37
+ if (!fs.existsSync(paths.executablePath)) {
38
+ throw new Error('Node.js install did not produce expected executable: ' + paths.executablePath);
39
+ }
40
+
41
+ return {
42
+ distribution,
43
+ executablePath: paths.executablePath,
44
+ installDir: paths.installDir,
45
+ reused: false
46
+ };
47
+ }
48
+
49
+ async function ensurePnpm(options) {
50
+ const opts = options || {};
51
+ const distribution = getPnpmDistribution(opts.version);
52
+ const paths = getPnpmPaths(opts.cacheRoot, distribution.version);
53
+
54
+ if (fs.existsSync(paths.executablePath)) {
55
+ return {
56
+ distribution,
57
+ executablePath: paths.executablePath,
58
+ installDir: paths.installDir,
59
+ reused: true
60
+ };
61
+ }
62
+
63
+ const download = opts.downloadFile || downloadFile;
64
+ const extract = opts.extractArchive || extractArchive;
65
+ const archivePath = path.join(opts.cacheRoot, 'downloads', distribution.fileName);
66
+
67
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
68
+ fs.mkdirSync(paths.installDir, { recursive: true });
69
+
70
+ await download(distribution.url, archivePath);
71
+ await extract(archivePath, paths.installDir);
72
+
73
+ if (!fs.existsSync(paths.executablePath)) {
74
+ throw new Error('pnpm install did not produce expected CLI: ' + paths.executablePath);
75
+ }
76
+
77
+ return {
78
+ distribution,
79
+ executablePath: paths.executablePath,
80
+ installDir: paths.installDir,
81
+ reused: false
82
+ };
83
+ }
84
+
85
+ function downloadFile(url, destination) {
86
+ const client = url.startsWith('https:') ? https : http;
87
+
88
+ return new Promise((resolve, reject) => {
89
+ const request = client.get(url, (response) => {
90
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
91
+ response.resume();
92
+ downloadFile(response.headers.location, destination).then(resolve, reject);
93
+ return;
94
+ }
95
+
96
+ if (response.statusCode !== 200) {
97
+ response.resume();
98
+ reject(new Error('Download failed with HTTP ' + response.statusCode + ': ' + url));
99
+ return;
100
+ }
101
+
102
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
103
+ const stream = fs.createWriteStream(destination);
104
+ response.pipe(stream);
105
+ stream.on('finish', () => stream.close(resolve));
106
+ stream.on('error', reject);
107
+ });
108
+
109
+ request.on('error', reject);
110
+ });
111
+ }
112
+
113
+ function extractArchive(archivePath, destination) {
114
+ fs.mkdirSync(destination, { recursive: true });
115
+
116
+ const result = childProcess.spawnSync('tar', ['-xf', archivePath, '-C', destination], {
117
+ stdio: 'inherit'
118
+ });
119
+
120
+ if (result.error) {
121
+ throw result.error;
122
+ }
123
+
124
+ if (result.status !== 0) {
125
+ throw new Error('Failed to extract archive: ' + archivePath);
126
+ }
127
+ }
128
+
129
+ module.exports = {
130
+ ensureNode,
131
+ ensurePnpm,
132
+ downloadFile,
133
+ extractArchive
134
+ };
package/src/npmrc.cjs ADDED
@@ -0,0 +1,30 @@
1
+ function parseNpmrcText(text) {
2
+ const result = {};
3
+ const lines = String(text || '').split(/\r?\n/);
4
+
5
+ for (const rawLine of lines) {
6
+ const line = rawLine.trim();
7
+
8
+ if (!line || line.startsWith('#') || line.startsWith(';')) {
9
+ continue;
10
+ }
11
+
12
+ const separatorIndex = line.indexOf('=');
13
+ if (separatorIndex < 0) {
14
+ continue;
15
+ }
16
+
17
+ const key = line.slice(0, separatorIndex).trim();
18
+ const value = line.slice(separatorIndex + 1).trim();
19
+
20
+ if (key) {
21
+ result[key] = value;
22
+ }
23
+ }
24
+
25
+ return result;
26
+ }
27
+
28
+ module.exports = {
29
+ parseNpmrcText
30
+ };
package/src/shims.cjs ADDED
@@ -0,0 +1,61 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function ensurePnpmShim(options) {
5
+ const opts = options || {};
6
+ const platform = opts.platform || process.platform;
7
+ const arch = opts.arch || process.arch;
8
+ const dir = path.join(
9
+ opts.cacheRoot,
10
+ 'shims',
11
+ 'node-' + opts.nodeVersion,
12
+ 'pnpm-' + opts.pnpmVersion,
13
+ platform + '-' + arch
14
+ );
15
+
16
+ fs.mkdirSync(dir, { recursive: true });
17
+
18
+ if (platform === 'win32') {
19
+ const command = path.join(dir, 'pnpm.cmd');
20
+ const content = [
21
+ '@echo off',
22
+ '"' + opts.nodePath + '" "' + opts.pnpmCliPath + '" %*',
23
+ ''
24
+ ].join('\r\n');
25
+
26
+ fs.writeFileSync(command, content, 'utf8');
27
+ return { dir, command };
28
+ }
29
+
30
+ const command = path.join(dir, 'pnpm');
31
+ const content = [
32
+ '#!/bin/sh',
33
+ "exec " + shellQuote(opts.nodePath) + " " + shellQuote(opts.pnpmCliPath) + ' "$@"',
34
+ ''
35
+ ].join('\n');
36
+
37
+ fs.writeFileSync(command, content, { encoding: 'utf8', mode: 0o755 });
38
+ try {
39
+ fs.chmodSync(command, 0o755);
40
+ } catch (error) {
41
+ // Best effort on filesystems that do not support POSIX modes.
42
+ }
43
+
44
+ return { dir, command };
45
+ }
46
+
47
+ function shellQuote(value) {
48
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
49
+ }
50
+
51
+ function buildToolchainPath(options) {
52
+ const opts = options || {};
53
+ const delimiter = opts.delimiter || path.delimiter;
54
+ const segments = [opts.shimDir, opts.nodeBinDir, opts.currentPath].filter(Boolean);
55
+ return segments.join(delimiter);
56
+ }
57
+
58
+ module.exports = {
59
+ ensurePnpmShim,
60
+ buildToolchainPath
61
+ };