Created
October 21, 2025 09:00
-
-
Save erukiti/f6caafc6224359a634326c890a34fdc5 to your computer and use it in GitHub Desktop.
Macのように、人間はzshを使って、Codex/Claude Codeはbashを使うみたいな環境で、実行するものの差分が生じるするのを検出するスクリプト。bun shell-drift-check.ts みたいに実行して
This file contains hidden or 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
| /** | |
| * Shell Drift Checker | |
| * | |
| * Compare environment differences between `bash -lc` (Codex default) and `zsh -lc` (typical human shell). | |
| * | |
| * Usage: | |
| * bun shell-drift-check.ts [--interactive] [--json] [--cmds node,bun,npm,yarn,pnpm,deno,python3,go,jq,rg] | |
| * | |
| * Notes: | |
| * - By default, prints a concise human-readable report without dumping all env values to avoid leaking secrets. | |
| * - Focuses on PATH order and command resolution for common toolchain binaries. | |
| * - Use `--json` to get a machine-readable summary (no secret values included). | |
| */ | |
| type Shell = 'bash' | 'zsh'; | |
| const DEFAULT_CMDS = [ | |
| 'node', | |
| 'npm', | |
| 'bun', | |
| 'yarn', | |
| 'pnpm', | |
| 'deno', | |
| 'python3', | |
| 'go', | |
| 'jq', | |
| 'rg', | |
| 'awk', | |
| 'sed', | |
| 'grep', | |
| ]; | |
| const argv = new Set(process.argv.slice(2)); | |
| const interactive = argv.has('--interactive'); | |
| const jsonOut = argv.has('--json'); | |
| const cmdsArg = [...argv].find((a) => a.startsWith('--cmds=')); | |
| const CMDS = cmdsArg | |
| ? cmdsArg.replace(/^--cmds=/, '') | |
| .split(',') | |
| .map((s) => s.trim()) | |
| .filter(Boolean) | |
| : DEFAULT_CMDS; | |
| function sh(shell: Shell, code: string): Promise<{ stdout: string; stderr: string; exitCode: number }>{ | |
| const flags = interactive ? ['-lic'] : ['-lc']; | |
| const p = Bun.spawn([shell, ...flags, code], { stdio: ['ignore', 'pipe', 'pipe'] }); | |
| return Promise.all([p.stdout?.text() ?? Promise.resolve(''), p.stderr?.text() ?? Promise.resolve('')]).then( | |
| ([stdout, stderr]) => ({ stdout, stderr, exitCode: p.exitCode }) | |
| ); | |
| } | |
| async function getPathList(shell: Shell): Promise<string[]> { | |
| const { stdout } = await sh(shell, "printf '%s' \"$PATH\" | tr ':' '\n'"); | |
| return stdout.split('\n').filter(Boolean); | |
| } | |
| async function getAliases(shell: Shell): Promise<Record<string, string>> { | |
| const { stdout } = await sh(shell, 'alias'); | |
| const out: Record<string, string> = {}; | |
| for (const line of stdout.split('\n')) { | |
| // Expected: alias ll='ls -l' | |
| const m = line.match(/^alias\s+([^=]+)=(.*)$/); | |
| if (!m) continue; | |
| const name = m[1].trim(); | |
| let rhs = m[2].trim(); | |
| // strip one layer of quotes if present | |
| if ((rhs.startsWith("'") && rhs.endsWith("'")) || (rhs.startsWith('"') && rhs.endsWith('"'))) { | |
| rhs = rhs.slice(1, -1); | |
| } | |
| out[name] = rhs; | |
| } | |
| return out; | |
| } | |
| async function which(shell: Shell, cmd: string): Promise<string | null> { | |
| const { stdout, exitCode } = await sh(shell, `command -v ${cmd} 2>/dev/null || true`); | |
| const p = stdout.trim(); | |
| return exitCode === 0 && p ? p : p || null; | |
| } | |
| async function version(shell: Shell, cmd: string): Promise<string | null> { | |
| // Try common version flags conservatively | |
| const attempts = [ | |
| `${cmd} --version`, | |
| `${cmd} -V`, | |
| `${cmd} -v`, | |
| ]; | |
| for (const a of attempts) { | |
| const { stdout, stderr, exitCode } = await sh(shell, `${a} 2>/dev/null || true`); | |
| const out = (stdout || stderr).trim(); | |
| if (exitCode === 0 && out) return out.split('\n')[0]; | |
| if (out) return out.split('\n')[0]; | |
| } | |
| return null; | |
| } | |
| function diffOrderedPath(a: string[], b: string[]) { | |
| // Returns order-aware differences | |
| const max = Math.max(a.length, b.length); | |
| const rows: { idx: number; bash?: string; zsh?: string; same: boolean }[] = []; | |
| for (let i = 0; i < max; i++) { | |
| const ra = a[i]; | |
| const rb = b[i]; | |
| rows.push({ idx: i, bash: ra, zsh: rb, same: ra === rb }); | |
| } | |
| const firstMismatch = rows.find((r) => !r.same); | |
| // Set differences (regardless of order) | |
| const setA = new Set(a); | |
| const setB = new Set(b); | |
| const onlyA = a.filter((p) => !setB.has(p)); | |
| const onlyB = b.filter((p) => !setA.has(p)); | |
| return { rows, firstMismatchIndex: firstMismatch?.idx ?? -1, onlyA, onlyB }; | |
| } | |
| async function collect(shell: Shell) { | |
| const [pathList, aliases, whichMap, versionMap] = await Promise.all([ | |
| getPathList(shell), | |
| getAliases(shell), | |
| (async () => { | |
| const map: Record<string, string | null> = {}; | |
| await Promise.all( | |
| CMDS.map(async (c) => { | |
| map[c] = await which(shell, c); | |
| }) | |
| ); | |
| return map; | |
| })(), | |
| (async () => { | |
| const map: Record<string, string | null> = {}; | |
| await Promise.all( | |
| CMDS.map(async (c) => { | |
| // Version call only if command exists | |
| const w = await which(shell, c); | |
| map[c] = w ? await version(shell, c) : null; | |
| }) | |
| ); | |
| return map; | |
| })(), | |
| ]); | |
| return { shell, pathList, aliases, which: whichMap, versions: versionMap }; | |
| } | |
| function formatReport(bashInfo: Awaited<ReturnType<typeof collect>>, zshInfo: Awaited<ReturnType<typeof collect>>) { | |
| const pathDiff = diffOrderedPath(bashInfo.pathList, zshInfo.pathList); | |
| const driftFlags: string[] = []; | |
| if (pathDiff.firstMismatchIndex !== -1 || pathDiff.onlyA.length || pathDiff.onlyB.length) { | |
| driftFlags.push('PATH'); | |
| } | |
| const cmdRows: { name: string; bashPath: string | null; zshPath: string | null; bashVer: string | null; zshVer: string | null; same: boolean }[] = []; | |
| for (const c of CMDS) { | |
| const bashPath = bashInfo.which[c] ?? null; | |
| const zshPath = zshInfo.which[c] ?? null; | |
| const bashVer = bashInfo.versions[c] ?? null; | |
| const zshVer = zshInfo.versions[c] ?? null; | |
| const same = Boolean(bashPath && zshPath && bashPath === zshPath && bashVer && zshVer && bashVer === zshVer); | |
| if (!same) driftFlags.push(`cmd:${c}`); | |
| cmdRows.push({ name: c, bashPath, zshPath, bashVer, zshVer, same }); | |
| } | |
| const aliasNamesB = new Set(Object.keys(bashInfo.aliases)); | |
| const aliasNamesZ = new Set(Object.keys(zshInfo.aliases)); | |
| const aliasOnlyB = [...aliasNamesB].filter((a) => !aliasNamesZ.has(a)); | |
| const aliasOnlyZ = [...aliasNamesZ].filter((a) => !aliasNamesB.has(a)); | |
| if (aliasOnlyB.length || aliasOnlyZ.length) driftFlags.push('alias'); | |
| const summary = { | |
| driftDetected: driftFlags.length > 0, | |
| driftAreas: [...new Set(driftFlags)], | |
| firstPathMismatchIndex: pathDiff.firstMismatchIndex, | |
| }; | |
| if (jsonOut) { | |
| const json = { | |
| summary, | |
| bash: { | |
| path: bashInfo.pathList, | |
| aliases: Object.keys(bashInfo.aliases), | |
| which: bashInfo.which, | |
| versions: bashInfo.versions, | |
| }, | |
| zsh: { | |
| path: zshInfo.pathList, | |
| aliases: Object.keys(zshInfo.aliases), | |
| which: zshInfo.which, | |
| versions: zshInfo.versions, | |
| }, | |
| pathDiff: { | |
| onlyInBash: pathDiff.onlyA, | |
| onlyInZsh: pathDiff.onlyB, | |
| firstMismatchIndex: pathDiff.firstMismatchIndex, | |
| }, | |
| commands: cmdRows, | |
| }; | |
| console.log(JSON.stringify(json, null, 2)); | |
| return summary.driftDetected ? 1 : 0; | |
| } | |
| // Human-readable report | |
| const lines: string[] = []; | |
| lines.push('=== Shell Drift Report ==='); | |
| lines.push(`Mode: ${interactive ? 'login+interactive' : 'login'} shells (bash -l${interactive ? 'i' : ''}c / zsh -l${interactive ? 'i' : ''}c)`); | |
| lines.push(`Drift: ${summary.driftDetected ? 'DETECTED' : 'none'}`); | |
| lines.push(''); | |
| lines.push('- PATH order & membership:'); | |
| if (pathDiff.firstMismatchIndex === -1 && pathDiff.onlyA.length === 0 && pathDiff.onlyB.length === 0) { | |
| lines.push(' identical'); | |
| } else { | |
| if (pathDiff.firstMismatchIndex !== -1) { | |
| lines.push(` first mismatch index: ${pathDiff.firstMismatchIndex}`); | |
| } | |
| if (pathDiff.onlyA.length) { | |
| lines.push(' only in bash:'); | |
| for (const p of pathDiff.onlyA) lines.push(` ${p}`); | |
| } | |
| if (pathDiff.onlyB.length) { | |
| lines.push(' only in zsh:'); | |
| for (const p of pathDiff.onlyB) lines.push(` ${p}`); | |
| } | |
| } | |
| lines.push(''); | |
| lines.push('- Command resolution & versions:'); | |
| for (const r of cmdRows) { | |
| const icon = r.same ? '✓' : '✗'; | |
| lines.push(` ${icon} ${r.name}`); | |
| lines.push(` bash: ${r.bashPath ?? '-'} ${r.bashVer ? `(${r.bashVer})` : ''}`); | |
| lines.push(` zsh : ${r.zshPath ?? '-'} ${r.zshVer ? `(${r.zshVer})` : ''}`); | |
| } | |
| lines.push(''); | |
| lines.push('- Alias names (names only, values omitted):'); | |
| if (!aliasOnlyB.length && !aliasOnlyZ.length) { | |
| lines.push(' identical set of alias names'); | |
| } else { | |
| if (aliasOnlyB.length) { | |
| lines.push(' only in bash: ' + aliasOnlyB.join(', ')); | |
| } | |
| if (aliasOnlyZ.length) { | |
| lines.push(' only in zsh : ' + aliasOnlyZ.join(', ')); | |
| } | |
| } | |
| console.log(lines.join('\n')); | |
| return summary.driftDetected ? 1 : 0; | |
| } | |
| async function main() { | |
| const [bashInfo, zshInfo] = await Promise.all([collect('bash'), collect('zsh')]); | |
| const code = formatReport(bashInfo, zshInfo); | |
| process.exit(code); | |
| } | |
| main().catch((err) => { | |
| console.error('[shell-drift-check] fatal:', err); | |
| process.exit(2); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment