#!/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 Change working directory to --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} ファイルが存在する場合はtrue */ const fileExists = async (filePath: string): Promise => { 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();