@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 +144 -0
- package/bin/tc.cjs +9 -0
- package/package.json +33 -0
- package/skills/migrate-to-fexd-toolchain/SKILL.md +144 -0
- package/src/cache.cjs +57 -0
- package/src/cli.cjs +170 -0
- package/src/config.cjs +61 -0
- package/src/distributions.cjs +81 -0
- package/src/install.cjs +134 -0
- package/src/npmrc.cjs +30 -0
- package/src/shims.cjs +61 -0
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
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
|
+
};
|
package/src/install.cjs
ADDED
|
@@ -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
|
+
};
|