Created
April 2, 2025 00:16
-
-
Save azu/9a6c957da8c5aef0426873df7e732eca to your computer and use it in GitHub Desktop.
migrate to pnpm from npm/yarn
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node --experimental-strip-types | |
// # npm/yarn を pnpm に移行するスクリプト | |
// ## 制限 | |
// Node.jsのコアパッケージのみを利用する | |
// - fsのglob | |
// https://nodejs.org/api/fs.html#fspromisesglobpattern-options | |
// - util.parseArgv | |
// https://nodejs.org/api/util.html#utilparseargsconfig | |
// ## 変更箇所 | |
// ### package.json | |
// - `corepack use pnpm`を実行して、pnpmを有効化する | |
// - `scripts`フィールドで`yarn`を`pnpm`コマンドに置換 | |
// ### lockfile | |
// - `pnpm import`を実行して、lockfileをpnpmに変換する | |
// https://pnpm.io/cli/import | |
// ### .github/workflows/*.{yml,yaml} | |
// - pnpm/action-setup@v4がなければ、actions/checkoutの後に追加 | |
// - `yarn` コマンドを`pnpm`に置換/`npm` コマンドを`pnpm`に置換 | |
// https://github.com/azu/ni.zsh#command-table を参考にして置換 | |
// - actions/setup-nodeのcacheの設定を pnpm に変更 | |
// https://github.com/actions/setup-node | |
// ### .githooks/pre-commit | |
// `#!/usr/bin/env node`となっている場合は | |
// ``` | |
// #!/bin/sh | |
// npx --no-install lint-staged | |
// ``` | |
// に置換 | |
const USAGE = ` | |
Usage: migrate-to-pnpm [options] | |
Options: | |
--cwd <path> Change working directory to <path> | |
--help Show this help message | |
`; | |
import { promises as fs } from "fs"; | |
import * as path from "path"; | |
import * as util from "util"; | |
import { exec as execCallback } from "child_process"; | |
// child_processのexecをPromise化 | |
const exec = util.promisify(execCallback); | |
// コマンドラインオプションの解析 | |
const parseArgs = () => { | |
try { | |
const options = util.parseArgs({ | |
options: { | |
cwd: { | |
type: "string", | |
}, | |
help: { | |
type: "boolean", | |
}, | |
}, | |
}); | |
if (options.values.help) { | |
console.log(USAGE); | |
process.exit(0); | |
} | |
return { | |
cwd: options.values.cwd ?? process.cwd(), | |
}; | |
} catch (error) { | |
console.error( | |
`エラー: ${error instanceof Error ? error.message : String(error)}` | |
); | |
console.log(USAGE); | |
process.exit(1); | |
} | |
}; | |
/** | |
* package.jsonを更新します | |
* @param {string} cwd - 作業ディレクトリ | |
*/ | |
const updatePackageJson = async (cwd: string) => { | |
try { | |
const packageJsonPath = path.join(cwd, "package.json"); | |
const packageJsonContent = await fs.readFile(packageJsonPath, "utf-8"); | |
const packageJson = JSON.parse(packageJsonContent); | |
// scriptsフィールドで `yarn` コマンドを `pnpm` に置換 | |
if (packageJson.scripts) { | |
for (const [key, value] of Object.entries(packageJson.scripts)) { | |
if (typeof value === "string") { | |
// yarn コマンドを pnpm に置換 | |
packageJson.scripts[key] = value.replace(/\byarn\b/g, "pnpm"); | |
} | |
} | |
} | |
// 更新したpackage.jsonを書き込み | |
await fs.writeFile( | |
packageJsonPath, | |
JSON.stringify(packageJson, null, 2) + "\n", | |
"utf-8" | |
); | |
console.log("✅ package.jsonを更新しました"); | |
} catch (error) { | |
console.error( | |
`❌ package.jsonの更新に失敗しました: ${ | |
error instanceof Error ? error.message : String(error) | |
}` | |
); | |
throw error; | |
} | |
}; | |
/** | |
* corerpackを使ってpnpmを有効化します | |
* @param {string} cwd - 作業ディレクトリ | |
*/ | |
const enablePnpmWithCorepack = async (cwd: string) => { | |
try { | |
console.log("🔧 corepack use pnpmを実行しています..."); | |
await exec("corepack use pnpm", { cwd }); | |
console.log("✅ pnpmを有効化しました"); | |
} catch (error) { | |
console.error( | |
`❌ pnpmの有効化に失敗しました: ${ | |
error instanceof Error ? error.message : String(error) | |
}` | |
); | |
throw error; | |
} | |
}; | |
/** | |
* lockfileをpnpmに変換します | |
* @param {string} cwd - 作業ディレクトリ | |
*/ | |
const convertLockfile = async (cwd: string) => { | |
try { | |
// yarn.lockかpackage-lock.jsonがあるか確認 | |
const hasYarnLock = await fileExists(path.join(cwd, "yarn.lock")); | |
const hasNpmLock = await fileExists(path.join(cwd, "package-lock.json")); | |
if (!hasYarnLock && !hasNpmLock) { | |
console.log( | |
"⚠️ yarn.lockもpackage-lock.jsonも見つかりませんでした。lockfileの変換をスキップします。" | |
); | |
return; | |
} | |
console.log("🔧 pnpm importを実行しています..."); | |
await exec("pnpm import", { cwd }); | |
console.log("✅ lockfileを変換しました"); | |
} catch (error) { | |
console.error( | |
`❌ lockfileの変換に失敗しました: ${ | |
error instanceof Error ? error.message : String(error) | |
}` | |
); | |
throw error; | |
} | |
}; | |
/** | |
* ファイルが存在するか確認します | |
* @param {string} filePath - ファイルパス | |
* @returns {Promise<boolean>} ファイルが存在する場合はtrue | |
*/ | |
const fileExists = async (filePath: string): Promise<boolean> => { | |
try { | |
await fs.access(filePath); | |
return true; | |
} catch { | |
return false; | |
} | |
}; | |
/** | |
* GitHub Actionsのワークフローファイルを更新します | |
* @param {string} cwd - 作業ディレクトリ | |
*/ | |
const updateGitHubWorkflows = async (cwd: string) => { | |
try { | |
const workflowsDir = path.join(cwd, ".github", "workflows"); | |
// .github/workflowsディレクトリが存在するか確認 | |
if (!(await fileExists(workflowsDir))) { | |
console.log( | |
"⚠️ .github/workflowsディレクトリが見つかりませんでした。GitHub Actionsの更新をスキップします。" | |
); | |
return; | |
} | |
// .github/workflows/*.{yml,yaml}ファイルを取得 | |
// absoluteオプションがないため、相対パスで取得して後から絶対パスに変換 | |
const relativeWorkflowFiles = await Array.fromAsync( | |
fs.glob("**/*.{yml,yaml}", { | |
cwd: workflowsDir, | |
}) | |
); | |
if (relativeWorkflowFiles.length === 0) { | |
console.log( | |
"⚠️ ワークフローファイルが見つかりませんでした。GitHub Actionsの更新をスキップします。" | |
); | |
return; | |
} | |
// 相対パスを絶対パスに変換 | |
const workflowFiles = relativeWorkflowFiles.map((relPath) => | |
path.join(workflowsDir, relPath) | |
); | |
for (const workflowFile of workflowFiles) { | |
await updateWorkflowFile(workflowFile); | |
} | |
console.log( | |
`✅ ${workflowFiles.length}個のGitHub Actionsワークフローファイルを更新しました` | |
); | |
} catch (error) { | |
console.error( | |
`❌ GitHub Actionsワークフローの更新に失敗しました: ${ | |
error instanceof Error ? error.message : String(error) | |
}` | |
); | |
throw error; | |
} | |
}; | |
/** | |
* ワークフローファイルを更新します | |
* @param {string} filePath - ワークフローファイルのパス | |
*/ | |
const updateWorkflowFile = async (filePath: string) => { | |
try { | |
let content = await fs.readFile(filePath, "utf-8"); | |
// yarn/npmコマンドをpnpmに置換 | |
// https://github.com/azu/ni.zsh#command-table を参考に置換 | |
const replacements: [RegExp, string][] = [ | |
[/\byarn\b/g, "pnpm"], | |
[/\byarn add\b/g, "pnpm add"], | |
[/\byarn add -D\b/g, "pnpm add -D"], | |
[/\byarn install\b/g, "pnpm install"], | |
[/\byarn remove\b/g, "pnpm remove"], | |
[/\byarn run\b/g, "pnpm run"], | |
[/\bnpm\b/g, "pnpm"], | |
[/\bnpm i\b/g, "pnpm install"], | |
[/\bnpm install\b/g, "pnpm install"], | |
[/\bnpm uninstall\b/g, "pnpm remove"], | |
[/\bnpm run\b/g, "pnpm run"], | |
]; | |
for (const [pattern, replacement] of replacements) { | |
content = content.replace(pattern, replacement); | |
} | |
// actions/setup-nodeのcacheの設定を変更 | |
const nodeSetupRegex = /uses:\s+actions\/setup-node@/; | |
if (nodeSetupRegex.test(content)) { | |
// すでにcacheがある場合は置換する | |
if (/cache:\s+['"](?:npm|yarn)['"]/.test(content)) { | |
content = content.replace( | |
/cache:\s+['"](?:npm|yarn)['"]/, | |
"cache: 'pnpm'" | |
); | |
} | |
} | |
// pnpm/action-setup@v4を追加(ない場合のみ) | |
if (!content.includes("uses: pnpm/action-setup@")) { | |
// actions/checkoutを探す | |
const checkoutPattern = /(uses:\s+actions\/checkout@[^\s]+)/; | |
if (checkoutPattern.test(content)) { | |
content = content.replace( | |
checkoutPattern, | |
"$1\n\n - name: Install pnpm\n uses: pnpm/action-setup@v4" | |
); | |
} | |
} | |
// 変更を保存 | |
await fs.writeFile(filePath, content, "utf-8"); | |
console.log(` 📝 ${path.basename(filePath)}を更新しました`); | |
} catch (error) { | |
console.error( | |
` ❌ ${path.basename(filePath)}の更新に失敗しました: ${ | |
error instanceof Error ? error.message : String(error) | |
}` | |
); | |
throw error; | |
} | |
}; | |
/** | |
* .githooks/pre-commitファイルを更新します | |
* @param {string} cwd - 作業ディレクトリ | |
*/ | |
const updateGitHooks = async (cwd: string) => { | |
try { | |
const preCommitPath = path.join(cwd, ".githooks", "pre-commit"); | |
// .githooks/pre-commitファイルが存在するか確認 | |
if (!await fileExists(preCommitPath)) { | |
console.log("⚠️ .githooks/pre-commitファイルが見つかりませんでした。Githooksの更新をスキップします。"); | |
return; | |
} | |
// ファイルの内容を読み込み | |
const content = await fs.readFile(preCommitPath, "utf-8"); | |
// #!/usr/bin/env nodeで始まるかチェック | |
if (content.trimStart().startsWith("#!/usr/bin/env node")) { | |
console.log("🔧 .githooks/pre-commitを更新しています..."); | |
// 置換後の内容 | |
const newContent = `#!/bin/sh | |
npx --no-install lint-staged | |
`; | |
// ファイルを書き込み | |
await fs.writeFile(preCommitPath, newContent, "utf-8"); | |
// 実行権限を付与 | |
await fs.chmod(preCommitPath, 0o755); | |
console.log("✅ .githooks/pre-commitを更新しました"); | |
} else { | |
console.log("⚠️ .githooks/pre-commitは既に更新されているか、形式が異なります。スキップします。"); | |
} | |
} catch (error) { | |
console.error(`❌ .githooks/pre-commitの更新に失敗しました: ${error instanceof Error ? error.message : String(error)}`); | |
throw error; | |
} | |
}; | |
/** | |
* メイン処理 | |
*/ | |
const main = async () => { | |
try { | |
const { cwd } = parseArgs(); | |
console.log(`🚀 '${cwd}'でpnpm移行プロセスを開始します`); | |
// 各タスクを順番に実行 | |
await enablePnpmWithCorepack(cwd); | |
await updatePackageJson(cwd); | |
await convertLockfile(cwd); | |
await updateGitHubWorkflows(cwd); | |
await updateGitHooks(cwd); | |
console.log("🎉 pnpmへの移行が完了しました!"); | |
} catch (error) { | |
console.error(`❌ pnpmへの移行に失敗しました: ${error instanceof Error ? error.message : String(error)}`); | |
process.exit(1); | |
} | |
}; | |
// スクリプトの実行 | |
main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment