EdamAme-x / EdamAme-x/pentect

npm workspace のローカル導入が global と誤判定され、update / uninstall の対象がずれる

Open
#1,451 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
26
Forks
6
Avg merge
1h 13m
Merged PRs (30d)
384

Description

## 問題

npm workspace の子 package だけが `pentect` に依存し、npm が monorepo ルートの `node_modules/pentect` に hoist した場合、ランチャーが `PENTECT_NPM_SCOPE=global` を子プロセスに渡します。

これは通常の npm workspace 配置です。ドキュメントは [project-local npm installations の更新](https://github.com/EdamAme-x/pentect/blob/31c515a7cfe6de6e7909c3c7a420385079d85050/website/src/content/docs/start/install.md#L80) を案内していますが、この配置ではインストール元を正しく扱えません。

確認対象: `main` / `31c515a7cfe6de6e7909c3c7a420385079d85050`(Pentect `0.0.81`)。Linux、Node.js `26.1.0`、Cargo `1.97.1`。

## 再現と実測

1. 現行 checkout を `npm pack --ignore-scripts` で tarball にする。
2. 一時 monorepo のルートに `workspaces: ["packages/app"]` を置く。ルートには `pentect` 依存を置かない。
3. `packages/app/package.json` にのみ `pentect` の tarball 依存を置き、`npm install --offline --ignore-scripts --no-audit --no-fund` を実行する。
4. 子 workspace を cwd にして、hoist された実際の `packaging/npm/bin/pentect.js` を起動する。
5. ネイティブ executable は一時キャッシュ内の fixture とし、ランチャーが渡す scope / projectRoot だけを記録する。

通常の npm ローカル導入を対照群にした実測:

```json
{"layout":"ordinary-npm","scope":"local","projectRoot":""}
{"layout":"npm-workspace-hoisted","scope":"global","projectRoot":null}
```

そのまま実行できる合成 fixture

以下を `/tmp/pentect-workspace-repro.mjs` に保存し、`node /tmp/pentect-workspace-repro.mjs /absolute/path/to/pentect` で実行できます。一時ファイルを作成・削除し、公開パッケージのインストールや実際の update / uninstall は行いません。

```js
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
import {mkdtemp, mkdir, writeFile, readFile, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join, resolve} from 'node:path';
const root = resolve(process.argv[2]);
const temp = await mkdtemp(join(tmpdir(), 'pentect-workspace-repro-'));
function run(command, args, cwd, env = process.env) {
const result = spawnSync(command, args, {cwd, env, encoding: 'utf8'});
if (result.status !== 0) throw new Error(result.stderr || result.stdout);
return result.stdout;
}
try {
const metadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
const packed = JSON.parse(run('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', temp], root));
const tarball = join(temp, packed[0].filename);
const cache = join(temp, 'binary-cache');
await mkdir(join(cache, metadata.version), {recursive: true});
await writeFile(join(cache, metadata.version, 'pentect'),
'#!/usr/bin/env node\nconsole.log(JSON.stringify({scope:process.env.PENTECT_NPM_SCOPE,projectRoot:process.env.PENTECT_NPM_PROJECT_ROOT??null}))\n', {mode: 0o755});
for (const workspace of [false, true]) {
const project = join(temp, workspace ? 'workspace' : 'ordinary');
await mkdir(project);
const manifest = {name: 'audit-fixture', version: '1.0.0', private: true};
if (workspace) {
manifest.workspaces = ['packages/app'];
await mkdir(join(project, 'packages/app'), {recursive: true});
await writeFile(join(project, 'packages/app/package.json'), JSON.stringify({name:'audit-app',version:'1.0.0',dependencies:{pentect:`file:${tarball}`}}));
} else manifest.dependencies = {pentect:`file:${tarball}`};
await writeFile(join(project, 'package.json'), JSON.stringify(manifest));
run('npm', ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund'], project);
const output = JSON.parse(run(process.execPath, [join(project, 'node_modules/pentect/packaging/npm/bin/pentect.js'), 'version'], workspace ? join(project,'packages/app') : project, {...process.env, PENTECT_NPM_CACHE:cache}));
console.log(JSON.stringify({layout:workspace?'npm-workspace-hoisted':'ordinary-npm',...output}));
assert.equal(output.scope, workspace ? 'global' : 'local');
if (workspace) assert.equal(output.projectRoot, null);
}
} finally {
await rm(temp, {recursive: true, force: true});
}
```

## 原因と影響

[localProjectRoot()](https://github.com/EdamAme-x/pentect/blob/31c515a7cfe6de6e7909c3c7a420385079d85050/packaging/npm/bin/pentect.js#L10) は `packageRoot/../..` の `package.json` だけを見ます。そのファイルに直接の `pentect` 依存がない場合、[常に global を渡します](https://github.com/EdamAme-x/pentect/blob/31c515a7cfe6de6e7909c3c7a420385079d85050/packaging/npm/bin/pentect.js#L32)。

この値から作られるコマンドは、[更新では `npm install --global pentect@...`](https://github.com/EdamAme-x/pentect/blob/31c515a7cfe6de6e7909c3c7a420385079d85050/crates/pentect-cli/src/update.rs#L402)、[削除では `npm uninstall --global pentect`](https://github.com/EdamAme-x/pentect/blob/31c515a7cfe6de6e7909c3c7a420385079d85050/crates/pentect-cli/src/uninstall.rs#L67) になります。ローカル導入を操作するつもりで別の global 導入を変更し、ローカル側を残す可能性があります。

確認済みなのは実ランチャーの誤判定と、Rust 側のコマンド生成経路です。今回、ユーザー環境の global install を変更する実験はしていません。

## 完了条件

- 通常の npm local / global に加え、workspace の hoist 配置でも導入元と対象 workspace を識別する。
- 導入元を判定できない場合に global と断定しない。
- update / uninstall の対象が元の依存宣言と一致し、無関係な global 導入や root manifest を変更しないことを fixture で確認する。

Contributor guide

Open the contributing guide

Research direction

Start with packaging/npm/bin/pentect.js and run the supplied /tmp/pentect-workspace-repro.mjs fixture against the repository. Then trace the generated commands in crates/pentect-cli/src/update.rs and uninstall.rs. Done means ordinary local/global installs and hoisted npm workspaces identify the correct dependency and workspace, unknown origins are not treated as global, and the fixture verifies update/uninstall targets.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js, rust
Domain
cli, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.