From bdfed7b367b0e400d447c0e8bf8a4b5d27637834 Mon Sep 17 00:00:00 2001 From: bash0816 Date: Sat, 31 Oct 2026 11:20:25 +0900 Subject: [PATCH] feat: install missing prerequisites (glibc, tar) after confirmation and handle mixed cases Offer a consented two-stage install (glibc-repo, then glibc) when the glibc loader is missing, check that tar can run, and repair the auto-installable items even when manual-only problems are mixed in. After a repair, the remaining problems are explained in Japanese and English. Co-Authored-By: Claude Haiku 5.5 --- README.md | 4 + lib/preflight.js | 163 +++++++++++++--- test/preflight.test.js | 431 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 536 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 0c798a6..83e3ec1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ pkg install llvm readelf は llvm パッケージが提供します / readelf is provided by the llvm package +On first run (when setup runs), `opencode-termux` checks for missing prerequisites: `glibc`, `patchelf`, `libc++`, `llvm` (for readelf), and `tar`. If any are missing, it shows the commands it will run and asks for confirmation (type `y`) before installing them with `pkg install`. If you answer `n`, or if the terminal is non-interactive, it installs nothing, shows the commands to run manually, and exits. If `glibc` is missing, `glibc-repo` (which adds the repository configuration) is installed first. Changes to the repository configuration are persistent, and there is no guarantee they can be reverted. If you need to remove them, check the effects on dependencies first, then remove them manually. It does not run a full upgrade (`pkg update && pkg upgrade`) automatically. For problems it cannot fix automatically (for example, package link errors or a loader that cannot run), it shows the commands to run manually and exits. + +初回セットアップの実行時、`opencode-termux` は不足している前提パッケージ(`glibc`、`patchelf`、`libc++`、`llvm`(readelf 用)、`tar`)を検査します。不足があれば、実行するコマンドを表示し、確認(`y` を入力)のうえで `pkg install` により導入します。`n` と答えた場合、または対話できない(非対話の)端末の場合は、何も導入せず、手動で実行するコマンドを案内して終了します。`glibc` が無い場合は、先に `glibc-repo`(リポジトリ設定の追加)を導入します。リポジトリ設定の変更は永続的で、元に戻す保証はありません。削除が必要な場合は、依存関係などへの影響を確認したうえで、手動で行ってください。全体更新(`pkg update && pkg upgrade`)は自動では実行しません。パッケージのリンクエラーや実行できないローダーなど、自動では直せない問題の場合は、手動で実行するコマンドを案内して終了します。 + ### If setup fails with `cannot locate symbol "_ZNSt6__ndk1..."` / セットアップが `cannot locate symbol` で失敗する場合 On first run, `opencode` may stop with an error like this: diff --git a/lib/preflight.js b/lib/preflight.js index 693d3d2..760fb98 100644 --- a/lib/preflight.js +++ b/lib/preflight.js @@ -13,13 +13,14 @@ const PM_SCRIPT = TERMUX_USR + '/bin/termux-setup-package-manager'; const GLIBC_LD_PATH = TERMUX_USR + '/glibc/lib/ld-linux-aarch64.so.1'; // 自動修復の固定順序 -const PKG_ORDER = ['patchelf', 'libc++', 'llvm']; +const PKG_ORDER = ['glibc', 'patchelf', 'libc++', 'llvm', 'tar']; // 問題コードごとの固定値 const PROBLEMS = { 'glibc-missing': { - auto: false, - pkgs: [], + auto: true, + pkgs: ['glibc'], + repoPkgs: ['glibc-repo'], ja: 'glibc のローダーが見つかりません', en: 'glibc loader not found', commands: ['pkg install glibc-repo && pkg install glibc'], @@ -27,6 +28,7 @@ const PROBLEMS = { 'glibc-broken': { auto: false, pkgs: [], + repoPkgs: [], ja: 'glibc のローダーを実行できません', en: 'glibc loader cannot be executed', commands: ['pkg install glibc-repo && pkg install glibc'], @@ -34,6 +36,7 @@ const PROBLEMS = { 'patchelf-missing': { auto: true, pkgs: ['patchelf', 'libc++'], + repoPkgs: [], ja: 'patchelf が見つかりません', en: 'patchelf not found', commands: ['pkg install patchelf libc++'], @@ -41,6 +44,7 @@ const PROBLEMS = { 'patchelf-libcxx-mismatch': { auto: true, pkgs: ['libc++'], + repoPkgs: [], ja: 'patchelf が libc++ の不一致で起動できません(libc++ が古い可能性があります)', en: 'patchelf cannot start due to a libc++ mismatch (the installed libc++ may be too old)', commands: ['pkg install libc++'], @@ -48,6 +52,7 @@ const PROBLEMS = { 'patchelf-link-error': { auto: false, pkgs: [], + repoPkgs: [], ja: 'patchelf のリンクエラーです。『pkg update && pkg upgrade』は全パッケージを更新します', en: "patchelf failed to link. 'pkg update && pkg upgrade' updates ALL installed packages", commands: ['pkg update && pkg upgrade'], @@ -55,6 +60,7 @@ const PROBLEMS = { 'patchelf-failed': { auto: false, pkgs: [], + repoPkgs: [], ja: 'patchelf を実行できません', en: 'patchelf cannot be executed', commands: ['pkg install patchelf libc++'], @@ -62,6 +68,7 @@ const PROBLEMS = { 'readelf-missing': { auto: true, pkgs: ['llvm'], + repoPkgs: [], ja: 'readelf が見つかりません', en: 'readelf not found', commands: ['pkg install llvm'], @@ -69,10 +76,27 @@ const PROBLEMS = { 'readelf-failed': { auto: false, pkgs: [], + repoPkgs: [], ja: 'readelf を実行できません', en: 'readelf cannot be executed', commands: ['pkg install llvm'], }, + 'tar-missing': { + auto: true, + pkgs: ['tar'], + repoPkgs: [], + ja: 'tar が見つかりません', + en: 'tar not found', + commands: ['pkg install tar'], + }, + 'tar-failed': { + auto: false, + pkgs: [], + repoPkgs: [], + ja: 'tar を実行できません', + en: 'tar cannot be executed', + commands: ['pkg install tar'], + }, }; // 原文の先頭を最大 2000 文字に切る @@ -88,6 +112,7 @@ function makeProblem(code, detail = '') { code, auto: p.auto, pkgs: [...p.pkgs], + repoPkgs: [...p.repoPkgs], ja: p.ja, en: p.en, commands: [...p.commands], @@ -133,16 +158,26 @@ class PrereqError extends Error { } } -// 自動修復の許可リストを作る。手動のみが1件でもあれば null +// 自動修復の許可リストを作る。自動対象が無ければ null。手動案内は manual に分けて返す function buildAutoFix(problems) { if (!Array.isArray(problems) || problems.length === 0) return null; - if (problems.some((p) => p.auto === false)) return null; - const set = new Set(); - for (const p of problems) { - for (const pkg of p.pkgs) set.add(pkg); + const auto = problems.filter((p) => p.auto === true); + if (auto.length === 0) return null; + const pkgSet = new Set(); + const repoSet = new Set(); + for (const p of auto) { + for (const pkg of p.pkgs) pkgSet.add(pkg); + for (const repo of p.repoPkgs) repoSet.add(repo); } - const pkgs = PKG_ORDER.filter((pkg) => set.has(pkg)); - return { pkgs, args: ['install', ...pkgs] }; + const pkgs = PKG_ORDER.filter((pkg) => pkgSet.has(pkg)); + const repoPkgs = [...repoSet]; + const steps = + repoPkgs.length > 0 + ? [{ args: ['install', ...repoPkgs] }, { args: ['install', ...pkgs] }] + : [{ args: ['install', ...pkgs] }]; + const args = [...steps[steps.length - 1].args]; + const manual = problems.filter((p) => p.auto === false); + return { steps, pkgs, args, manual }; } // 例外の原文(message と stderr)を1つの文字列にする @@ -205,6 +240,14 @@ function checkPrerequisites(io = {}) { problems.push(makeProblem(code, processText(e))); } + // tar: 見つからない場合と実行失敗を分ける + try { + spawn('tar', ['--version'], opts); + } catch (e) { + const code = e && e.code === 'ENOENT' ? 'tar-missing' : 'tar-failed'; + problems.push(makeProblem(code, processText(e))); + } + return problems; } @@ -232,6 +275,33 @@ function pkgFailedLead(state) { ); } +// 段の失敗見出し(index は 0 始まりの段番号。state は終了状態の文字列) +function stageFailedLead(index, command, state) { + if (index === 0) { + return ( + '1 段目 ' + command + ' が終了状態 ' + state + ' で失敗しました。変更がなかった保証はありません。以降の段は実行していません。表示済みの出力を参照してください / ' + + 'Stage 1 (' + command + ') failed (' + state + '). It is not guaranteed that nothing changed. Later stages were not run. See the output shown above. Manual commands:' + ); + } + return ( + '1 段目は成功し、リポジトリ設定などが変更されている可能性があります。' + (index + 1) + ' 段目 ' + command + ' が終了状態 ' + state + ' で失敗しました。表示済みの出力を参照してください / ' + + 'Earlier stages succeeded and repository settings may have been changed. Stage ' + (index + 1) + ' (' + command + ') failed (' + state + '). See the output shown above. Manual commands:' + ); +} + +// 実行した段の一覧と、glibc のローダーが残っている場合の診断(自動では再実行しない) +function glibcRemainingNote(steps) { + const lines = ['実行した段と終了状態 / Stages run and exit status:']; + steps.forEach((step, i) => { + lines.push(' ' + (i + 1) + '. ' + TERMUX_PKG + ' ' + step.args.join(' ') + ' → status: 0'); + }); + lines.push( + 'glibc のローダーが残っています。glibc パッケージは入っていてもローダーだけ欠けている場合があります。状態を確認し、必要なら pkg reinstall glibc を検討してください(自動では再実行しません) / ' + + 'The glibc loader is still missing. The glibc package may be installed while only the loader is absent. Check its state and consider pkg reinstall glibc if needed (it is not run automatically).' + ); + return lines.join('\n') + '\n'; +} + // 手順 1: 全問題の説明と手動 commands を表示する文面 function describeProblems(problems) { const lines = ['[opencode-termux] 前提条件の問題 / Prerequisite problems']; @@ -242,11 +312,12 @@ function describeProblems(problems) { return lines.join('\n') + '\n'; } -// 最終の手動案内: 問題の説明は入れず、問題コードとコマンドだけを並べる +// 最終の手動案内: 問題コードと説明、その手動 commands を並べる function manualText(lead, problems) { const lines = [lead]; for (const p of problems) { - for (const cmd of p.commands) lines.push(' - [' + p.code + '] ' + cmd); + lines.push(' - [' + p.code + '] ' + p.ja + ' / ' + p.en); + for (const cmd of p.commands) lines.push(' 手動 / Manual: ' + cmd); } return lines.join('\n') + '\n'; } @@ -283,12 +354,39 @@ const CONSENT_NOTES = [ ], ]; +// リポジトリ追加(glibc-repo)を含む場合の注意書き(ja, en の組) +const REPO_NOTES = [ + [ + 'glibc-repo の導入はリポジトリ設定を永続的に変更します。既に導入済みの場合は新規追加とは限りません。', + 'Installing glibc-repo permanently changes the repository settings. If it is already installed, this may not be a new addition.', + ], + [ + '元の状態に完全に戻せる保証はありません。削除が必要な場合は、依存関係などの影響を確認したうえで手動で行ってください。', + 'There is no guarantee that the original state can be fully restored. If removal is needed, check the impact on dependencies first and do it manually.', + ], +]; + // 同意画面(手順 5) function consentText(problems, fix) { const lines = ['[opencode-termux] 次の前提条件を自動で修復します / Automatic repair of prerequisites']; - for (const p of problems) lines.push('- ' + p.ja + ' / ' + p.en); - lines.push('実行するコマンド / Command: ' + TERMUX_PKG + ' ' + fix.args.join(' ')); + for (const p of problems) if (p.auto === true) lines.push('- ' + p.ja + ' / ' + p.en); + if (fix.manual.length > 0) { + lines.push('手動で対応が必要な項目(自動では実行しません) / Items that need manual action (not run automatically):'); + for (const p of fix.manual) { + lines.push('- ' + p.ja + ' / ' + p.en); + for (const cmd of p.commands) lines.push(' 手動 / Manual: ' + cmd); + } + } + if (fix.steps.length === 1) { + lines.push('実行するコマンド / Command: ' + TERMUX_PKG + ' ' + fix.steps[0].args.join(' ')); + } else { + lines.push('実行するコマンド(上から順に実行します) / Commands (run in this order):'); + fix.steps.forEach((step, i) => lines.push(' ' + (i + 1) + '. ' + TERMUX_PKG + ' ' + step.args.join(' '))); + } for (const [ja, en] of CONSENT_NOTES) lines.push('注意 / Note: ' + ja + ' / ' + en); + if (fix.steps.length >= 2) { + for (const [ja, en] of REPO_NOTES) lines.push('注意 / Note: ' + ja + ' / ' + en); + } return lines.join('\n') + '\n'; } @@ -411,23 +509,34 @@ async function resolvePrerequisites(problems, io = {}) { throw manualError(1, LEAD.noInput, problems); } - // 7. 固定パスの pkg を実行する(PATH の内容に依らない) - const r = runSync(TERMUX_PKG, fix.args, { - stdio: 'inherit', - env: { ...env, TERMUX_PKG_NO_MIRROR_SELECT: '1' }, - }); - if (r.error || r.signal || r.status !== 0) { - let state; - if (r.error) state = 'error: ' + (r.error.message || String(r.error)); - else if (r.signal) state = 'signal: ' + r.signal; - else state = 'status: ' + r.status; - throw manualError(1, pkgFailedLead(state), problems); + // 7. 固定パスの pkg を段ごとに先頭から実行する(PATH の内容に依らない)。段が失敗したら後続の段は実行しない + for (let i = 0; i < fix.steps.length; i++) { + const step = fix.steps[i]; + const r = runSync(TERMUX_PKG, step.args, { + stdio: 'inherit', + env: { ...env, TERMUX_PKG_NO_MIRROR_SELECT: '1' }, + }); + if (r.error || r.signal || r.status !== 0) { + let state; + if (r.error) state = 'error: ' + (r.error.message || String(r.error)); + else if (r.signal) state = 'signal: ' + r.signal; + else state = 'status: ' + r.status; + const lead = + fix.steps.length === 1 + ? pkgFailedLead(state) + : stageFailedLead(i, TERMUX_PKG + ' ' + step.args.join(' '), state); + throw manualError(1, lead, problems); + } } - // 8. 再検査は1回だけ。残れば再実行せず手動案内で終わる + // 8. 再検査は全段の後に1回だけ。残れば再実行せず手動案内で終わる const remaining = check(io); if (remaining.length === 0) return; - throw manualError(1, LEAD.remaining, remaining); + let lead = LEAD.remaining; + if (remaining.some((p) => p.code === 'glibc-missing')) { + lead = LEAD.remaining + '\n' + glibcRemainingNote(fix.steps).replace(/\n$/, ''); + } + throw manualError(1, lead, remaining); } // 前提を調べ、問題があれば解決を試みる diff --git a/test/preflight.test.js b/test/preflight.test.js index b3d1c87..e10bb81 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -32,7 +32,7 @@ test('GLIBC_LD_PATH matches the value used by patcher', () => { }); // PROBLEMS の固定値 -test('PROBLEMS has exactly the 8 problem codes', () => { +test('PROBLEMS has exactly the 10 problem codes', () => { assert.deepEqual(Object.keys(PROBLEMS).sort(), [ 'glibc-broken', 'glibc-missing', @@ -42,25 +42,30 @@ test('PROBLEMS has exactly the 8 problem codes', () => { 'patchelf-missing', 'readelf-failed', 'readelf-missing', + 'tar-failed', + 'tar-missing', ]); }); test('PROBLEMS fixed values match the table', () => { const expected = { - 'glibc-missing': { auto: false, pkgs: [], commands: ['pkg install glibc-repo && pkg install glibc'] }, - 'glibc-broken': { auto: false, pkgs: [], commands: ['pkg install glibc-repo && pkg install glibc'] }, - 'patchelf-missing': { auto: true, pkgs: ['patchelf', 'libc++'], commands: ['pkg install patchelf libc++'] }, - 'patchelf-libcxx-mismatch': { auto: true, pkgs: ['libc++'], commands: ['pkg install libc++'] }, - 'patchelf-link-error': { auto: false, pkgs: [], commands: ['pkg update && pkg upgrade'] }, - 'patchelf-failed': { auto: false, pkgs: [], commands: ['pkg install patchelf libc++'] }, - 'readelf-missing': { auto: true, pkgs: ['llvm'], commands: ['pkg install llvm'] }, - 'readelf-failed': { auto: false, pkgs: [], commands: ['pkg install llvm'] }, + 'glibc-missing': { auto: true, pkgs: ['glibc'], repoPkgs: ['glibc-repo'], commands: ['pkg install glibc-repo && pkg install glibc'] }, + 'glibc-broken': { auto: false, pkgs: [], repoPkgs: [], commands: ['pkg install glibc-repo && pkg install glibc'] }, + 'patchelf-missing': { auto: true, pkgs: ['patchelf', 'libc++'], repoPkgs: [], commands: ['pkg install patchelf libc++'] }, + 'patchelf-libcxx-mismatch': { auto: true, pkgs: ['libc++'], repoPkgs: [], commands: ['pkg install libc++'] }, + 'patchelf-link-error': { auto: false, pkgs: [], repoPkgs: [], commands: ['pkg update && pkg upgrade'] }, + 'patchelf-failed': { auto: false, pkgs: [], repoPkgs: [], commands: ['pkg install patchelf libc++'] }, + 'readelf-missing': { auto: true, pkgs: ['llvm'], repoPkgs: [], commands: ['pkg install llvm'] }, + 'readelf-failed': { auto: false, pkgs: [], repoPkgs: [], commands: ['pkg install llvm'] }, + 'tar-missing': { auto: true, pkgs: ['tar'], repoPkgs: [], commands: ['pkg install tar'] }, + 'tar-failed': { auto: false, pkgs: [], repoPkgs: [], commands: ['pkg install tar'] }, }; for (const [code, want] of Object.entries(expected)) { const p = PROBLEMS[code]; assert.ok(p, `missing code: ${code}`); assert.equal(p.auto, want.auto, `auto: ${code}`); assert.deepEqual(p.pkgs, want.pkgs, `pkgs: ${code}`); + assert.deepEqual(p.repoPkgs, want.repoPkgs, `repoPkgs: ${code}`); assert.deepEqual(p.commands, want.commands, `commands: ${code}`); } }); @@ -194,7 +199,7 @@ test('undefined stderr maps to patchelf-failed', () => { // makeProblem test('makeProblem returns the expected fields', () => { const p = makeProblem('patchelf-missing', 'detail text'); - assert.deepEqual(Object.keys(p).sort(), ['auto', 'code', 'commands', 'detail', 'en', 'ja', 'pkgs']); + assert.deepEqual(Object.keys(p).sort(), ['auto', 'code', 'commands', 'detail', 'en', 'ja', 'pkgs', 'repoPkgs']); assert.equal(p.code, 'patchelf-missing'); assert.equal(p.auto, true); assert.deepEqual(p.pkgs, ['patchelf', 'libc++']); @@ -232,65 +237,186 @@ test('makeProblem throws for an unknown code', () => { assert.throws(() => makeProblem('no-such-code'), /unknown problem code/); }); +// makeProblem(repoPkgs の複製・全コードの形) +test('makeProblem copies repoPkgs', () => { + const p = makeProblem('glibc-missing'); + p.repoPkgs.push('x'); + assert.deepEqual(makeProblem('glibc-missing').repoPkgs, ['glibc-repo']); + assert.deepEqual(PROBLEMS['glibc-missing'].repoPkgs, ['glibc-repo']); +}); + +test('makeProblem gives an array repoPkgs for every PROBLEMS code', () => { + const codes = Object.keys(PROBLEMS); + assert.equal(codes.length, 10); + for (const code of codes) { + assert.ok(Array.isArray(makeProblem(code).repoPkgs), `repoPkgs: ${code}`); + } +}); + // buildAutoFix test('buildAutoFix returns null for an empty list', () => { assert.equal(buildAutoFix([]), null); }); -test('buildAutoFix returns null when a manual-only problem is present', () => { - const problems = [makeProblem('glibc-missing'), makeProblem('patchelf-missing')]; +test('buildAutoFix returns null when only manual problems are present', () => { + const problems = [makeProblem('glibc-broken'), makeProblem('patchelf-link-error')]; assert.equal(buildAutoFix(problems), null); }); test('buildAutoFix for patchelf-missing alone', () => { assert.deepEqual(buildAutoFix([makeProblem('patchelf-missing')]), { + steps: [{ args: ['install', 'patchelf', 'libc++'] }], pkgs: ['patchelf', 'libc++'], args: ['install', 'patchelf', 'libc++'], + manual: [], }); }); test('buildAutoFix for patchelf-missing and readelf-missing', () => { assert.deepEqual( buildAutoFix([makeProblem('patchelf-missing'), makeProblem('readelf-missing')]), - { pkgs: ['patchelf', 'libc++', 'llvm'], args: ['install', 'patchelf', 'libc++', 'llvm'] }, + { + steps: [{ args: ['install', 'patchelf', 'libc++', 'llvm'] }], + pkgs: ['patchelf', 'libc++', 'llvm'], + args: ['install', 'patchelf', 'libc++', 'llvm'], + manual: [], + }, ); }); test('buildAutoFix for libcxx-mismatch and readelf-missing', () => { assert.deepEqual( buildAutoFix([makeProblem('patchelf-libcxx-mismatch'), makeProblem('readelf-missing')]), - { pkgs: ['libc++', 'llvm'], args: ['install', 'libc++', 'llvm'] }, + { + steps: [{ args: ['install', 'libc++', 'llvm'] }], + pkgs: ['libc++', 'llvm'], + args: ['install', 'libc++', 'llvm'], + manual: [], + }, ); }); test('buildAutoFix for readelf-missing alone', () => { assert.deepEqual(buildAutoFix([makeProblem('readelf-missing')]), { + steps: [{ args: ['install', 'llvm'] }], pkgs: ['llvm'], args: ['install', 'llvm'], + manual: [], }); }); test('buildAutoFix deduplicates libc++ from patchelf-missing and libcxx-mismatch', () => { assert.deepEqual( buildAutoFix([makeProblem('patchelf-missing'), makeProblem('patchelf-libcxx-mismatch')]), - { pkgs: ['patchelf', 'libc++'], args: ['install', 'patchelf', 'libc++'] }, + { + steps: [{ args: ['install', 'patchelf', 'libc++'] }], + pkgs: ['patchelf', 'libc++'], + args: ['install', 'patchelf', 'libc++'], + manual: [], + }, ); }); test('buildAutoFix deduplicates llvm from two readelf-missing', () => { assert.deepEqual( buildAutoFix([makeProblem('readelf-missing'), makeProblem('readelf-missing')]), - { pkgs: ['llvm'], args: ['install', 'llvm'] }, + { + steps: [{ args: ['install', 'llvm'] }], + pkgs: ['llvm'], + args: ['install', 'llvm'], + manual: [], + }, ); }); test('buildAutoFix uses fixed package order regardless of input order', () => { assert.deepEqual( buildAutoFix([makeProblem('readelf-missing'), makeProblem('patchelf-missing')]), - { pkgs: ['patchelf', 'libc++', 'llvm'], args: ['install', 'patchelf', 'libc++', 'llvm'] }, + { + steps: [{ args: ['install', 'patchelf', 'libc++', 'llvm'] }], + pkgs: ['patchelf', 'libc++', 'llvm'], + args: ['install', 'patchelf', 'libc++', 'llvm'], + manual: [], + }, ); }); +// buildAutoFix(追加): 段階の分け方・パッケージ集合・manual・配列の非共有 +test('buildAutoFix for glibc-missing alone uses two stages: glibc-repo, then glibc', () => { + const r = buildAutoFix([makeProblem('glibc-missing')]); + assert.deepEqual(r.steps.map((s) => s.args), [['install', 'glibc-repo'], ['install', 'glibc']]); + assert.deepEqual(r.pkgs, ['glibc']); + assert.deepEqual(r.args, ['install', 'glibc']); + assert.deepEqual(r.manual, []); +}); + +test('buildAutoFix for glibc-missing and patchelf-missing puts glibc, patchelf, libc++ in stage 2', () => { + const r = buildAutoFix([makeProblem('glibc-missing'), makeProblem('patchelf-missing')]); + assert.deepEqual(r.steps.map((s) => s.args), [ + ['install', 'glibc-repo'], + ['install', 'glibc', 'patchelf', 'libc++'], + ]); +}); + +test('buildAutoFix moves glibc-broken to manual and keeps only auto problems in steps', () => { + const r = buildAutoFix([makeProblem('glibc-missing'), makeProblem('glibc-broken')]); + assert.deepEqual(r.manual.map((p) => p.code), ['glibc-broken']); + assert.deepEqual(r.steps.map((s) => s.args), [['install', 'glibc-repo'], ['install', 'glibc']]); + assert.deepEqual(r.pkgs, ['glibc']); +}); + +test('buildAutoFix for tar-missing alone is one stage', () => { + assert.deepEqual(buildAutoFix([makeProblem('tar-missing')]), { + steps: [{ args: ['install', 'tar'] }], + pkgs: ['tar'], + args: ['install', 'tar'], + manual: [], + }); +}); + +test('buildAutoFix for tar-missing, readelf-missing and patchelf-missing uses fixed package order', () => { + const r = buildAutoFix([ + makeProblem('tar-missing'), + makeProblem('readelf-missing'), + makeProblem('patchelf-missing'), + ]); + assert.deepEqual(r.steps.map((s) => s.args), [['install', 'patchelf', 'libc++', 'llvm', 'tar']]); + assert.deepEqual(r.pkgs, ['patchelf', 'libc++', 'llvm', 'tar']); + assert.deepEqual(r.args, ['install', 'patchelf', 'libc++', 'llvm', 'tar']); +}); + +test('buildAutoFix with two glibc-missing lists glibc-repo only once', () => { + const r = buildAutoFix([makeProblem('glibc-missing'), makeProblem('glibc-missing')]); + assert.deepEqual(r.steps.map((s) => s.args), [['install', 'glibc-repo'], ['install', 'glibc']]); + assert.deepEqual(r.pkgs, ['glibc']); +}); + +test('buildAutoFix result does not share arrays with its inputs or PROBLEMS', () => { + const problems = [makeProblem('glibc-missing'), makeProblem('patchelf-missing'), makeProblem('glibc-broken')]; + const r = buildAutoFix(problems); + r.steps[0].args.push('x'); + r.steps[1].args.push('x'); + r.pkgs.push('x'); + r.args.push('x'); + r.manual.push('x'); + assert.deepEqual(problems[0].pkgs, ['glibc']); + assert.deepEqual(problems[0].repoPkgs, ['glibc-repo']); + assert.deepEqual(problems[1].pkgs, ['patchelf', 'libc++']); + assert.equal(problems.length, 3); + assert.deepEqual(makeProblem('glibc-missing').repoPkgs, ['glibc-repo']); + assert.deepEqual(PROBLEMS['glibc-missing'].pkgs, ['glibc']); + assert.deepEqual(PROBLEMS['glibc-missing'].repoPkgs, ['glibc-repo']); + assert.deepEqual(PROBLEMS['patchelf-missing'].pkgs, ['patchelf', 'libc++']); +}); + +test('buildAutoFix returns null for a non-array input', () => { + assert.equal(buildAutoFix(null), null); + assert.equal(buildAutoFix(undefined), null); + assert.equal(buildAutoFix('glibc-missing'), null); + assert.equal(buildAutoFix({}), null); + assert.equal(buildAutoFix([]), null); +}); + // PrereqError test('PrereqError carries exitCode, userMessage and problems', () => { const problems = [makeProblem('glibc-missing')]; @@ -431,7 +557,7 @@ test('glibc loader missing (ENOENT) is reported as glibc-missing and not execute assert.deepEqual(checkPrerequisites({ spawn: s.spawn, fsApi: f }), [ makeProblem('glibc-missing', 'ENOENT: no such file'), ]); - assert.deepEqual(s.calls.map((c) => c.cmd), ['patchelf', 'readelf']); + assert.deepEqual(s.calls.map((c) => c.cmd), ['patchelf', 'readelf', 'tar']); }); test('glibc loader not executable (EACCES) is reported as glibc-broken and not executed', () => { @@ -440,7 +566,7 @@ test('glibc loader not executable (EACCES) is reported as glibc-broken and not e assert.deepEqual(checkPrerequisites({ spawn: s.spawn, fsApi: f }), [ makeProblem('glibc-broken', 'EACCES: permission denied'), ]); - assert.deepEqual(s.calls.map((c) => c.cmd), ['patchelf', 'readelf']); + assert.deepEqual(s.calls.map((c) => c.cmd), ['patchelf', 'readelf', 'tar']); }); test('glibc loader spawn failure is reported as glibc-broken with message and stderr', () => { @@ -466,7 +592,7 @@ test('all missing items are collected in detection order', () => { ]); }); -test('glibc spawn failure does not stop patchelf and readelf checks', () => { +test('glibc spawn failure does not stop patchelf, readelf and tar checks', () => { const s = fakeSpawn({ [GLIBC_LD_PATH]: execError({ status: 1, message: 'Command failed' }), patchelf: execError({ status: 127, stderr: 'cannot locate symbol "_ZNSt6__ndk1x"' }), @@ -478,25 +604,76 @@ test('glibc spawn failure does not stop patchelf and readelf checks', () => { 'patchelf-libcxx-mismatch', 'readelf-failed', ]); - assert.equal(s.calls.length, 3); + assert.equal(s.calls.length, 4); }); // spawn の引数・オプション test('each spawn call uses --version, pipe stdio, utf8 and 10s timeout', () => { const s = fakeSpawn(); checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }); - assert.equal(s.calls.length, 3); + assert.equal(s.calls.length, 4); for (const c of s.calls) { assert.deepEqual(c.args, ['--version'], `args: ${c.cmd}`); assert.deepEqual(c.opts, { stdio: 'pipe', encoding: 'utf8', timeout: 10000 }, `opts: ${c.cmd}`); } }); -// spawn の呼び出し順と回数(glibc 正常 → patchelf → readelf の3回) -test('spawn is called in order: glibc loader, patchelf, readelf (3 calls)', () => { +// spawn の呼び出し順と回数(glibc 正常 → patchelf → readelf → tar の4回) +test('spawn is called in order: glibc loader, patchelf, readelf, tar (4 calls)', () => { const s = fakeSpawn(); checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }); - assert.deepEqual(s.calls.map((c) => c.cmd), [GLIBC_LD_PATH, 'patchelf', 'readelf']); + assert.deepEqual(s.calls.map((c) => c.cmd), [GLIBC_LD_PATH, 'patchelf', 'readelf', 'tar']); +}); + +// checkPrerequisites(tar): 検出の有無・分類・検出順・成功時の呼び出し +test('tar ENOENT is reported as tar-missing at the end of the list', () => { + const s = fakeSpawn({ tar: execError({ code: 'ENOENT' }) }); + const problems = checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }); + assert.deepEqual(problems.map((p) => p.code), ['tar-missing']); + const last = problems[problems.length - 1]; + assert.equal(last.code, 'tar-missing'); + assert.equal(last.auto, true); + assert.deepEqual(last.pkgs, ['tar']); +}); + +test('tar non-zero exit is reported as tar-failed with stderr and status in detail', () => { + const s = fakeSpawn({ tar: execError({ status: 1, stderr: 'x' }) }); + const problems = checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }); + assert.deepEqual(problems, [makeProblem('tar-failed', 'x\nstatus: 1')]); + assert.equal(problems[0].auto, false); +}); + +test('tar timeout with SIGTERM is reported as tar-failed with signal in detail', () => { + const s = fakeSpawn({ tar: execError({ code: 'ETIMEDOUT', signal: 'SIGTERM' }) }); + assert.deepEqual(checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }), [ + makeProblem('tar-failed', 'signal: SIGTERM'), + ]); +}); + +test('tar EACCES without status, stderr or signal is reported as tar-failed with empty detail', () => { + const s = fakeSpawn({ tar: execError({ code: 'EACCES' }) }); + assert.deepEqual(checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }), [ + makeProblem('tar-failed', ''), + ]); +}); + +test('patchelf, readelf and tar failures are detected in order glibc, patchelf, readelf, tar', () => { + const s = fakeSpawn({ + patchelf: execError({ code: 'ENOENT' }), + readelf: execError({ code: 'ENOENT' }), + tar: execError({ code: 'ENOENT' }), + }); + const problems = checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }); + assert.deepEqual(problems.map((p) => p.code), ['patchelf-missing', 'readelf-missing', 'tar-missing']); + assert.deepEqual(s.calls.map((c) => c.cmd), [GLIBC_LD_PATH, 'patchelf', 'readelf', 'tar']); +}); + +test('all checks pass calls tar once with --version', () => { + const s = fakeSpawn(); + assert.deepEqual(checkPrerequisites({ spawn: s.spawn, fsApi: fakeFsApi() }), []); + const tarCalls = s.calls.filter((c) => c.cmd === 'tar'); + assert.equal(tarCalls.length, 1); + assert.deepEqual(tarCalls[0].args, ['--version']); }); // ---- resolvePrerequisites の追加テスト(stub のみ使用。実際の pkg / patchelf / readelf / bash は呼ばない) ---- @@ -606,7 +783,7 @@ async function runResolve(problems, opts = {}) { // 1. 手動のみの問題が混在 → 自動実行せず exitCode 1、spawnSync は一切呼ばれず、各問題の ja/en は stderr に1回ずつ出る test('mixed manual-only problems: exit 1, no spawnSync call at all, each ja/en printed once', async () => { - const problems = [makeProblem('patchelf-missing'), makeProblem('glibc-missing')]; + const problems = [makeProblem('glibc-broken'), makeProblem('patchelf-link-error')]; const r = await runResolve(problems, { isTTY: true }); assert.ok(r.err instanceof PrereqError); assert.equal(r.err.exitCode, 1); @@ -736,16 +913,16 @@ test('non-TTY stdin: no question, PrereqError exit 1, no pkg run', async () => { assert.equal(r.runCalls.filter((c) => c.cmd === TERMUX_PKG).length, 0); }); -// 7. describeProblems の内容は stderr に出て、PrereqError.userMessage には ja/en を含まず [code] とコマンドを含む -test('describeProblems goes to stderr; userMessage has [code] and commands but no ja/en', async () => { +// 7. describeProblems の内容は stderr に出て、PrereqError.userMessage には ja/en・[code]・コマンドを含む +test('describeProblems goes to stderr; userMessage has ja/en, [code] and commands', async () => { const problems = [makeProblem('patchelf-missing'), makeProblem('readelf-missing')]; const r = await runResolve(problems, { isTTY: false }); assert.ok(r.err instanceof PrereqError); for (const p of problems) { assert.ok(r.out.includes(p.ja), `stderr ja: ${p.code}`); assert.ok(r.out.includes(p.en), `stderr en: ${p.code}`); - assert.ok(!r.err.userMessage.includes(p.ja), `no ja in userMessage: ${p.code}`); - assert.ok(!r.err.userMessage.includes(p.en), `no en in userMessage: ${p.code}`); + assert.ok(r.err.userMessage.includes(p.ja), `ja in userMessage: ${p.code}`); + assert.ok(r.err.userMessage.includes(p.en), `en in userMessage: ${p.code}`); assert.ok(r.err.userMessage.includes('[' + p.code + ']'), `code in userMessage: ${p.code}`); for (const cmd of p.commands) { assert.ok(r.err.userMessage.includes(cmd), `command in userMessage: ${cmd}`); @@ -972,8 +1149,8 @@ test('consent screen is written to stderr on every path, with notes and the comm } }); -// 8. 拒否・EOF・SIGINT の userMessage は手動コマンドを含み、問題の ja/en 説明文は含まない -test('refuse, EOF and SIGINT userMessage has the manual command and no ja/en text', async () => { +// 8. 拒否・EOF・SIGINT の userMessage は手動コマンドと問題の ja/en 説明文を含む +test('refuse, EOF and SIGINT userMessage has the manual command and ja/en text', async () => { const p = makeProblem('patchelf-missing'); const cases = [ { name: 'refuse', inputs: ['n'] }, @@ -984,8 +1161,8 @@ test('refuse, EOF and SIGINT userMessage has the manual command and no ja/en tex const r = await runConsent(c.inputs); assert.ok(r.err instanceof PrereqError, c.name); assert.ok(r.err.userMessage.includes('pkg install patchelf libc++'), `${c.name}: manual command`); - assert.ok(!r.err.userMessage.includes(p.ja), `${c.name}: no ja`); - assert.ok(!r.err.userMessage.includes(p.en), `${c.name}: no en`); + assert.ok(r.err.userMessage.includes(p.ja), `${c.name}: ja`); + assert.ok(r.err.userMessage.includes(p.en), `${c.name}: en`); } }); @@ -1165,10 +1342,194 @@ test('ensurePrerequisites: no problems → no readline or pkg; fixed → check t // 7. 手動のみ混在を ensurePrerequisites で実行 → exitCode 1、pkg なし、check は1回 test('ensurePrerequisites: manual-only mix → exitCode 1, no pkg run, check once', async () => { - const scripted = scriptedCheck([makeProblem('patchelf-missing'), makeProblem('glibc-missing')]); + const scripted = scriptedCheck([makeProblem('glibc-broken'), makeProblem('patchelf-link-error')]); const r = await runPkgFlow([], { ensure: true, check: scripted.fn }); assert.ok(r.err instanceof PrereqError); assert.equal(r.err.exitCode, 1); assert.equal(r.pkgCalls.length, 0); assert.equal(scripted.calls.length, 1); }); + +// ---- resolvePrerequisites の多段実行・混在・再検査(I2c)---- + +// pkg 呼び出しごとに results[n] を返す(尽きたら status 0)。段ごとに結果を変えるために使う +function stagedPkg(...results) { + let n = 0; + return () => { + const r = results[n]; + n += 1; + return r === undefined ? { status: 0 } : r; + }; +} + +// 混在(自動: patchelf-libcxx-mismatch、手動: glibc-broken)の問題一覧 +function mixedProblems() { + return [makeProblem('patchelf-libcxx-mismatch'), makeProblem('glibc-broken')]; +} + +// 1. glibc-missing だけ: glibc-repo → glibc の2段を順に実行し、再検査は1回。同意画面に番号付きの2コマンドと glibc-repo の注意を出す +test('multi-stage: glibc-missing runs glibc-repo then glibc, check once, numbered commands and repo notes shown', async () => { + const check = scriptedCheck(); + const r = await runPkgFlow([makeProblem('glibc-missing')], { check: check.fn }); + assert.equal(r.err, null); + assert.equal(r.pkgCalls.length, 2); + assert.deepEqual(r.pkgCalls.map((c) => c.args), [['install', 'glibc-repo'], ['install', 'glibc']]); + for (const call of r.pkgCalls) { + assert.equal(call.opts.stdio, 'inherit'); + assert.equal(call.opts.env.TERMUX_PKG_NO_MIRROR_SELECT, '1'); + } + assert.equal(check.calls.length, 1); + assert.ok(r.out.includes(` 1. ${TERMUX_PKG} install glibc-repo`)); + assert.ok(r.out.includes(` 2. ${TERMUX_PKG} install glibc`)); + assert.ok(r.out.includes('glibc-repo の導入はリポジトリ設定を永続的に変更します')); + assert.ok(r.out.includes('Installing glibc-repo permanently changes the repository settings')); +}); + +// 2. glibc-missing と patchelf-missing: 2段目は glibc, patchelf, libc++ を1回の pkg install にまとめる +test('multi-stage: glibc-missing with patchelf-missing makes stage 2 install glibc patchelf libc++', async () => { + const check = scriptedCheck(); + const r = await runPkgFlow([makeProblem('glibc-missing'), makeProblem('patchelf-missing')], { check: check.fn }); + assert.equal(r.err, null); + assert.deepEqual(r.pkgCalls.map((c) => c.args), [['install', 'glibc-repo'], ['install', 'glibc', 'patchelf', 'libc++']]); + assert.equal(check.calls.length, 1); +}); + +// 3. 1段目の失敗(status / signal / error のどれでも): 2段目は実行せず、再検査もしない。変更がなかった保証がない旨を案内する +test('multi-stage: stage 1 failure (status, signal or error) stops before stage 2 and skips check', async () => { + const cases = [ + { name: 'status 1', result: { status: 1 }, state: 'status: 1' }, + { name: 'signal SIGTERM', result: { signal: 'SIGTERM', status: null }, state: 'signal: SIGTERM' }, + { name: 'error', result: { error: new Error('spawn failed'), status: null }, state: 'error: spawn failed' }, + ]; + for (const c of cases) { + const check = scriptedCheck(); + const r = await runPkgFlow([makeProblem('glibc-missing')], { pkgResult: stagedPkg(c.result), check: check.fn }); + assert.ok(r.err instanceof PrereqError, c.name); + assert.equal(r.err.exitCode, 1, c.name); + assert.equal(r.pkgCalls.length, 1, c.name); + assert.equal(check.calls.length, 0, c.name); + assert.ok(r.err.userMessage.includes('1 段目'), c.name); + assert.ok(r.err.userMessage.includes('変更がなかった保証はありません'), c.name); + assert.ok(r.err.userMessage.includes(c.state), c.name); + } +}); + +// 4. 1段目成功・2段目失敗(status 2): リポジトリ設定の変更可能性を案内し、再検査はしない +test('multi-stage: stage 1 success and stage 2 failure reports possible repo changes and skips check', async () => { + const check = scriptedCheck(); + const r = await runPkgFlow([makeProblem('glibc-missing')], { + pkgResult: stagedPkg({ status: 0 }, { status: 2 }), + check: check.fn, + }); + assert.ok(r.err instanceof PrereqError); + assert.equal(r.err.exitCode, 1); + assert.equal(r.pkgCalls.length, 2); + assert.equal(check.calls.length, 0); + assert.ok(r.err.userMessage.includes('1 段目は成功し、リポジトリ設定などが変更されている可能性があります')); + assert.ok(r.err.userMessage.includes('2 段目')); + assert.ok(r.err.userMessage.includes('status: 2')); +}); + +// 5. 両段成功後も glibc-missing が残る: 再実行せず手動案内で終わる。実行した段と終了状態、pkg reinstall glibc の案内を含む +test('multi-stage: both stages succeed but glibc-missing remains gives manual guidance without rerun', async () => { + const check = scriptedCheck([makeProblem('glibc-missing')]); + const r = await runPkgFlow([makeProblem('glibc-missing')], { check: check.fn }); + assert.ok(r.err instanceof PrereqError); + assert.equal(r.err.exitCode, 1); + assert.equal(r.pkgCalls.length, 2); + assert.equal(check.calls.length, 1); + const m = r.err.userMessage; + const g = makeProblem('glibc-missing'); + assert.ok(m.includes('自動実行後も問題が残っています')); + assert.ok(m.includes(' - [glibc-missing]')); + assert.ok(m.includes('実行した段と終了状態')); + assert.ok(m.includes(` 1. ${TERMUX_PKG} install glibc-repo → status: 0`)); + assert.ok(m.includes(` 2. ${TERMUX_PKG} install glibc → status: 0`)); + assert.ok(m.includes('pkg reinstall glibc')); + assert.ok(m.includes(g.ja)); + assert.ok(m.includes(g.en)); +}); + +// 6. 両段成功後に readelf-failed が残る(新しい問題種別): 日英説明と手動コマンド pkg install llvm を案内する +test('multi-stage: both stages succeed but readelf-failed remains gives its ja/en text and manual command', async () => { + const check = scriptedCheck([makeProblem('readelf-failed')]); + const r = await runPkgFlow([makeProblem('glibc-missing')], { check: check.fn }); + assert.ok(r.err instanceof PrereqError); + assert.equal(r.err.exitCode, 1); + assert.equal(r.pkgCalls.length, 2); + assert.equal(check.calls.length, 1); + const p = makeProblem('readelf-failed'); + assert.ok(r.err.userMessage.includes(p.ja)); + assert.ok(r.err.userMessage.includes(p.en)); + assert.ok(r.err.userMessage.includes('pkg install llvm')); +}); + +// 7(a). 混在: 同意画面に手動項目の見出しと glibc-broken の手動コマンドを出し、実行は libc++ の1段だけ。再検査で残った glibc-broken を案内する +test('mixed auto and manual: consent lists manual items, runs only the libc++ stage, reports remaining glibc-broken', async () => { + const check = scriptedCheck([makeProblem('glibc-broken')]); + const r = await runPkgFlow(mixedProblems(), { inputs: ['y'], check: check.fn }); + assert.ok(r.out.includes('手動で対応が必要な項目(自動では実行しません) / Items that need manual action (not run automatically):')); + assert.ok(r.out.includes('pkg install glibc-repo && pkg install glibc')); + assert.deepEqual(r.pkgCalls.map((c) => c.args), [['install', 'libc++']]); + assert.ok(r.err instanceof PrereqError); + assert.equal(r.err.exitCode, 1); + const g = makeProblem('glibc-broken'); + assert.ok(r.err.userMessage.includes(g.ja)); + assert.ok(r.err.userMessage.includes(g.en)); + assert.equal(check.calls.length, 1); +}); + +// 7(b)(c)(d). 混在: 拒否は exitCode 0、EOF と SIGINT は exitCode 1。いずれも pkg と再検査は動かない +test('mixed auto and manual: refusal, EOF and SIGINT never run pkg or check', async () => { + const cases = [ + { name: 'refuse', inputs: ['n'], exitCode: 0 }, + { name: 'EOF', inputs: [{ eof: true }], exitCode: 1 }, + { name: 'SIGINT', inputs: [{ sigint: true }], exitCode: 1 }, + ]; + for (const c of cases) { + const check = scriptedCheck(); + const r = await runPkgFlow(mixedProblems(), { inputs: c.inputs, check: check.fn }); + assert.ok(r.err instanceof PrereqError, c.name); + assert.equal(r.err.exitCode, c.exitCode, c.name); + assert.equal(r.pkgCalls.length, 0, c.name); + assert.equal(check.calls.length, 0, c.name); + } +}); + +// 7(e). 混在・非TTY: 同意画面は出さず、pkg も動かさず、手動コマンドを案内する +test('mixed auto and manual: non-TTY shows no consent screen and gives manual guidance', async () => { + const check = scriptedCheck(); + const r = await runPkgFlow(mixedProblems(), { isTTY: false, inputs: [], check: check.fn }); + assert.ok(r.err instanceof PrereqError); + assert.equal(r.err.exitCode, 1); + assert.equal(r.pkgCalls.length, 0); + assert.equal(check.calls.length, 0); + assert.equal(r.rl.createInterfaceArgs.length, 0); + assert.ok(!r.out.includes('次の前提条件を自動で修復します')); + assert.ok(r.err.userMessage.includes('pkg install libc++')); + assert.ok(r.err.userMessage.includes('pkg install glibc-repo && pkg install glibc')); +}); + +// 7(f). 混在: libc++ の1段を実行し、再検査で問題が無ければ正常終了する +test('mixed auto and manual: when the re-check finds no problems, resolve after the libc++ stage', async () => { + const check = scriptedCheck(); + const r = await runPkgFlow(mixedProblems(), { check: check.fn }); + assert.equal(r.err, null); + assert.deepEqual(r.pkgCalls.map((c) => c.args), [['install', 'libc++']]); + assert.equal(check.calls.length, 1); +}); + +// 8. 1段だけ(自動: patchelf-libcxx-mismatch): 同意画面に glibc-repo の注意を出さず、pkg が status 1 で失敗すると終了状態を案内する +test('single stage: no glibc-repo note on consent, and pkg status 1 gives the status failure message', async () => { + const check = scriptedCheck(); + const r = await runPkgFlow([makeProblem('patchelf-libcxx-mismatch')], { + pkgResult: stagedPkg({ status: 1 }), + check: check.fn, + }); + assert.ok(!r.out.includes('glibc-repo の導入はリポジトリ設定を永続的に変更します')); + assert.ok(r.err instanceof PrereqError); + assert.equal(r.err.exitCode, 1); + assert.equal(r.pkgCalls.length, 1); + assert.equal(check.calls.length, 0); + assert.ok(r.err.userMessage.includes('pkg install が終了状態 status: 1 で失敗しました')); +});