빌드노트
루트에서 npm run -w를 실행했는데 파일을 못 찾는 이유
npm run -w로 실행한 스크립트의 cwd는 선택한 패키지 루트다. 호출 폴더를 바꾼 실제 출력과 파일 읽기를 비교하고, 패키지 입력·호출자 입력·스크립트 자원에 맞는 경로 기준을 고른다.
저장소 루트에 shared.txt가 있는데 npm run broken -w report로 실행한 스크립트가 ENOENT를 낸다면, 파일을 읽는 기준 폴더부터 확인한다. npm 스크립트는 선택한 패키지의 루트에서 실행된다. 명령을 입력한 저장소 루트와 report 패키지의 루트는 다를 수 있다.
readFileSync('./shared.txt')의 ./는 스크립트 파일이 있는 폴더도, 터미널에서 명령을 입력한 폴더도 자동으로 가리키지 않는다. Node가 현재 작업 디렉터리인 process.cwd()를 기준으로 해석한다. npm의 실행 위치와 Node의 파일 경로 규칙이 합쳐져 생기는 문제다. npm run의 실행 위치 설명 (새 탭에서 열림), Node의 상대 파일 경로 규칙 (새 탭에서 열림)
파일이 패키지 기준 입력인지, 호출자가 고른 입력인지, 스크립트와 함께 배포하는 자원인지에 따라 기준을 정한다. 호출 위치와 무관해야 하는 자원이라면 스크립트 파일을 기준으로 URL을 만들 수 있다.
파일은 있는데 다른 경로를 찾고 있었다
실제 서비스와 분리한 작은 workspace에서 확인했다. 환경은 Windows, PowerShell 7.6.6, Node.js 22.23.2, npm 10.9.8이다. 같은 비교를 하려면 Node와 npm이 설치된 상태에서 빈 cwd-lab 폴더를 만들고 그 안에 아래 파일을 직접 준비한다. 외부 의존성이나 설치 명령은 필요하지 않다.
루트의 package.json은 {"name":"cwd-lab","private":true,"workspaces":["packages/*"]}로 저장한다. packages/report/package.json에는 아래 내용을 넣는다. -w report는 이 파일의 name으로 대상 workspace를 선택한다. npm workspace에서 명령 실행하기 (새 탭에서 열림)
{
"name": "report",
"version": "1.0.0",
"private": true,
"scripts": {
"broken": "node scripts/broken.mjs",
"fixed-shared": "node scripts/fixed-shared.mjs",
"fixed": "node scripts/fixed.mjs"
}
}
루트의 shared.txt에는 ROOT_SHARED 한 줄을 쓴다. packages/report/tasks 폴더도 만든다. packages/report/scripts/broken.mjs에는 다음을 저장한다. .mjs 파일이므로 ES 모듈 문법을 쓸 수 있다.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
console.log('cwd=' + process.cwd());
console.log('INIT_CWD=' + process.env.INIT_CWD);
console.log('target=' + resolve('./shared.txt'));
console.log(readFileSync('./shared.txt', 'utf8').trim());
PowerShell에서 실습 루트를 현재 폴더로 두고 다음 순서로 실행한다. Windows에서는 npm.cmd로 호출했다. 세 npm 명령은 실패를 관찰하기 위한 것이므로 오류 뒤에도 다음 폴더에서 비교한다.
npm.cmd run broken -w report
Set-Location packages/report
npm.cmd run broken -w report
Set-Location tasks
npm.cmd run broken -w report
루트에서 실행한 첫 출력은 다음과 같았다. 긴 절대 경로의 실습 루트 부분만 <실습 루트>로 줄였다.
cwd=<실습 루트>\packages\report
INIT_CWD=<실습 루트>
target=<실습 루트>\packages\report\shared.txt
이어서 파일 읽기에 ENOENT가 났고 npm의 종료 코드는 1이었다. 실제 파일은 <실습 루트>\shared.txt에 있는데, 코드는 <실습 루트>\packages\report\shared.txt를 찾았다. resolve()는 여기서 찾을 경로를 보여 주기 위해 썼으며, 호출만으로 파일 존재를 검사하지는 않는다.
나머지 두 호출도 종료 코드 1이었다. 세 호출의 cwd와 target은 같았고, INIT_CWD만 달랐다.
- 루트에서 호출:
INIT_CWD는 실습 루트였다. packages/report에서 호출:INIT_CWD는 해당 패키지 루트였다.packages/report/tasks에서 호출:INIT_CWD는tasks폴더였다.
공식 문서도 INIT_CWD를 npm 명령을 시작한 폴더의 전체 경로로 설명한다. 따라서 이 값을 언제나 저장소 루트라고 간주하면 안 된다. 같은 패키지라도 호출 위치에 따라 값이 바뀐다.
자기 코드에서는 실패한 readFileSync() 직전에 위 진단 출력을 넣고, target이 실제 파일의 절대 경로와 같은지 비교하면 된다. 같다면 파일 누락·파일명·배포 내용을 조사한다. node를 찾지 못하거나 workspace 선택 자체가 실패했다면 파일 읽기 단계에 도달하지 않은 것이므로 그 오류부터 해결한다.
스크립트와의 위치 관계가 고정된 파일이라면
이 배치에서 루트의 공통 파일을 읽기로 정했다면, packages/report/scripts/fixed-shared.mjs를 다음처럼 작성할 수 있다.
import { readFileSync } from 'node:fs';
const shared = new URL('../../../shared.txt', import.meta.url);
console.log(readFileSync(shared, 'utf8').trim());
import.meta.url은 현재 모듈 파일의 절대 URL이다. 여기서 세 단계 위로 올라가면 실습 루트가 된다. new URL()로 만든 파일 URL 객체를 readFileSync()에 직접 넘긴다. Node 공식 문서에도 이 방식으로 모듈 옆의 파일을 읽는 예제가 있다. import.meta.url과 상대 파일 읽기 (새 탭에서 열림)
앞에서 머문 tasks 폴더에서도 npm.cmd run fixed-shared -w report를 실행할 수 있다. 루트·패키지 루트·tasks에서 각각 실행했을 때 모두 ROOT_SHARED를 출력하고 종료 코드 0으로 끝났다. 처음 실패한 그 파일을 읽되, 경로의 기준을 모듈 파일로 고정한 결과다.
다만 ../../../는 지금의 폴더 배치에 의존한다. 스크립트를 다른 깊이로 옮기면 수정해야 한다. 배치 자체가 달라지는 도구라면 공통 파일의 절대 경로를 명시적으로 전달하는 설계도 검토한다.
스크립트에 딸린 템플릿은 더 가까운 관계로 표현할 수 있다. packages/report/scripts/template.txt에 SCRIPT_TEMPLATE을 쓰고, 같은 폴더의 fixed.mjs를 아래처럼 작성한다.
import { readFileSync } from 'node:fs';
const template = new URL('./template.txt', import.meta.url);
console.log(readFileSync(template, 'utf8').trim());
npm.cmd run fixed -w report는 세 호출 위치에서 모두 SCRIPT_TEMPLATE을 출력하고 종료 코드 0이었다. 별도 비교에서 readFileSync('./template.txt')는 세 위치 모두 ENOENT였다. 파일이 스크립트 옆에 있다는 사실만으로 일반 상대 경로의 기준이 그곳으로 바뀌지는 않았다.
INIT_CWD가 필요한 입력도 있다
반대로 사용자가 명령을 입력한 폴더의 파일을 처리하는 도구라면 호출 위치가 의미를 가진다. 별도 비교에서는 루트·패키지 루트·tasks에 각각 input.txt를 두고 내용을 ROOT_INPUT, WORKSPACE_INPUT, TASK_INPUT으로 구분했다.
같은 npm 스크립트에서 readFileSync('./input.txt', 'utf8')로 읽으면 세 호출 모두 WORKSPACE_INPUT이었다. resolve(process.env.INIT_CWD, 'input.txt')로 만든 경로를 읽으면 호출 순서에 따라 ROOT_INPUT, WORKSPACE_INPUT, TASK_INPUT이었다. 파일이 존재하면 잘못된 기준을 써도 오류 없이 다른 내용을 읽을 수 있었다.
따라서 선택 기준은 다음처럼 잡을 수 있다.
- 선택한 패키지 루트의 파일을 처리한다면
process.cwd()기준을 사용한다. 이후 코드에서 작업 디렉터리를 바꾸지 않는 조건인지 확인한다. - npm을 호출한 폴더의 사용자 입력을 처리한다면
INIT_CWD기준을 사용한다. 값이 없을 때는 명확한 오류를 내거나 별도 경로 입력을 받도록 정한다. - 스크립트와 위치 관계가 정해진 자원을 읽는다면
new URL(상대 경로, import.meta.url)로 기준을 고정한다. 위 코드는 ES 모듈 파일용이다.
INIT_CWD는 npm이 제공하는 값이므로 node로 직접 실행하는 경로에서도 있다고 가정하지 않는다. process.cwd()를 대신 쓰는 처리도 호출 위치를 바꾸는 선택이므로, 도구가 받아들이는 입력의 기준과 맞는지 판단해야 한다.
이번에 직접 확인한 것은 Windows의 npm 10.9.8에서 외부 의존성 없이 실행한 스크립트와 텍스트 읽기다. npm 10 문서 페이지의 표시 버전은 10.9.9였다. Linux·npm 11·중첩 npm 호출에서의 환경변수 전달·번들링 뒤의 자원 배치는 실행해 확인하지 않았다. URL로 기준을 고정해도 파일이 배포본에 빠졌다면 읽을 수 없으므로, 수정 후 같은 호출 위치에서 실제 내용과 종료 코드까지 다시 확인한다.