From dff565a0730c59596dc3e175bc7df0f939ed4c22 Mon Sep 17 00:00:00 2001 From: yeonigi Date: Sun, 5 Jul 2026 01:15:11 +0900 Subject: [PATCH 1/5] docs: spec for cloudfront directory-index 301 redirect (#44) Co-Authored-By: Claude Opus 4.8 --- ...05-cloudfront-directory-redirect-design.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-cloudfront-directory-redirect-design.md diff --git a/docs/superpowers/specs/2026-07-05-cloudfront-directory-redirect-design.md b/docs/superpowers/specs/2026-07-05-cloudfront-directory-redirect-design.md new file mode 100644 index 0000000..42c8b92 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-cloudfront-directory-redirect-design.md @@ -0,0 +1,138 @@ +# cloudfront 디렉터리 인덱스 301 리다이렉트 + +- **이슈**: [#44](https://github.com/jkas2016/hostdoc/issues/44) — 폴더 배포 시 trailing slash 없는 URL 흰 화면 +- **날짜**: 2026-07-05 +- **모드 영향**: cloudfront 전용 (s3-website는 변경 없음) + +## 문제 + +cloudfront 모드로 배포한 폴더(멀티파일) 문서는 공유 링크에서 trailing slash가 빠지면 흰 화면이 된다. + +현재 CloudFront Function(`infra/index-rewrite.js`)은 확장자 없는 URI(`/apple-game`)를 **내부 rewrite**로 `/apple-game/index.html`을 그대로 200 서빙한다. 이때 브라우저의 주소는 슬래시 없이 `/apple-game`으로 남고, `index.html`이 참조하는 상대경로 자산 `./assets/...`는 도메인 루트 `/assets/...`로 새어 403이 된다. 앱이 부팅하지 못하고 **콘솔 에러 없는 흰 화면**이 된다. + +``` +GET /apple-game → 200 (index.html, 주소는 슬래시 없음) +GET /assets/index-*.js → 403 (상대경로가 루트로 샘) +GET /apple-game/assets/... → 200 (슬래시 있으면 정상) +``` + +근본 원인은 엣지 라우팅이다. 앱은 상대경로로 올바르게 빌드되어 있어 수정 대상이 아니다. + +### 왜 cloudfront만 문제인가 + +- **s3-website 모드**: S3 정적 웹사이트 호스팅이 `IndexDocument`를 설정하면([`src/commands/setup.ts:50`](../../../src/commands/setup.ts)) 폴더형 요청 `/foo`를 `/foo/`로 네이티브 302 리다이렉트한다. 이미 정상 동작. +- **cloudfront 모드**: 오리진이 비공개 S3(OAC)라 웹사이트 엔드포인트의 디렉터리 리다이렉트가 없다. 라우팅을 CloudFront Function이 전담하며, 현재 그 함수가 슬래시를 붙이지 않는다. + +## 기대 동작 + +정적 호스트의 표준 디렉터리 인덱스 동작: 디렉터리 코드 URL `/` 접속 시 엣지가 `//`로 **301 리다이렉트**한다. 그러면 브라우저 주소에 슬래시가 붙고 상대경로 자산이 항상 올바른 서브패스로 풀린다. + +## 설계 + +### 변경 대상 + +- `infra/index-rewrite.js` — CloudFront Function (viewer-request) +- `test/index-rewrite.test.ts` — 순수 핸들러 로직 테스트 + +### 로직 (분기 순서 유지) + +| # | 조건 | 동작 | 변경 | +|---|------|------|------| +| 1 | URI가 `/_`로 시작 | `403 Forbidden` | 유지 | +| 2 | URI가 `/`로 끝남 | `index.html` 부착 (내부 rewrite) | 유지 | +| 3 | 확장자 없음 + 슬래시 없음 | **`301 → uri + "/"` (쿼리스트링 보존)** | **변경** | +| 4 | 확장자 있는 파일 | 그대로 통과 | 유지 | + +3번 분기가 기존의 내부 rewrite(`uri + "/index.html"`)에서 **301 리다이렉트**로 바뀐다. 리다이렉트 후 브라우저가 슬래시 URL로 재요청하면 2번 분기가 `index.html`을 서빙한다. + +적용 범위는 **모든 확장자 없는 URI** — 최상위 코드(`/apple-game`)뿐 아니라 중첩 경로(`/team/q1/report`)도 동일하게 301로 슬래시를 붙인다. 동작이 균일하고 중첩 폴더의 상대경로 자산도 항상 올바르게 풀린다. + +### 쿼리스트링 보존 + +CloudFront Function의 `event.request.querystring`은 `{ key: { value }, ... }` 형태의 맵이다. `multiValue`가 있는 키(반복 파라미터)까지 포함해 원본 쿼리 문자열을 재구성하여 `Location` 헤더에 이어 붙인다. 쿼리스트링이 비면 `?`를 붙이지 않는다. + +```js +function handler(event) { + var request = event.request; + var uri = request.uri; + + if (uri.indexOf("/_") === 0) { + return { statusCode: 403, statusDescription: "Forbidden" }; + } + + if (uri.endsWith("/")) { + request.uri = uri + "index.html"; + return request; + } + + var lastSegment = uri.substring(uri.lastIndexOf("/") + 1); + if (lastSegment.indexOf(".") === -1) { + var location = uri + "/" + buildQueryString(request.querystring); + return { + statusCode: 301, + statusDescription: "Moved Permanently", + headers: { location: { value: location } }, + }; + } + + return request; +} + +// {k:{value}} 또는 {k:{multiValue:[{value}...]}} → "?k=v&k=v2" ("" if empty) +function buildQueryString(qs) { + var parts = []; + for (var key in qs) { + var v = qs[key]; + if (v.multiValue) { + for (var i = 0; i < v.multiValue.length; i++) { + parts.push(key + "=" + v.multiValue[i].value); + } + } else { + parts.push(v.value === "" ? key : key + "=" + v.value); + } + } + return parts.length ? "?" + parts.join("&") : ""; +} +``` + +### 동작 결과 + +| 접속 URL | 응답 | 최종 | +|---|---|---| +| `/apple-game` | 301 → `/apple-game/` | 슬래시 URL 재요청 → index.html 200, 상대경로 자산 정상 ✅ | +| `/apple-game/` | 200 index.html | 정상 ✅ | +| `/apple-game?level=3` | 301 → `/apple-game/?level=3` | 쿼리 보존 ✅ | +| `/team/q1/report` | 301 → `/team/q1/report/` | 중첩도 정상 ✅ | +| `/apple-game/assets/app.js` | 통과 | 200 ✅ | +| `/_meta/x.json` | 403 | 차단 ✅ | + +## 테스트 계획 (TDD — 구현 전 작성) + +`test/index-rewrite.test.ts`는 `vm`으로 함수 소스를 로드해 순수 핸들러를 검증한다. `reqEvent` 헬퍼에 `querystring`을 넣을 수 있도록 확장한다. + +**변경(rewrite → redirect 기대값 교체):** +- `/x7Kq2a` → `statusCode` 301, `headers.location.value` = `/x7Kq2a/` +- `/team/q1/report` → 301, `location` = `/team/q1/report/` + +**추가(쿼리스트링 보존):** +- `/x7Kq2a` + `querystring {a:{value:"1"}, b:{value:"2"}}` → `location` = `/x7Kq2a/?a=1&b=2` +- 쿼리 없음 → `location`에 `?` 없음 (`/x7Kq2a/`) +- 값 없는 플래그 파라미터 `{flag:{value:""}}` → `location` = `/x7Kq2a/?flag` +- 반복 파라미터 `multiValue` → `?k=v1&k=v2` + +**회귀(유지 검증):** +- `/x7Kq2a/` → `uri` = `/x7Kq2a/index.html` +- `/team/q1/report/` → `uri` = `/team/q1/report/index.html` +- `/x7Kq2a/assets/app.js` → `uri` 그대로 +- `/_meta/x7Kq2a.json` → 403 +- `/_meta/team/q1/report.json` → 403 + +## 배포 주의사항 + +이 함수 수정은 코드 변경만으로 기존 배포된 CloudFront distribution에 반영되지 않는다. 사용자가 최신 hostdoc으로 **재-provision**(`terraform apply`) 해야 새 함수가 적용된다. 릴리스 노트/이슈에 이 점을 명시한다. + +## 범위 밖 (YAGNI) + +- **SPA 클라이언트 라우팅 폴백**: `/apple-game/level/3` 같은 존재하지 않는 경로를 index.html로 폴백하는 것은 별개 기능. 현재도(그리고 변경 후에도) 301→슬래시→index.html 미존재로 404가 되며, 이 이슈 범위가 아니다. +- **s3-website 모드**: 이미 정상이라 손대지 않는다. +- **앱/업로드 레벨 수정**: 근본 원인이 엣지라 대상 아님. From bf89427922ffe662b46041f9c079322a4a9c79d7 Mon Sep 17 00:00:00 2001 From: yeonigi Date: Sun, 5 Jul 2026 01:59:09 +0900 Subject: [PATCH 2/5] docs: implementation plan for cloudfront directory redirect (#44) Co-Authored-By: Claude Opus 4.8 --- ...026-07-05-cloudfront-directory-redirect.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-cloudfront-directory-redirect.md diff --git a/docs/superpowers/plans/2026-07-05-cloudfront-directory-redirect.md b/docs/superpowers/plans/2026-07-05-cloudfront-directory-redirect.md new file mode 100644 index 0000000..5940492 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-cloudfront-directory-redirect.md @@ -0,0 +1,198 @@ +# cloudfront 디렉터리 인덱스 301 리다이렉트 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** cloudfront 모드에서 `/`(슬래시 없음) 접속 시 `//`로 301 리다이렉트해, 상대경로 자산이 루트로 새어 흰 화면이 되는 버그(#44)를 없앤다. + +**Architecture:** `infra/index-rewrite.js`(CloudFront Function, viewer-request)의 확장자-없음+슬래시-없음 분기를 내부 rewrite에서 301 리다이렉트로 전환한다. 쿼리스트링을 재구성해 `Location` 헤더에 보존한다. 순수 함수라 `vm` 샌드박스로 로드해 단위 테스트한다. + +**Tech Stack:** CloudFront Functions (cloudfront-js-2.0, ES5 문법), Vitest, node `vm`. + +## Global Constraints + +- CloudFront Functions 런타임은 **ES5**만 지원 — `var`만 사용, `const`/`let`/화살표함수/템플릿리터럴/`for...of` 금지 (기존 `infra/index-rewrite.js` 스타일 준수). +- 분기 순서 불변: ①`/_*`→403 → ②트레일링 슬래시→index.html → ③확장자 없음→301 → ④파일 통과. +- CI는 AWS/Terraform 없이 build→typecheck→test만 실행 — 테스트는 함수 소스를 `vm`으로 로드하는 순수 로직 검증만. +- 테스트 러너: `npx vitest run test/index-rewrite.test.ts`. + +--- + +### Task 1: `/` → `//` 301 리다이렉트 (쿼리스트링 보존) + +**Files:** +- Modify: `infra/index-rewrite.js` (확장자-없음 분기 + `buildQueryString` 헬퍼 추가) +- Test: `test/index-rewrite.test.ts` (rewrite 기대값 2건 교체 + 신규 케이스 추가) + +**Interfaces:** +- Consumes: CloudFront Function 이벤트 `{ request: { uri, querystring, headers } }`. `querystring`은 `{ key: { value } }` 또는 반복 파라미터 시 `{ key: { multiValue: [{ value }, ...] } }`. +- Produces: `handler(event)` 반환값 — + - `/_*`: `{ statusCode: 403, statusDescription: "Forbidden" }` + - 트레일링 슬래시/파일: `request`(수정된 `uri`) + - 확장자 없음+슬래시 없음: `{ statusCode: 301, statusDescription: "Moved Permanently", headers: { location: { value } } }` + - 내부 헬퍼 `buildQueryString(qs) -> string` (`""` 또는 `"?..."`) + +- [ ] **Step 1: 실패하는 테스트 작성** + +`test/index-rewrite.test.ts`에서 `reqEvent` 헬퍼를 querystring 지원하도록 확장하고, 확장자-없음 케이스 2건을 redirect 기대값으로 교체 후 신규 케이스를 추가한다. + +기존 헬퍼(파일 상단, 현재 16행): +```ts +const reqEvent = (uri: string) => ({ request: { uri, headers: {} } }); +``` +를 다음으로 교체: +```ts +const reqEvent = (uri: string, querystring: Record = {}) => ({ + request: { uri, querystring, headers: {} }, +}); +``` + +기존 테스트 2건 교체 — 현재: +```ts + it("appends /index.html for an extensionless URI", () => { + const out = handler(reqEvent("/x7Kq2a")); + expect(out.uri).toBe("/x7Kq2a/index.html"); + }); +``` +```ts + it("appends /index.html for a nested extensionless URI", () => { + const out = handler(reqEvent("/team/q1/report")); + expect(out.uri).toBe("/team/q1/report/index.html"); + }); +``` +를 다음으로 교체: +```ts + it("301-redirects an extensionless URI to add a trailing slash", () => { + const out = handler(reqEvent("/x7Kq2a")); + expect(out.statusCode).toBe(301); + expect(out.statusDescription).toBe("Moved Permanently"); + expect(out.headers.location.value).toBe("/x7Kq2a/"); + }); + + it("301-redirects a nested extensionless URI to add a trailing slash", () => { + const out = handler(reqEvent("/team/q1/report")); + expect(out.statusCode).toBe(301); + expect(out.headers.location.value).toBe("/team/q1/report/"); + }); +``` + +신규 케이스 추가(같은 describe 블록 안, 위 redirect 테스트들 뒤): +```ts + it("preserves the query string in the redirect location", () => { + const out = handler( + reqEvent("/x7Kq2a", { a: { value: "1" }, b: { value: "2" } }), + ); + expect(out.headers.location.value).toBe("/x7Kq2a/?a=1&b=2"); + }); + + it("omits the '?' when there is no query string", () => { + const out = handler(reqEvent("/x7Kq2a")); + expect(out.headers.location.value).toBe("/x7Kq2a/"); + }); + + it("keeps a valueless flag parameter without '='", () => { + const out = handler(reqEvent("/x7Kq2a", { flag: { value: "" } })); + expect(out.headers.location.value).toBe("/x7Kq2a/?flag"); + }); + + it("expands a repeated (multiValue) parameter", () => { + const out = handler( + reqEvent("/x7Kq2a", { k: { multiValue: [{ value: "1" }, { value: "2" }] } }), + ); + expect(out.headers.location.value).toBe("/x7Kq2a/?k=1&k=2"); + }); +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: `npx vitest run test/index-rewrite.test.ts` +Expected: FAIL — 교체된 케이스는 `out.uri`가 아닌 `out.statusCode`/`out.headers`를 기대하나 현재 핸들러는 `uri`를 rewrite해 `out.statusCode`가 `undefined`, `out.headers`가 `undefined`라 `location` 접근 시 실패. 신규 케이스도 동일하게 실패. + +- [ ] **Step 3: 핸들러 구현** + +`infra/index-rewrite.js` 전체를 다음으로 교체: +```js +// CloudFront Function (runtime: cloudfront-js-2.0), viewer-request. +// - Protect the private `_meta/` prefix: any "/_*" path returns 403. +// - Directory index: CloudFront's Default Root Object applies only to "/". +// Trailing-slash URIs get "index.html" appended (internal rewrite). +// Extensionless URIs without a trailing slash 301-redirect to add the +// slash, so relative assets in index.html resolve under the subpath +// instead of leaking to the domain root. +function handler(event) { + var request = event.request; + var uri = request.uri; + + if (uri.indexOf("/_") === 0) { + return { statusCode: 403, statusDescription: "Forbidden" }; + } + + if (uri.endsWith("/")) { + request.uri = uri + "index.html"; + return request; + } + + var lastSegment = uri.substring(uri.lastIndexOf("/") + 1); + if (lastSegment.indexOf(".") === -1) { + return { + statusCode: 301, + statusDescription: "Moved Permanently", + headers: { location: { value: uri + "/" + buildQueryString(request.querystring) } }, + }; + } + + return request; +} + +// {k:{value}} or {k:{multiValue:[{value},...]}} -> "?k=v&k=v2" ("" if empty). +function buildQueryString(qs) { + var parts = []; + for (var key in qs) { + var v = qs[key]; + if (v.multiValue) { + for (var i = 0; i < v.multiValue.length; i++) { + parts.push(key + "=" + v.multiValue[i].value); + } + } else { + parts.push(v.value === "" ? key : key + "=" + v.value); + } + } + return parts.length ? "?" + parts.join("&") : ""; +} +``` + +- [ ] **Step 4: 테스트 실행 → 통과 확인** + +Run: `npx vitest run test/index-rewrite.test.ts` +Expected: PASS — 전체 케이스(교체 2 + 신규 4 + 회귀 유지: 트레일링 슬래시 2, 파일 통과 1, `_meta` 403 2) 통과. + +- [ ] **Step 5: 커밋** + +```bash +git add infra/index-rewrite.js test/index-rewrite.test.ts +git commit -m "fix: 301-redirect extensionless URIs to trailing slash (#44) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: 전체 테스트·타입체크·빌드 회귀 확인 + +**Files:** (변경 없음 — 검증 전용) + +- [ ] **Step 1: 전체 테스트** + +Run: `npm test` +Expected: PASS — 기존 전체 스위트가 무회귀로 통과. + +- [ ] **Step 2: 타입체크** + +Run: `npm run typecheck` +Expected: 에러 없음. + +- [ ] **Step 3: 빌드** + +Run: `npm run build` +Expected: 에러 없이 `dist/` 생성. (`infra/`는 tsc 대상이 아니므로 함수 변경은 빌드에 영향 없지만, 테스트 파일 변경이 빌드/타입에 영향 없음을 확인.) + +이 태스크는 코드 변경이 없으므로 별도 커밋 없음. From fde8d8fa0c2ce53879ffa5eb63cd5ccf307eb729 Mon Sep 17 00:00:00 2001 From: yeonigi Date: Sun, 5 Jul 2026 02:01:39 +0900 Subject: [PATCH 3/5] fix: 301-redirect extensionless URIs to trailing slash (#44) Co-Authored-By: Claude Opus 4.8 --- infra/index-rewrite.js | 38 ++++++++++++++++++++++++++++++------- test/index-rewrite.test.ts | 39 +++++++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/infra/index-rewrite.js b/infra/index-rewrite.js index ec4cc5d..1d209e8 100644 --- a/infra/index-rewrite.js +++ b/infra/index-rewrite.js @@ -1,7 +1,10 @@ // CloudFront Function (runtime: cloudfront-js-2.0), viewer-request. // - Protect the private `_meta/` prefix: any "/_*" path returns 403. -// - Subdirectory index: CloudFront's Default Root Object applies only to "/", -// so append "index.html" for trailing-slash or extensionless URIs. +// - Directory index: CloudFront's Default Root Object applies only to "/". +// Trailing-slash URIs get "index.html" appended (internal rewrite). +// Extensionless URIs without a trailing slash 301-redirect to add the +// slash, so relative assets in index.html resolve under the subpath +// instead of leaking to the domain root. function handler(event) { var request = event.request; var uri = request.uri; @@ -12,12 +15,33 @@ function handler(event) { if (uri.endsWith("/")) { request.uri = uri + "index.html"; - } else { - var lastSegment = uri.substring(uri.lastIndexOf("/") + 1); - if (lastSegment.indexOf(".") === -1) { - request.uri = uri + "/index.html"; - } + return request; + } + + var lastSegment = uri.substring(uri.lastIndexOf("/") + 1); + if (lastSegment.indexOf(".") === -1) { + return { + statusCode: 301, + statusDescription: "Moved Permanently", + headers: { location: { value: uri + "/" + buildQueryString(request.querystring) } }, + }; } return request; } + +// {k:{value}} or {k:{multiValue:[{value},...]}} -> "?k=v&k=v2" ("" if empty). +function buildQueryString(qs) { + var parts = []; + for (var key in qs) { + var v = qs[key]; + if (v.multiValue) { + for (var i = 0; i < v.multiValue.length; i++) { + parts.push(key + "=" + v.multiValue[i].value); + } + } else { + parts.push(v.value === "" ? key : key + "=" + v.value); + } + } + return parts.length ? "?" + parts.join("&") : ""; +} diff --git a/test/index-rewrite.test.ts b/test/index-rewrite.test.ts index 91dca2f..4827c28 100644 --- a/test/index-rewrite.test.ts +++ b/test/index-rewrite.test.ts @@ -13,7 +13,9 @@ function loadHandler(): (event: unknown) => any { } const handler = loadHandler(); -const reqEvent = (uri: string) => ({ request: { uri, headers: {} } }); +const reqEvent = (uri: string, querystring: Record = {}) => ({ + request: { uri, querystring, headers: {} }, +}); describe("index-rewrite handler", () => { it("appends index.html for a trailing-slash URI", () => { @@ -21,9 +23,11 @@ describe("index-rewrite handler", () => { expect(out.uri).toBe("/x7Kq2a/index.html"); }); - it("appends /index.html for an extensionless URI", () => { + it("301-redirects an extensionless URI to add a trailing slash", () => { const out = handler(reqEvent("/x7Kq2a")); - expect(out.uri).toBe("/x7Kq2a/index.html"); + expect(out.statusCode).toBe(301); + expect(out.statusDescription).toBe("Moved Permanently"); + expect(out.headers.location.value).toBe("/x7Kq2a/"); }); it("leaves a file URI with an extension untouched", () => { @@ -41,9 +45,34 @@ describe("index-rewrite handler", () => { expect(out.uri).toBe("/team/q1/report/index.html"); }); - it("appends /index.html for a nested extensionless URI", () => { + it("301-redirects a nested extensionless URI to add a trailing slash", () => { const out = handler(reqEvent("/team/q1/report")); - expect(out.uri).toBe("/team/q1/report/index.html"); + expect(out.statusCode).toBe(301); + expect(out.headers.location.value).toBe("/team/q1/report/"); + }); + + it("preserves the query string in the redirect location", () => { + const out = handler( + reqEvent("/x7Kq2a", { a: { value: "1" }, b: { value: "2" } }), + ); + expect(out.headers.location.value).toBe("/x7Kq2a/?a=1&b=2"); + }); + + it("omits the '?' when there is no query string", () => { + const out = handler(reqEvent("/x7Kq2a")); + expect(out.headers.location.value).toBe("/x7Kq2a/"); + }); + + it("keeps a valueless flag parameter without '='", () => { + const out = handler(reqEvent("/x7Kq2a", { flag: { value: "" } })); + expect(out.headers.location.value).toBe("/x7Kq2a/?flag"); + }); + + it("expands a repeated (multiValue) parameter", () => { + const out = handler( + reqEvent("/x7Kq2a", { k: { multiValue: [{ value: "1" }, { value: "2" }] } }), + ); + expect(out.headers.location.value).toBe("/x7Kq2a/?k=1&k=2"); }); it("returns 403 for a nested underscore-prefixed meta path", () => { From 80938afabf5602423a3a9209105ab59ea1629b42 Mon Sep 17 00:00:00 2001 From: yeonigi Date: Sun, 5 Jul 2026 01:14:08 +0900 Subject: [PATCH 4/5] fix: enforce LF on .mjs scripts; tolerate CRLF in skill frontmatter test On a Windows checkout (core.autocrlf=true, no .gitattributes) the shebang .mjs wrapper scripts get CRLF line endings. esbuild/vite's hashbang stripping then leaves an invalid token, so importing them from test/skill.test.ts fails the whole suite with "SyntaxError: Invalid or unexpected token" (CI on Linux is LF, so it never saw this). A CRLF shebang also breaks execution on Unix. Add `*.mjs text eol=lf` so these scripts are always LF regardless of the contributor's autocrlf. Once the suite collected, a second latent CRLF assumption surfaced: the SKILL.md frontmatter regex hard-coded `\n`, so it failed on the CRLF checkout. Make it line-ending agnostic (`\r?\n`). Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 5 +++++ test/skill.test.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..06521fb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell-executable scripts carry a shebang (#!/usr/bin/env node). A CRLF shebang +# breaks execution on Unix and breaks esbuild/vite hashbang stripping when the +# file is imported from a test (test/skill.test.ts fails to parse on a CRLF +# checkout). Force LF regardless of the contributor's core.autocrlf. +*.mjs text eol=lf diff --git a/test/skill.test.ts b/test/skill.test.ts index a12857f..84d47d8 100644 --- a/test/skill.test.ts +++ b/test/skill.test.ts @@ -76,7 +76,7 @@ describe("preflight.mjs", () => { describe("skill structure", () => { const skillDir = join(repo, "skills", "hostdoc"); it("has SKILL.md with name+description frontmatter", () => { - const fm = readFileSync(join(skillDir, "SKILL.md"), "utf8").match(/^---\n([\s\S]*?)\n---/); + const fm = readFileSync(join(skillDir, "SKILL.md"), "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---/); expect(fm).toBeTruthy(); expect(fm![1]).toMatch(/^name:\s*hostdoc\s*$/m); expect(fm![1]).toMatch(/^description:\s*\S+/m); From 6711917cdbfdf0428442a09d856d11998e1e12cf Mon Sep 17 00:00:00 2001 From: yeonigi Date: Sun, 5 Jul 2026 01:14:08 +0900 Subject: [PATCH 5/5] fix: run `npm pack` via execSync so it resolves on Windows test/pack.test.ts spawned npm with execFileSync("npm", [...]), which throws spawnSync ENOENT on Windows (the launcher is npm.cmd, and execFileSync does not apply PATHEXT). Spawning npm.cmd directly is EINVAL since Node's CVE-2024-27980 fix, and shell:true with an args array warns DEP0190. Use execSync with a fixed literal command string: the shell resolves npm.cmd on Windows and /bin/sh runs it on Linux/CI, with no deprecation noise. Co-Authored-By: Claude Opus 4.8 --- test/pack.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/pack.test.ts b/test/pack.test.ts index f855370..4a1e4e8 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect } from "vitest"; -import { execFileSync } from "node:child_process"; +import { execSync } from "node:child_process"; import { TEMPLATE_FILES } from "../src/lib/templates.js"; // Assumes `npm run build` has run (CI order is build→typecheck→test), so // dist/templates/infra exists. --ignore-scripts packs the current dist as-is. +// execSync (shell) so `npm` resolves to npm.cmd on Windows — execFileSync can't: +// bare "npm" is ENOENT and spawning npm.cmd directly is EINVAL since Node's +// CVE-2024-27980 fix. The command is a fixed literal (no injection surface). function packedFiles(): string[] { - const out = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + const out = execSync("npm pack --dry-run --json --ignore-scripts", { encoding: "utf8", }); const parsed = JSON.parse(out) as Array<{ files: Array<{ path: string }> }>;