sayren 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +1507 -0
- package/package.json +41 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { ApiError, ApprovalPendingError, STOREFRONT_BUILD_TERMINAL_STATUSES, STOREFRONT_SOURCE_BLOB_BATCH_MAX, createStoreClient, normalizeStorefrontPath } from "@sayren/store-sdk";
|
|
5
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
//#region package.json
|
|
10
|
+
var version = "0.1.0";
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/client.ts
|
|
14
|
+
/**
|
|
15
|
+
* 관리 API 클라이언트 — `@sayren/store-sdk`(계약의 원천)를 쓴다. User-Agent `sayren-cli/{버전}`은 api가 버전의 만든 경로
|
|
16
|
+
* (`createdVia: CLI`)를 적는 표시다(권한과 무관하다. 에이전트 PAT면 행위자는 여전히 에이전트이고 발행은 승인 대기다).
|
|
17
|
+
* 토큰은 헤더에만 싣고 화면·로그에 찍지 않는다.
|
|
18
|
+
*/
|
|
19
|
+
const CLI_VERSION = version;
|
|
20
|
+
const CLI_USER_AGENT = `sayren-cli/${CLI_VERSION}`;
|
|
21
|
+
function clientOf(auth, fetchImpl = fetch) {
|
|
22
|
+
return createStoreClient({
|
|
23
|
+
baseUrl: `${auth.apiOrigin}/v1`,
|
|
24
|
+
auth: { apiToken: auth.token },
|
|
25
|
+
fetch: (input, init) => {
|
|
26
|
+
const headers = new Headers(init?.headers);
|
|
27
|
+
headers.set("user-agent", CLI_USER_AGENT);
|
|
28
|
+
return fetchImpl(input, {
|
|
29
|
+
...init,
|
|
30
|
+
headers
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** 오류 코드 — `ApiError`가 아니면 null */
|
|
36
|
+
function codeOf(error) {
|
|
37
|
+
return error instanceof ApiError ? error.code : null;
|
|
38
|
+
}
|
|
39
|
+
function detailsOf(error) {
|
|
40
|
+
return error instanceof ApiError ? error.details ?? {} : {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region ../mcp/src/rules.ts
|
|
45
|
+
/** 문서 사이트 — `doc` 경로를 절대 주소로 만들 때 쓴다 */
|
|
46
|
+
const DOCS_BASE_URL = "https://docs.sayren.app";
|
|
47
|
+
const docUrl = (doc) => `${DOCS_BASE_URL}${doc}`;
|
|
48
|
+
const RULES = [
|
|
49
|
+
{
|
|
50
|
+
id: "server-only-env",
|
|
51
|
+
title: "`process.env`는 `*.server.ts`에만 두고 서버 함수 안에서 읽는다",
|
|
52
|
+
why: "브라우저 번들에 들어가면 `process is not defined`로 그 화면 모듈이 통째로 깨진다. 결제 화면에서 터지면 결제가 시작조차 안 된다",
|
|
53
|
+
fix: "`process.env`를 읽는 줄을 `*.server.ts`로 옮기고, 화면에 필요한 값은 서버 함수(loader)가 내려준다. 브라우저 파일에서는 그 값을 인자로 받는다",
|
|
54
|
+
example: "// src/lib/config.server.ts\nexport const API_BASE_URL = process.env.SAYREN_API_URL ?? \"\";\n\n// 화면은 loader가 내려준 값을 쓴다\nconst getConfig = createServerFn().handler(() => ({ apiBaseUrl: API_BASE_URL }));",
|
|
55
|
+
doc: "/guides/mcp-customize",
|
|
56
|
+
templateFile: "src/lib/config.server.ts"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "tenant-header",
|
|
60
|
+
title: "테넌트는 `X-Store-Code` 헤더로 보낸다",
|
|
61
|
+
why: "공유 API 호스트에서 스토어를 가르는 값이다. 빠지면 다른 스토어 데이터가 보이거나 404가 난다",
|
|
62
|
+
fix: "`createStorefrontClient`에 `storeCode`를 넘긴다. SDK가 헤더를 붙인다. 서브도메인으로 서비스할 때만 생략한다",
|
|
63
|
+
example: "createStorefrontClient({ baseUrl: API_BASE_URL, storeCode: resolveStoreCode() });",
|
|
64
|
+
doc: "/guides/storefront-sdk",
|
|
65
|
+
templateFile: "src/lib/api.server.ts"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "no-module-token",
|
|
69
|
+
title: "토큰을 모듈 전역에 담지 않는다",
|
|
70
|
+
why: "서버 렌더는 요청을 공유한다. 전역에 담으면 다른 구매자의 요청에 토큰이 섞인다",
|
|
71
|
+
fix: "토큰은 요청마다 읽는다. 서버 함수 안에서 `readToken()`으로 받아 API 클라이언트에 넘기고, 모듈 변수에 저장하지 않는다",
|
|
72
|
+
example: "const load = createServerFn().handler(async () => {\n const api = apiFor({ accessToken: readToken() }); // 요청마다 읽는다\n return api.orders.list();\n});",
|
|
73
|
+
doc: "/guides/storefront-auth",
|
|
74
|
+
templateFile: "src/lib/session.server.ts"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "loader-first",
|
|
78
|
+
title: "데이터는 라우트 loader에서 받는다(서버 함수 `createServerFn`, TanStack Query면 `ensureQueryData`)",
|
|
79
|
+
why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다. TanStack Query의 `useQuery`만 쓰면 서버 렌더에는 데이터가 없다",
|
|
80
|
+
fix: "화면마다 `createFileRoute(...)({ loader })`를 두고 loader가 서버 함수를 부른다. TanStack Query를 쓰면 loader가 `context.queryClient.ensureQueryData(queryOptions)`로 미리 받고 화면은 같은 queryOptions로 `useSuspenseQuery`를 부른다(`setupRouterSsrQueryIntegration`이 캐시를 HTML에 싣는다). `useEffect`에서 처음 데이터를 받지 않는다",
|
|
81
|
+
example: "const load = createServerFn().handler(() => apiFor().catalog.searchProducts({ size: 24 }));\nconst productsQuery = () => queryOptions({ queryKey: [\"products\"], queryFn: () => load() });\nexport const Route = createFileRoute(\"/products/\")({\n loader: ({ context }) => context.queryClient.ensureQueryData(productsQuery()),\n component: Products, // 화면: useSuspenseQuery(productsQuery())\n});",
|
|
82
|
+
doc: "/guides/mcp-customize",
|
|
83
|
+
templateFile: "src/routes/products.index.tsx"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "payment-browser-launch",
|
|
87
|
+
title: "결제창은 브라우저에서 `@sayren/storefront-sdk/payments`로 연다(`start` 또는 `open`)",
|
|
88
|
+
why: "결제창은 결제 서비스 주소를 브라우저 창으로 여는 일이다. 서버 파일에서 부르면 아무것도 열리지 않고 결제가 시작되지 않는다. 결제 시작(`startPayment`)은 서버 함수, 결제창은 브라우저다",
|
|
89
|
+
fix: "결제 모듈 import를 서버 파일에서 지우고 컴포넌트(클릭 핸들러·effect)로 옮긴다. 서버 함수는 `checkout.startPayment`로 결제 시작 값만 만들어 내려주고, 그 값을 브라우저에서 `payments.open()`에 넘긴다",
|
|
90
|
+
example: "// 서버 함수: 결제 시작(토큰은 서버에만)\nconst start = await api.checkout.startPayment(checkoutId, { option, returnUrl });\n\n// 브라우저: 결제창 열기\nconst payments = paymentsFor(config);\nawait payments.open(paymentStartSchema.parse(JSON.parse(start)), { window });",
|
|
91
|
+
doc: "/guides/checkout-flow",
|
|
92
|
+
templateFile: "src/lib/payments.ts"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "payment-popup-prepare",
|
|
96
|
+
title: "결제 시작이 서버 함수를 거치면 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 연다",
|
|
97
|
+
why: "팝업은 사용자 클릭에서 동기로 열어야 차단되지 않는다. 서버 응답을 기다린 뒤 열면 브라우저가 막아 결제가 시작되지 않는다",
|
|
98
|
+
fix: "클릭 핸들러 첫 줄에서 `payments.prepareWindow()`로 빈 창을 열고, 서버 함수가 끝나면 그 핸들을 `payments.open(start, { window })`에 넘긴다. 결제 시작이 실패하면 `window.close()`로 닫는다",
|
|
99
|
+
example: "const paymentWindow = payments.prepareWindow(); // 클릭 시점에 먼저 연다\nconst { start, error } = await startPayment({ data });\nif (!start) {\n paymentWindow.close();\n return;\n}\nawait payments.open(parsed, { window: paymentWindow });",
|
|
100
|
+
doc: "/guides/checkout-flow",
|
|
101
|
+
templateFile: "src/routes/checkout.index.tsx"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "payment-return-route",
|
|
105
|
+
title: "결제 복귀 화면에서 `payments.result()`로 결과를 읽는다",
|
|
106
|
+
why: "모바일·팝업 차단은 리다이렉트 결제다. 결제 서비스가 복귀 주소로 돌려보내므로 복귀 화면이 결제 상태를 확인하지 않으면 결제한 구매자가 결과를 볼 수 없다",
|
|
107
|
+
fix: "복귀 주소의 경로(`/checkout/return`)에 화면을 만들고 effect에서 `payments.result(window.location.href)`를 부른다. 주소의 `sayrenPaymentId`는 지우지 않는다(SDK가 읽는다)",
|
|
108
|
+
example: "useEffect(() => {\n void payments.result(window.location.href).then((result) => {\n if (result.status === \"COMPLETED\") navigate({ to: \"/checkout/complete\", search: { orderId: result.orderId } });\n });\n}, []);",
|
|
109
|
+
doc: "/guides/checkout-flow",
|
|
110
|
+
templateFile: "src/routes/checkout.return.tsx"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "payment-processing-status",
|
|
114
|
+
title: "결과 확인 중(`PROCESSING`)이면 결제 상태를 다시 조회한다",
|
|
115
|
+
why: "결과를 아직 모를 때 실패로 안내하면 구매자가 다시 결제해 이중 결제가 난다. 확정될 때까지 `payments.result()`·`payments.getStatus()`로 확인한다",
|
|
116
|
+
fix: "`PROCESSING`은 실패로 다루지 않는다. 안내 문구를 띄운 채 몇 초 간격으로 다시 조회하고, 그래도 모르면 주문 내역을 확인하라고 안내한다(재결제 버튼을 주지 않는다)",
|
|
117
|
+
example: "if (result.status === \"PROCESSING\") {\n setView({ kind: \"processing\" }); // 실패로 단정하지 않는다\n await new Promise((r) => setTimeout(r, 2000));\n const next = await payments.getStatus(result.paymentId);\n}",
|
|
118
|
+
doc: "/guides/checkout-flow",
|
|
119
|
+
templateFile: "src/routes/checkout.return.tsx"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "payment-amounts-final",
|
|
123
|
+
title: "결제 시작 응답의 `amounts`를 확정 금액으로 반영한다",
|
|
124
|
+
why: "주문서를 만들 때는 배송지를 몰라 제주·도서산간 추가 배송비가 빠져 있다. 결제 시작 응답이 보낸 배송지로 배송비를 다시 계산한 확정 금액이라, 화면에 반영하지 않으면 결제창 금액과 화면 금액이 어긋난다",
|
|
125
|
+
fix: "`payments.open()`에 넘기기 전에 결제 시작 값의 `amounts`·`delivery`로 화면 금액을 갱신한다",
|
|
126
|
+
example: "const paymentStart = paymentStartSchema.parse(JSON.parse(start));\n// 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다\nsetQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });\nawait payments.open(paymentStart, { window: paymentWindow });",
|
|
127
|
+
doc: "/guides/shipping-fees",
|
|
128
|
+
templateFile: "src/routes/checkout.index.tsx"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "payment-retry",
|
|
132
|
+
title: "결제가 실패·취소되면 같은 결제를 다른 결제 옵션으로 다시 시도하게 한다(`payments.retry`)",
|
|
133
|
+
why: "카드 거절·결제창 닫기 뒤에 주문서를 처음부터 다시 쓰게 하면 구매자가 이탈한다. 같은 결제에서 다른 결제사·결제수단으로 바꾸는 길이 있어야 한다",
|
|
134
|
+
fix: "복귀 화면의 실패·취소 결과(`CANCELED`·`FAILED`)에서 결제 옵션 목록을 보여 주고, 누르면 클릭 핸들러에서 `payments.retry(result.paymentId, { option })`를 부른다. 옵션은 주문서가 결제창을 열기 전에 결제 id별로 남겨 둔다",
|
|
135
|
+
example: "if (result.status === \"FAILED\" || result.status === \"CANCELED\") {\n // 버튼 클릭 핸들러에서 바로 부른다 — 팝업은 클릭 시점에 열어야 한다\n await payments.retry(result.paymentId, { option });\n}",
|
|
136
|
+
doc: "/guides/checkout-flow",
|
|
137
|
+
templateFile: "src/routes/checkout.return.tsx"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "checkout-origin",
|
|
141
|
+
title: "복귀 주소는 코드에 고정하지 않고 배포된 주소(요청 origin)에서 만든다",
|
|
142
|
+
why: "주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 제대로 돌아온다. 셀러 콘솔 설정 › 결제의 결제 도메인은 선택형 허용 목록이다. 비워 두면 https 복귀 주소를 모두 받고, 등록하면 목록 밖의 복귀 주소를 서버가 거절한다",
|
|
143
|
+
fix: "서버 함수에서 `getRequestUrl()`의 origin으로 복귀 주소를 만든다. 결제 도메인을 등록해 두었다면 배포 도메인도 목록에 넣는다(테스트 결제는 localhost가 항상 허용된다)",
|
|
144
|
+
example: "const returnUrl = new URL(PAYMENT_RETURN_PATH, new URL(getRequestUrl()).origin);\nawait api.checkout.startPayment(checkoutId, { option, returnUrl: returnUrl.toString() });",
|
|
145
|
+
doc: "/guides/payment-settings",
|
|
146
|
+
templateFile: "src/routes/checkout.index.tsx"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "delivery-quote-preview",
|
|
150
|
+
title: "우편번호를 받으면 `checkout.quoteDelivery`로 배송비를 다시 계산해 보여 준다",
|
|
151
|
+
why: "주문서의 배송비는 배송지를 모르는 값이다. 미리 보여 주지 않으면 결제 버튼의 금액과 결제창 금액이 달라져 구매자가 결제를 중단한다",
|
|
152
|
+
fix: "우편번호 입력이 끝나면(blur) 서버 함수로 `checkout.quoteDelivery(checkoutId, { zipCode })`를 부르고 응답의 `amounts`·`delivery`로 화면 금액을 갱신한다. 주문서를 바꾸지 않는 읽기 계산이다",
|
|
153
|
+
example: "const quote = await api.checkout.quoteDelivery(checkoutId, { zipCode });\nsetQuote(quote); // amounts.totalAmount · delivery.remoteSurcharge를 화면에 쓴다",
|
|
154
|
+
doc: "/guides/shipping-fees",
|
|
155
|
+
templateFile: "src/routes/checkout.index.tsx"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "buyer-token-cookie",
|
|
159
|
+
title: "구매자 토큰 쌍은 서버에서 HttpOnly 쿠키에 담는다",
|
|
160
|
+
why: "브라우저 JS나 `localStorage`에 둔 토큰은 스크립트 하나로 새 나간다. 리프레시 토큰이 새면 그 구매자 계정으로 계속 로그인된다",
|
|
161
|
+
fix: "로그인·가입·소셜 콜백을 서버 함수·서버 라우트에서 처리하고 받은 토큰 쌍을 `httpOnly` 쿠키 하나에 담는다. 브라우저 저장소에 토큰을 쓰지 않고, 인증 모듈(`@sayren/storefront-sdk/auth`)은 `*.server.ts`에서만 만든다. 프로덕션에서는 쿠키 이름에 `__Host-` 접두사를 붙이고 `secure`·`path: \"/\"`로 쓴다(`Domain`은 주지 않는다). 같은 상위 도메인의 다른 사이트가 심은 쿠키로 세션이 바뀌지 않는다",
|
|
162
|
+
example: "// 프로덕션은 __Host- 접두사 — Secure·Path=/·Domain 없음일 때만 브라우저가 받는다\nconst name = import.meta.env.PROD ? \"__Host-sayren_member\" : \"sayren_member\";\nsetCookie(name, JSON.stringify({ accessToken, refreshToken, expiresAt }), {\n httpOnly: true,\n sameSite: \"lax\",\n path: \"/\",\n secure: import.meta.env.PROD,\n});",
|
|
163
|
+
doc: "/guides/storefront-auth",
|
|
164
|
+
templateFile: "src/lib/cookies.server.ts"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "buyer-session-refresh",
|
|
168
|
+
title: "액세스 토큰은 만료 전에 리프레시 토큰으로 갱신한다",
|
|
169
|
+
why: "액세스 토큰은 30분이다. 갱신하지 않으면 구매자가 장바구니·주문서에서 갑자기 로그아웃된다. 리프레시 토큰은 한 번 쓰면 폐기되므로 같은 토큰으로 동시에 갱신하면 늦은 쪽이 401을 받는다",
|
|
170
|
+
fix: "전역 요청 미들웨어에서 요청마다 만료를 보고 만료 1분 전이면 `auth.refresh(refreshToken)`로 새 쌍을 받아 쿠키를 갈아 끼운다. 같은 리프레시 토큰의 동시 갱신은 프로세스 안에서 한 번으로 모은다. 401이면 쿠키를 지우고, 네트워크 오류·5xx면 지우지 않는다",
|
|
171
|
+
example: "if (stored.expiresAt - 60_000 <= Date.now()) {\n const tokens = await refreshOnce(stored.refreshToken); // 같은 토큰은 한 번만\n writeSession(tokens);\n}",
|
|
172
|
+
doc: "/guides/storefront-auth",
|
|
173
|
+
templateFile: "src/lib/session.server.ts"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "buyer-reconsent",
|
|
177
|
+
title: "`reconsentRequired`가 true면 재동의를 받는다",
|
|
178
|
+
why: "약관·개인정보 처리방침이 개정되면 이전 동의는 최신본 동의가 아니다. 화면이 재동의를 받지 않으면 동의 이력이 최신본으로 남지 않는다",
|
|
179
|
+
fix: "`member.consents()`(또는 `member.me()`)의 `reconsentRequired`를 읽어 true면 개정된 문서 링크와 재동의 버튼을 보여 주고, 누르면 `member.updateConsents({ terms: true, privacy: true })`를 부른다",
|
|
180
|
+
example: "const consents = await api.member.consents();\nif (consents.reconsentRequired) {\n // 개정 문서 링크(currentDocumentUrl)를 보여 주고 동의를 받는다\n await api.member.updateConsents({ terms: true, privacy: true });\n}",
|
|
181
|
+
doc: "/guides/storefront-auth",
|
|
182
|
+
templateFile: "src/routes/account.tsx"
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "buyer-signup",
|
|
186
|
+
title: "이메일·비밀번호 로그인을 받으면 가입 화면도 둔다(`auth.signUp`)",
|
|
187
|
+
why: "비밀번호 로그인만 있고 가입이 없으면 소셜 로그인을 켜지 않은 스토어에서 새 구매자가 계정을 만들 수 없다",
|
|
188
|
+
fix: "스토어 `loginMethods`에 `password`가 있으면 가입 화면을 둔다. 약관·개인정보 수집이용 동의(필수)와 마케팅 수신(선택)을 받아 서버 함수에서 `auth.signUp({ email, password, name, agreements, cartToken })`을 부르고, 받은 토큰 쌍을 HttpOnly 쿠키에 담는다",
|
|
189
|
+
example: "const tokens = await authFor().signUp({\n email, password, name,\n agreements: { terms: true, privacy: true, marketing },\n cartToken: readCartToken() ?? undefined, // 비회원 장바구니를 합친다\n});\nsignIn(tokens);",
|
|
190
|
+
doc: "/guides/storefront-auth",
|
|
191
|
+
templateFile: "src/routes/signup.tsx"
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "buyer-signout",
|
|
195
|
+
title: "로그아웃은 서버에서 리프레시 토큰을 폐기하고 쿠키를 지운다(`auth.signOut`)",
|
|
196
|
+
why: "쿠키만 지우면 새 나간 리프레시 토큰이 14일 동안 계속 살아 있다. 로그아웃 화면이 없으면 공용 기기에서 계정을 닫을 방법이 없다",
|
|
197
|
+
fix: "계정 화면에 로그아웃을 두고, 서버 함수에서 `auth.signOut(accessToken)`을 부른 뒤 세션 쿠키를 지운다. 탈퇴(`auth.withdraw`)도 같은 화면에 둔다",
|
|
198
|
+
example: "const logout = createServerFn({ method: \"POST\" }).handler(async () => {\n const accessToken = readToken();\n if (accessToken) await authFor().signOut(accessToken).catch(() => undefined);\n clearSessionCookie();\n});",
|
|
199
|
+
doc: "/guides/storefront-auth",
|
|
200
|
+
templateFile: "src/routes/account.tsx"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "analytics-start",
|
|
204
|
+
title: "방문 분석은 브라우저에서 한 번 시작한다(`@sayren/storefront-sdk/analytics`)",
|
|
205
|
+
why: "빠지면 셀러 콘솔 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다",
|
|
206
|
+
fix: "루트 컴포넌트의 effect에서 `createAnalytics`를 한 번 부르고(설정은 loader가 내려준다), 화면은 그 인스턴스의 `track`으로 행동을 남긴다. 장바구니·결제 시작·구매는 서버가 기록하므로 보내지 않는다",
|
|
207
|
+
example: "import { createAnalytics } from \"@sayren/storefront-sdk/analytics\";\n\nuseEffect(() => {\n startAnalytics({ apiBaseUrl, storeCode, debug }); // 브라우저에서 한 번\n}, []);",
|
|
208
|
+
doc: "/guides/storefront-analytics",
|
|
209
|
+
templateFile: "src/lib/analytics.ts"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: "analytics-ids",
|
|
213
|
+
title: "서버의 API 클라이언트에 방문 식별 쿠키를 싣는다(`analyticsIdsFromCookie`)",
|
|
214
|
+
why: "빠지면 장바구니·결제 시작·구매가 방문과 이어지지 않아 퍼널과 유입별 매출에서 빠진다",
|
|
215
|
+
fix: "서버 함수에서 API 클라이언트를 만들 때 요청 쿠키를 `analyticsIdsFromCookie`로 풀어 함께 넘긴다",
|
|
216
|
+
example: "createStorefrontClient({\n baseUrl: API_BASE_URL,\n storeCode: resolveStoreCode(),\n ...analyticsIdsFromCookie(getRequestHeader(\"cookie\")),\n});",
|
|
217
|
+
doc: "/guides/storefront-analytics",
|
|
218
|
+
templateFile: "src/lib/api.server.ts"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "custom-input-private",
|
|
222
|
+
title: "직접 입력값은 서버 함수 본문으로만 보낸다(URL·브라우저 저장소·로그 금지)",
|
|
223
|
+
why: "각인 문구·선물 메시지 같은 직접 입력값에는 이름·연락처 같은 개인정보가 섞인다. URL 검색 파라미터에 실으면 방문 기록·서버 접근 로그·리퍼러로 새고, 브라우저 저장소에 두면 같은 기기의 다른 사람이 읽는다",
|
|
224
|
+
fix: "담기·바로구매는 서버 함수 본문으로 `customInputs`를 보낸다. 바로구매처럼 주문서 진입 조건을 들고 다녀야 하면 URL에는 표시(`direct=1`)만 두고 값은 서버가 짧게 사는 HttpOnly 쿠키에 담은 뒤 주문서를 만들면 지운다. `localStorage`·`sessionStorage`·`console.log`에 입력값을 쓰지 않는다",
|
|
225
|
+
example: "// 서버 함수 본문으로 보낸다\nawait addToCart({ data: { productId, optionId, quantity, addons, customInputs } });\n\n// 바로구매 — 값은 HttpOnly 쿠키, 주소에는 표시만\nwriteDirectLine(line); // 서버 함수 안\nnavigate({ to: \"/checkout\", search: { direct: 1, productId } });",
|
|
226
|
+
doc: "/guides/product-options",
|
|
227
|
+
templateFile: "src/lib/direct-checkout.server.ts"
|
|
228
|
+
}
|
|
229
|
+
];
|
|
230
|
+
/**
|
|
231
|
+
* 주석을 걷어낸다 — 규칙을 설명하는 주석이 위반으로 잡히지 않게 한다.
|
|
232
|
+
* 줄 번호를 알려 주려면 줄 수가 보존돼야 해서 블록 주석은 개행만 남긴다.
|
|
233
|
+
*/
|
|
234
|
+
function stripComments(source) {
|
|
235
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
|
|
236
|
+
}
|
|
237
|
+
/** 패턴이 처음 걸린 줄 번호(1부터). 없으면 undefined */
|
|
238
|
+
function lineOf(content, pattern) {
|
|
239
|
+
const match = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")).exec(content);
|
|
240
|
+
if (!match) return void 0;
|
|
241
|
+
return content.slice(0, match.index).split("\n").length;
|
|
242
|
+
}
|
|
243
|
+
const CLIENT_FILE = /\.(ts|tsx)$/;
|
|
244
|
+
const SERVER_FILE = /\.server\.(ts|tsx)$/;
|
|
245
|
+
/** 토큰을 브라우저 저장소에 쓰는 코드 */
|
|
246
|
+
const BROWSER_TOKEN_STORAGE = /(localStorage|sessionStorage)\.setItem\(\s*[`'"][^`'"]*([Tt]oken|refresh|session|sayren_member)/;
|
|
247
|
+
/**
|
|
248
|
+
* 직접 입력값이 새는 자리 — URL 검색 파라미터(`search`·`searchParams`), 브라우저 저장소, 콘솔.
|
|
249
|
+
* 같은 식 안에서 `customInputs`가 보일 때만 잡는다.
|
|
250
|
+
*/
|
|
251
|
+
const CUSTOM_INPUT_LEAKS = [
|
|
252
|
+
[/search\s*[:=]\s*\{\{?[^}]*\bcustomInputs\b/, "URL 검색 파라미터"],
|
|
253
|
+
[/searchParams\.(set|append)\([^)]*\bcustomInputs\b/, "URL 검색 파라미터"],
|
|
254
|
+
[/(localStorage|sessionStorage)\.setItem\([^)]*\bcustomInputs\b/, "브라우저 저장소"],
|
|
255
|
+
[/console\.(log|info|warn|error|debug)\([^)]*\bcustomInputs\b/, "콘솔 로그"]
|
|
256
|
+
];
|
|
257
|
+
/**
|
|
258
|
+
* 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
|
|
259
|
+
* 여기 있는 것들은 실제로 반복해서 깨지는 것들이다.
|
|
260
|
+
*/
|
|
261
|
+
function verifySources(files) {
|
|
262
|
+
const findings = [];
|
|
263
|
+
const cleaned = files.map((file) => ({
|
|
264
|
+
path: file.path,
|
|
265
|
+
content: stripComments(file.content)
|
|
266
|
+
}));
|
|
267
|
+
const has = (needle) => cleaned.some((file) => file.content.includes(needle));
|
|
268
|
+
const matches = (pattern) => cleaned.find((file) => pattern.test(file.content));
|
|
269
|
+
/** 파일 한 곳의 위반. `at`이 있으면 그 패턴이 걸린 줄을 싣는다 */
|
|
270
|
+
const inFile = (ruleId, file, detail, at) => {
|
|
271
|
+
findings.push({
|
|
272
|
+
ruleId,
|
|
273
|
+
file: file.path,
|
|
274
|
+
line: at ? lineOf(file.content, at) : void 0,
|
|
275
|
+
detail
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
/** 프로젝트 전체를 보고 판정한 위반 — 고칠 자리는 규칙의 `templateFile`이 알려 준다 */
|
|
279
|
+
const inProject = (ruleId, detail) => {
|
|
280
|
+
findings.push({
|
|
281
|
+
ruleId,
|
|
282
|
+
file: "(프로젝트 전체)",
|
|
283
|
+
detail
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
for (const file of cleaned) {
|
|
287
|
+
if (!CLIENT_FILE.test(file.path)) continue;
|
|
288
|
+
const isServer = SERVER_FILE.test(file.path) || /\/(server|\.server)\//.test(file.path);
|
|
289
|
+
const env = /process\.env\.[A-Z_]/;
|
|
290
|
+
if (!isServer && env.test(file.content)) inFile("server-only-env", file, "브라우저로 갈 수 있는 파일에서 process.env를 읽는다. `*.server.ts`로 옮긴다", env);
|
|
291
|
+
if (/createStorefrontClient\(/.test(file.content) && !/storeCode/.test(file.content)) inFile("tenant-header", file, "API 클라이언트를 만들 때 storeCode를 주지 않는다", /createStorefrontClient\(/);
|
|
292
|
+
const moduleToken = /^(const|let)\s+\w*[Tt]oken\s*=\s*["'`]|^let\s+[\w$]*[Tt]oken\b/m;
|
|
293
|
+
if (moduleToken.test(file.content)) inFile("no-module-token", file, "토큰처럼 보이는 값을 모듈 전역에 두었다", moduleToken);
|
|
294
|
+
const paymentsImport = /@sayren\/storefront-sdk\/payments/;
|
|
295
|
+
if (isServer && paymentsImport.test(file.content)) inFile("payment-browser-launch", file, "서버 파일에서 결제 모듈을 쓴다. 결제창(`start`·`open`)과 복귀 처리(`result`)는 브라우저(컴포넌트·effect)에서 부른다", paymentsImport);
|
|
296
|
+
const authFactory = /createStorefrontAuth\(/;
|
|
297
|
+
if (!isServer && authFactory.test(file.content)) inFile("buyer-token-cookie", file, "브라우저로 갈 수 있는 파일에서 인증 모듈을 만든다. 토큰 쌍이 브라우저 JS에 노출된다 — `*.server.ts`로 옮기고 HttpOnly 쿠키에 담는다", authFactory);
|
|
298
|
+
if (BROWSER_TOKEN_STORAGE.test(file.content)) inFile("buyer-token-cookie", file, "토큰을 브라우저 저장소(localStorage·sessionStorage)에 쓴다. 서버에서 HttpOnly 쿠키에 담는다", BROWSER_TOKEN_STORAGE);
|
|
299
|
+
const cookieWrite = /setCookie\(/;
|
|
300
|
+
if (cookieWrite.test(file.content) && /refreshToken/.test(file.content) && !/httpOnly/.test(file.content)) inFile("buyer-token-cookie", file, "토큰 쌍을 쿠키에 담는데 `httpOnly`가 없다. 브라우저 JS가 읽을 수 있다", cookieWrite);
|
|
301
|
+
const cookieDomain = /\bdomain\s*:/;
|
|
302
|
+
if (cookieWrite.test(file.content) && cookieDomain.test(file.content)) inFile("buyer-token-cookie", file, "서버 쿠키에 `domain`을 준다. 호스트 전용 쿠키로 두고(`domain` 생략) 프로덕션은 `__Host-` 접두사를 붙인다", cookieDomain);
|
|
303
|
+
for (const [pattern, where] of CUSTOM_INPUT_LEAKS) if (pattern.test(file.content)) {
|
|
304
|
+
inFile("custom-input-private", file, `직접 입력값(\`customInputs\`)을 ${where}에 싣는다. 서버 함수 본문으로 보내고, 바로구매는 HttpOnly 쿠키에 둔다`, pattern);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
const analyticsFactory = /createAnalytics\(/;
|
|
308
|
+
if (isServer && analyticsFactory.test(file.content)) inFile("analytics-start", file, "서버 파일에서 방문 분석을 시작한다. 서버에서는 아무것도 하지 않으니 브라우저(effect)에서 부른다", analyticsFactory);
|
|
309
|
+
if (isServer && /createStorefrontClient\(/.test(file.content) && !/analyticsIdsFromCookie|visitorId/.test(file.content)) inFile("analytics-ids", file, "서버 API 클라이언트에 방문 식별자가 없다. `...analyticsIdsFromCookie(request.headers.get(\"cookie\"))`를 넘긴다", /createStorefrontClient\(/);
|
|
310
|
+
}
|
|
311
|
+
if (has("startPayment(") && !has("@sayren/storefront-sdk/payments")) inProject("payment-browser-launch", "결제를 시작하지만 `@sayren/storefront-sdk/payments`로 결제창을 여는 코드가 없다");
|
|
312
|
+
const opensPayment = matches(/\bpayments\.open\(|\.open\(\s*(start|paymentStart)\b/);
|
|
313
|
+
if (opensPayment && !has("prepareWindow(")) inFile("payment-popup-prepare", opensPayment, "받아 둔 결제 시작 값으로 결제창을 열지만 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 여는 코드가 없다", /\bpayments\.open\(|\.open\(\s*(start|paymentStart)\b/);
|
|
314
|
+
if (opensPayment && !/amounts/.test(opensPayment.content)) inFile("payment-amounts-final", opensPayment, "결제창을 열지만 결제 시작 응답의 `amounts`(확정 금액)를 화면에 반영하지 않는다", /\bpayments\.open\(/);
|
|
315
|
+
const fixedReturnUrl = matches(/returnUrl\s*[:=]\s*["'`]https?:\/\//);
|
|
316
|
+
if (fixedReturnUrl) inFile("checkout-origin", fixedReturnUrl, "복귀 주소를 고정 문자열로 두었다. 배포된 주소(요청 origin)에서 만든다", /returnUrl\s*[:=]\s*["'`]https?:\/\//);
|
|
317
|
+
if (has("startPayment(") && !has(".result(")) inProject("payment-return-route", "결제 복귀 화면에서 `payments.result()`를 부르는 코드가 없다");
|
|
318
|
+
if (has(".result(") && !matches(/\bpayments\.retry\(|\.retry\(\s*[\w.]*paymentId\b/)) inProject("payment-retry", "결제 복귀 화면에서 실패·취소 뒤 `payments.retry()`로 다른 결제 옵션을 다시 시도하는 코드가 없다");
|
|
319
|
+
if (has(".result(") && !has("PROCESSING")) inProject("payment-processing-status", "결과 확인 중(`PROCESSING`)일 때 결제 상태를 다시 조회하는 코드가 없다");
|
|
320
|
+
if (has("startPayment(") && has("zipCode") && !has("quoteDelivery(")) inProject("delivery-quote-preview", "배송지 우편번호를 받지만 `checkout.quoteDelivery`로 배송비를 다시 계산하는 코드가 없다");
|
|
321
|
+
if (has("createStorefrontAuth(")) {
|
|
322
|
+
const cookieFiles = cleaned.filter((file) => /setCookie\(/.test(file.content));
|
|
323
|
+
if (cookieFiles.length > 0 && !cookieFiles.some((file) => /httpOnly\s*:\s*true/.test(file.content))) inProject("buyer-token-cookie", "구매자 로그인을 쓰는데 서버 쿠키를 `httpOnly: true`로 쓰는 코드가 없다. 토큰 쌍이 브라우저 JS에 노출된다");
|
|
324
|
+
if (!has(".refresh(")) inProject("buyer-session-refresh", "로그인은 하지만 액세스 토큰을 갱신하는 코드가 없다(`auth.refresh(refreshToken)`)");
|
|
325
|
+
if (has(".session(") && !has(".signUp(")) inProject("buyer-signup", "이메일·비밀번호 로그인(`auth.session`)은 있지만 가입(`auth.signUp`)을 부르는 코드가 없다");
|
|
326
|
+
if (!matches(/\b(auth|authFor\(\))\.signOut\(|\.signOut\(\s*accessToken\b/)) inProject("buyer-signout", "로그아웃에서 리프레시 토큰을 폐기하는 코드가 없다(`auth.signOut(accessToken)`)");
|
|
327
|
+
if (!has("reconsentRequired")) inProject("buyer-reconsent", "재동의가 필요한 구매자를 처리하는 코드가 없다(`reconsentRequired`)");
|
|
328
|
+
}
|
|
329
|
+
if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) inProject("analytics-start", "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다");
|
|
330
|
+
const clientQuery = matches(/\buse(Suspense)?Query\(/);
|
|
331
|
+
if (clientQuery && !has("ensureQueryData(") && !has("prefetchQuery(")) inFile("loader-first", clientQuery, "TanStack Query로 데이터를 받지만 loader에서 `queryClient.ensureQueryData`로 미리 받는 코드가 없다. 서버 렌더에 데이터가 빠진다", /\buse(Suspense)?Query\(/);
|
|
332
|
+
if (has("createStorefrontClient(") && !has("loader")) inProject("loader-first", "loader에서 데이터를 받는 코드가 없다. 클라이언트 전용으로 그리면 SSR이 죽는다");
|
|
333
|
+
return findings;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region ../mcp/src/verify-report.ts
|
|
338
|
+
const ruleOf = (ruleId) => RULES.find((rule) => rule.id === ruleId);
|
|
339
|
+
function report(finding) {
|
|
340
|
+
const rule = ruleOf(finding.ruleId);
|
|
341
|
+
return {
|
|
342
|
+
ruleId: finding.ruleId,
|
|
343
|
+
rule: rule?.title ?? finding.ruleId,
|
|
344
|
+
file: finding.file,
|
|
345
|
+
...finding.line === void 0 ? {} : { line: finding.line },
|
|
346
|
+
detail: finding.detail,
|
|
347
|
+
cause: rule?.why ?? "",
|
|
348
|
+
fix: rule?.fix ?? "",
|
|
349
|
+
example: rule?.example ?? "",
|
|
350
|
+
doc: rule?.doc ?? "",
|
|
351
|
+
docUrl: rule ? docUrl(rule.doc) : "",
|
|
352
|
+
...rule?.templateFile ? { templateFile: rule.templateFile } : {}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** 위반을 고치는 순서 — 에이전트가 무엇부터 할지 묻지 않게 한다 */
|
|
356
|
+
function nextSteps(findings, target) {
|
|
357
|
+
if (!findings.length) return target === "remote" ? [
|
|
358
|
+
"사용자에게 콘솔 스토어프론트 공간의 [미리보기 열기]로 화면을 확인해 달라고 전한다.",
|
|
359
|
+
"`create_version`으로 버전을 만들고 `get_build`로 빌드 결과를 확인한다.",
|
|
360
|
+
"정적 검사가 잡지 못하는 것은 실제 결제 한 번이 잡는다. 발행 전에 테스트 결제를 권한다."
|
|
361
|
+
] : [
|
|
362
|
+
"개발 서버를 띄워 홈에 상품이 보이는지 확인한다.",
|
|
363
|
+
"테스트 결제로 주문을 한 번 만들어 주문 완료 화면까지 간다.",
|
|
364
|
+
"정적 검사가 잡지 못하는 것은 실제 결제 한 번이 잡는다."
|
|
365
|
+
];
|
|
366
|
+
const files = [...new Set(findings.map((finding) => finding.templateFile).filter(Boolean))];
|
|
367
|
+
const templateHint = target === "remote" ? `손대지 않아야 하는 파일을 고친 결과일 수 있다 — ${files.join(", ")}을(를) \`read_file\`로 읽어 규칙의 \`example\`과 대조하고 되돌린다.` : `손대지 않아야 하는 파일을 고친 결과일 수 있다 — \`get_template_file\`로 ${files.join(", ")}의 원본을 받아 대조한다.`;
|
|
368
|
+
return [
|
|
369
|
+
"위반마다 `fix`대로 고친다. `file`·`line`이 고칠 자리이고 `example`이 옳은 쪽 코드다.",
|
|
370
|
+
...files.length ? [templateHint] : [],
|
|
371
|
+
target === "remote" ? "고친 뒤 `verify_site`를 다시 부른다. 위반이 남으면 버전 빌드가 규칙 단계(`VERIFY`)에서 실패한다." : "고친 뒤 `verify_storefront`를 다시 부른다.",
|
|
372
|
+
"규칙 배경이 더 필요하면 위반의 `docUrl` 문서를 읽는다."
|
|
373
|
+
];
|
|
374
|
+
}
|
|
375
|
+
/** 소스를 규칙과 대조해 고칠 수 있는 보고서로 만든다 */
|
|
376
|
+
function buildReport(files, target = "local") {
|
|
377
|
+
return reportFromFindings(verifySources(files), files.length, target);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* 이미 찾은 위반으로 보고서를 만든다. 원격은 api가 작업 트리에서 `verifySources`를 돌려 위반만 돌려주고, 원인·수정
|
|
381
|
+
* 방법·예시는 이 패키지의 규칙 정의로 채운다.
|
|
382
|
+
*/
|
|
383
|
+
function reportFromFindings(findings, checkedFiles, target = "local") {
|
|
384
|
+
const counts = /* @__PURE__ */ new Map();
|
|
385
|
+
for (const finding of findings) counts.set(finding.ruleId, (counts.get(finding.ruleId) ?? 0) + 1);
|
|
386
|
+
const results = RULES.map((rule) => ({
|
|
387
|
+
ruleId: rule.id,
|
|
388
|
+
status: counts.has(rule.id) ? "violation" : "pass",
|
|
389
|
+
count: counts.get(rule.id) ?? 0
|
|
390
|
+
}));
|
|
391
|
+
const violated = results.filter((result) => result.status === "violation").length;
|
|
392
|
+
const reported = findings.map(report);
|
|
393
|
+
return {
|
|
394
|
+
ok: findings.length === 0,
|
|
395
|
+
checkedFiles,
|
|
396
|
+
summary: {
|
|
397
|
+
rules: RULES.length,
|
|
398
|
+
passed: RULES.length - violated,
|
|
399
|
+
violated,
|
|
400
|
+
findings: findings.length
|
|
401
|
+
},
|
|
402
|
+
results,
|
|
403
|
+
findings: reported,
|
|
404
|
+
message: findings.length ? `규칙 ${RULES.length}개 중 ${violated}개를 어겼습니다(위반 ${findings.length}건). 아래 findings의 fix대로 고친 뒤 다시 검사해주십시오` : `규칙 ${RULES.length}개를 모두 지켰습니다. 정적 검사가 잡지 못하는 것이 있으니 직접 띄워 결제까지 한 번 해보십시오`,
|
|
405
|
+
nextSteps: nextSteps(reported, target)
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region ../hosting-sandbox/src/hash.ts
|
|
411
|
+
const strictUtf8 = new TextDecoder("utf-8", {
|
|
412
|
+
fatal: true,
|
|
413
|
+
ignoreBOM: true
|
|
414
|
+
});
|
|
415
|
+
/** 유효한 UTF-8이면 문자열, 아니면 null */
|
|
416
|
+
function asUtf8(bytes) {
|
|
417
|
+
try {
|
|
418
|
+
return strictUtf8.decode(bytes);
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region ../hosting-sandbox/src/verify.ts
|
|
426
|
+
/**
|
|
427
|
+
* 규칙 검증. 원천은 `@sayren/mcp`의 규칙(apps/mcp/src/rules.ts)과 보고서(verify-report.ts)다 — stdio `verify_storefront`와
|
|
428
|
+
* 같은 함수를 Worker가 직접 돌린다(컨테이너가 필요 없다). 대상은 stdio와 같은 소스 확장자다.
|
|
429
|
+
*/
|
|
430
|
+
const SOURCE_RE = /\.(ts|tsx|js|jsx)$/;
|
|
431
|
+
function verifyTree(files) {
|
|
432
|
+
return buildReport([...files].filter(([path]) => SOURCE_RE.test(path)).sort(([a], [b]) => a < b ? -1 : 1).flatMap(([path, bytes]) => {
|
|
433
|
+
const content = asUtf8(bytes);
|
|
434
|
+
return content === null ? [] : [{
|
|
435
|
+
path,
|
|
436
|
+
content
|
|
437
|
+
}];
|
|
438
|
+
}), "remote");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region ../hosting-sandbox/src/paths.ts
|
|
443
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
444
|
+
const MAX_TREE_BYTES = 20 * 1024 * 1024;
|
|
445
|
+
const MAX_TREE_FILES = 2e3;
|
|
446
|
+
const MAX_PATH_LENGTH = 256;
|
|
447
|
+
const MAX_DEPTH = 16;
|
|
448
|
+
const SEGMENT_RE = /^[A-Za-z0-9$_.()[\]@+=,-]+$/;
|
|
449
|
+
/** 어디에 있든 금지 — 설치물·산출물·캐시·VCS */
|
|
450
|
+
const FORBIDDEN_SEGMENTS = new Set([
|
|
451
|
+
"node_modules",
|
|
452
|
+
"dist",
|
|
453
|
+
".tanstack",
|
|
454
|
+
".git",
|
|
455
|
+
".wrangler",
|
|
456
|
+
".output",
|
|
457
|
+
".nitro",
|
|
458
|
+
".vinxi"
|
|
459
|
+
]);
|
|
460
|
+
/** 쓸 수 있는 루트 파일 */
|
|
461
|
+
const WRITABLE_ROOT_FILES = new Set([
|
|
462
|
+
"README.md",
|
|
463
|
+
"tsconfig.json",
|
|
464
|
+
"vite.config.ts",
|
|
465
|
+
"vitest.config.ts",
|
|
466
|
+
"biome.json",
|
|
467
|
+
".gitignore"
|
|
468
|
+
]);
|
|
469
|
+
/** 읽기만 되는 루트 파일 — 의존성은 전용 경로로만 바꾼다(레지스트리 프록시와 잠금 파일을 함께 맞춘다) */
|
|
470
|
+
const READONLY_ROOT_FILES = new Set(["package.json", "pnpm-lock.yaml"]);
|
|
471
|
+
const WRITABLE_DIRS = new Set(["src", "public"]);
|
|
472
|
+
/** 형식·금지 세그먼트만 본다(위치 규칙은 보지 않는다). 통과하면 null, 아니면 이유 */
|
|
473
|
+
function formatProblem(path) {
|
|
474
|
+
if (typeof path !== "string" || path.length === 0) return "empty";
|
|
475
|
+
if (path.length > MAX_PATH_LENGTH) return "too-long";
|
|
476
|
+
if (path.startsWith("/")) return "absolute";
|
|
477
|
+
const segments = path.split("/");
|
|
478
|
+
if (segments.length > MAX_DEPTH) return "too-deep";
|
|
479
|
+
for (const seg of segments) {
|
|
480
|
+
if (seg === "" || seg === "." || seg === "..") return "dot-segment";
|
|
481
|
+
if (!SEGMENT_RE.test(seg)) return "characters";
|
|
482
|
+
if (FORBIDDEN_SEGMENTS.has(seg)) return "forbidden-segment";
|
|
483
|
+
if (seg.startsWith(".env")) return "env-file";
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
/** 위치 규칙: 어떤 모드로 이 경로에 닿을 수 있는가 */
|
|
488
|
+
function accessOf(path) {
|
|
489
|
+
if (formatProblem(path) !== null) return null;
|
|
490
|
+
const segments = path.split("/");
|
|
491
|
+
const [first] = segments;
|
|
492
|
+
if (!first) return null;
|
|
493
|
+
if (segments.length === 1) {
|
|
494
|
+
if (WRITABLE_ROOT_FILES.has(first)) return "write";
|
|
495
|
+
if (READONLY_ROOT_FILES.has(first)) return "read";
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
if (!WRITABLE_DIRS.has(first)) return null;
|
|
499
|
+
if (segments.some((s) => s.startsWith("."))) return null;
|
|
500
|
+
return "write";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region ../hosting-sandbox/src/tar.ts
|
|
505
|
+
const BLOCK = 512;
|
|
506
|
+
const enc = new TextEncoder();
|
|
507
|
+
const dec = new TextDecoder();
|
|
508
|
+
function readString(buf, offset, length) {
|
|
509
|
+
let end = offset;
|
|
510
|
+
while (end < offset + length && buf[end] !== 0) end++;
|
|
511
|
+
return dec.decode(buf.subarray(offset, end));
|
|
512
|
+
}
|
|
513
|
+
function readOctal(buf, offset, length) {
|
|
514
|
+
const s = readString(buf, offset, length).trim();
|
|
515
|
+
if (!s) return 0;
|
|
516
|
+
if (!/^[0-7]+$/.test(s)) throw new Error("tar 숫자 필드 형식 오류");
|
|
517
|
+
return Number.parseInt(s, 8);
|
|
518
|
+
}
|
|
519
|
+
function parsePax(text) {
|
|
520
|
+
const out = {};
|
|
521
|
+
let i = 0;
|
|
522
|
+
while (i < text.length) {
|
|
523
|
+
const sp = text.indexOf(" ", i);
|
|
524
|
+
if (sp < 0) break;
|
|
525
|
+
const len = Number(text.slice(i, sp));
|
|
526
|
+
if (!Number.isInteger(len) || len <= 0) break;
|
|
527
|
+
const record = text.slice(sp + 1, i + len - 1);
|
|
528
|
+
const eq = record.indexOf("=");
|
|
529
|
+
if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
|
|
530
|
+
i += len;
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
/** tar 바이트 → 일반 파일 항목. `maxBytes`는 풀어낸 파일 합계 상한(압축 폭탄 방어) */
|
|
535
|
+
function readTar(buf, maxBytes = Number.POSITIVE_INFINITY) {
|
|
536
|
+
const out = [];
|
|
537
|
+
let off = 0;
|
|
538
|
+
let longName = null;
|
|
539
|
+
let paxPath = null;
|
|
540
|
+
let total = 0;
|
|
541
|
+
while (off + BLOCK <= buf.length) {
|
|
542
|
+
const h = buf.subarray(off, off + BLOCK);
|
|
543
|
+
if (h.every((b) => b === 0)) break;
|
|
544
|
+
const size = readOctal(h, 124, 12);
|
|
545
|
+
const type = String.fromCharCode(h[156] ?? 0);
|
|
546
|
+
const dataStart = off + BLOCK;
|
|
547
|
+
const dataEnd = dataStart + size;
|
|
548
|
+
if (dataEnd > buf.length) throw new Error("tar가 잘렸습니다");
|
|
549
|
+
const data = buf.subarray(dataStart, dataEnd);
|
|
550
|
+
off = dataStart + Math.ceil(size / BLOCK) * BLOCK;
|
|
551
|
+
if (type === "L") {
|
|
552
|
+
longName = readString(data, 0, data.length);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (type === "x") {
|
|
556
|
+
paxPath = parsePax(dec.decode(data)).path ?? null;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (type === "g") continue;
|
|
560
|
+
let path = longName ?? paxPath;
|
|
561
|
+
longName = null;
|
|
562
|
+
paxPath = null;
|
|
563
|
+
if (path === null) {
|
|
564
|
+
const name = readString(h, 0, 100);
|
|
565
|
+
const prefix = readString(h, 345, 155);
|
|
566
|
+
path = prefix ? `${prefix}/${name}` : name;
|
|
567
|
+
}
|
|
568
|
+
if (type !== "0" && type !== "\0") continue;
|
|
569
|
+
while (path.startsWith("./")) path = path.slice(2);
|
|
570
|
+
if (!path) continue;
|
|
571
|
+
total += size;
|
|
572
|
+
if (total > maxBytes) throw new Error("tar 합계가 상한을 넘습니다");
|
|
573
|
+
out.push({
|
|
574
|
+
path,
|
|
575
|
+
bytes: data.slice()
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
/** gunzip. 풀린 크기가 `maxBytes`를 넘으면 멈춘다 */
|
|
581
|
+
async function gunzip(bytes, maxBytes) {
|
|
582
|
+
const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")).getReader();
|
|
583
|
+
const chunks = [];
|
|
584
|
+
let total = 0;
|
|
585
|
+
for (;;) {
|
|
586
|
+
const { done, value } = await reader.read();
|
|
587
|
+
if (done) break;
|
|
588
|
+
total += value.length;
|
|
589
|
+
if (total > maxBytes) {
|
|
590
|
+
await reader.cancel();
|
|
591
|
+
throw new Error("압축을 푼 크기가 상한을 넘습니다");
|
|
592
|
+
}
|
|
593
|
+
chunks.push(value);
|
|
594
|
+
}
|
|
595
|
+
const out = new Uint8Array(total);
|
|
596
|
+
let off = 0;
|
|
597
|
+
for (const c of chunks) {
|
|
598
|
+
out.set(c, off);
|
|
599
|
+
off += c.length;
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/config.ts
|
|
606
|
+
/**
|
|
607
|
+
* 설정 — 우선순위는 플래그 > 환경 변수(`SAYREN_TOKEN`·`SAYREN_API_ORIGIN`) > `.env` > `.sayrenrc`다.
|
|
608
|
+
*
|
|
609
|
+
* - `.sayrenrc`: pull이 만든다. 비밀이 없어 커밋해도 된다(`storeId`·`siteId`·`baseRef`, 기본이 아닌 api 주소).
|
|
610
|
+
* **토큰을 두지 않는다** — 읽을 때 `token` 같은 필드가 있으면 무시하고 경고한다.
|
|
611
|
+
* - `.env`: 토큰(`SAYREN_TOKEN`)과 스토어프론트 템플릿이 읽는 값(`SAYREN_API_URL`·`SAYREN_STORE_CODE`). `.gitignore` 대상이다.
|
|
612
|
+
* 템플릿은 `SAYREN_TOKEN`만 서버 코드 환경에 싣지 않는다(vite.config.ts).
|
|
613
|
+
*/
|
|
614
|
+
const RC_FILE = ".sayrenrc";
|
|
615
|
+
const ENV_FILE = ".env";
|
|
616
|
+
const DEFAULT_API_ORIGIN = "https://api.sayren.app";
|
|
617
|
+
const RC_KEYS = [
|
|
618
|
+
"storeId",
|
|
619
|
+
"siteId",
|
|
620
|
+
"baseRef",
|
|
621
|
+
"apiOrigin"
|
|
622
|
+
];
|
|
623
|
+
function readRc(dir) {
|
|
624
|
+
const path = join(dir, RC_FILE);
|
|
625
|
+
if (!existsSync(path)) return null;
|
|
626
|
+
let parsed;
|
|
627
|
+
try {
|
|
628
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
629
|
+
} catch {
|
|
630
|
+
throw new CliError(`${RC_FILE}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
|
|
631
|
+
}
|
|
632
|
+
if (!parsed || typeof parsed !== "object") throw new CliError(`${RC_FILE} 형식이 아닙니다`);
|
|
633
|
+
const rc = parsed;
|
|
634
|
+
for (const key of Object.keys(rc)) if (!RC_KEYS.includes(key)) console.warn(`경고: ${RC_FILE}의 ${key}는 쓰지 않습니다(토큰은 .env에 두십시오)`);
|
|
635
|
+
if (typeof rc.storeId !== "string" || typeof rc.siteId !== "string" || typeof rc.baseRef !== "string") throw new CliError(`${RC_FILE}에 storeId·siteId·baseRef가 없습니다. npx sayren pull로 다시 받으십시오`);
|
|
636
|
+
return {
|
|
637
|
+
storeId: rc.storeId,
|
|
638
|
+
siteId: rc.siteId,
|
|
639
|
+
baseRef: rc.baseRef,
|
|
640
|
+
...typeof rc.apiOrigin === "string" ? { apiOrigin: rc.apiOrigin } : {}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function writeRc(dir, rc) {
|
|
644
|
+
const out = {
|
|
645
|
+
storeId: rc.storeId,
|
|
646
|
+
siteId: rc.siteId,
|
|
647
|
+
baseRef: rc.baseRef
|
|
648
|
+
};
|
|
649
|
+
if (rc.apiOrigin && rc.apiOrigin !== DEFAULT_API_ORIGIN) out.apiOrigin = rc.apiOrigin;
|
|
650
|
+
writeFileSync(join(dir, RC_FILE), `${JSON.stringify(out, null, 2)}\n`);
|
|
651
|
+
}
|
|
652
|
+
/** `.env` 읽기 — `KEY=VALUE` 줄만(따옴표는 벗긴다). 없으면 빈 값 */
|
|
653
|
+
function readEnvFile(dir) {
|
|
654
|
+
const path = join(dir, ENV_FILE);
|
|
655
|
+
const out = /* @__PURE__ */ new Map();
|
|
656
|
+
if (!existsSync(path)) return out;
|
|
657
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
658
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
|
659
|
+
if (!m?.[1]) continue;
|
|
660
|
+
let value = m[2] ?? "";
|
|
661
|
+
if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
|
|
662
|
+
out.set(m[1], value);
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
/** `.env`에 값들을 쓴다 — 다른 줄은 그대로 두고 같은 키만 바꾼다. 파일 권한은 600이다(토큰) */
|
|
667
|
+
function updateEnvFile(dir, values) {
|
|
668
|
+
const path = join(dir, ENV_FILE);
|
|
669
|
+
const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
|
|
670
|
+
const pending = new Map(Object.entries(values));
|
|
671
|
+
const next = lines.map((line) => {
|
|
672
|
+
const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
|
|
673
|
+
if (key && pending.has(key)) {
|
|
674
|
+
const value = pending.get(key);
|
|
675
|
+
pending.delete(key);
|
|
676
|
+
return `${key}=${value}`;
|
|
677
|
+
}
|
|
678
|
+
return line;
|
|
679
|
+
});
|
|
680
|
+
while (next.length > 0 && next[next.length - 1] === "") next.pop();
|
|
681
|
+
for (const [key, value] of pending) next.push(`${key}=${value}`);
|
|
682
|
+
writeFileSync(path, `${next.join("\n")}\n`, { mode: 384 });
|
|
683
|
+
try {
|
|
684
|
+
chmodSync(path, 384);
|
|
685
|
+
} catch {}
|
|
686
|
+
}
|
|
687
|
+
/** `.gitignore`에 `.env`가 없으면 더한다. 더했으면 true */
|
|
688
|
+
function ensureGitignore(dir) {
|
|
689
|
+
const path = join(dir, ".gitignore");
|
|
690
|
+
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
691
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim());
|
|
692
|
+
if (lines.includes(".env") || lines.includes(".env*") || lines.includes("/.env")) return false;
|
|
693
|
+
writeFileSync(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}.env\n`);
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
/** 스토어프론트 API 주소(`…/storefront/v1`)에서 api origin을 읽는다 */
|
|
697
|
+
function originOfStorefrontUrl(url) {
|
|
698
|
+
if (!url) return null;
|
|
699
|
+
return /^(https?:\/\/[^/]+)\/storefront\/v1\/?$/.exec(url.trim())?.[1] ?? null;
|
|
700
|
+
}
|
|
701
|
+
function resolveAuth(flags, dir, rc, env = process.env) {
|
|
702
|
+
const dotenv = readEnvFile(dir);
|
|
703
|
+
const fromFlag = flags.token?.trim();
|
|
704
|
+
const fromEnv = env.SAYREN_TOKEN?.trim();
|
|
705
|
+
const fromFile = dotenv.get("SAYREN_TOKEN")?.trim();
|
|
706
|
+
const token = fromFlag || fromEnv || fromFile;
|
|
707
|
+
if (!token) throw new CliError("토큰이 없습니다. --token으로 주거나 SAYREN_TOKEN 환경 변수·.env에 넣으십시오(셀러 콘솔 › 개발자 › API 토큰, 스코프 storefront:rw)");
|
|
708
|
+
const picked = [
|
|
709
|
+
["flag", flags.apiOrigin?.trim()],
|
|
710
|
+
["env", env.SAYREN_API_ORIGIN?.trim()],
|
|
711
|
+
[".env", originOfStorefrontUrl(dotenv.get("SAYREN_API_URL"))],
|
|
712
|
+
["rc", rc?.apiOrigin]
|
|
713
|
+
].find(([, value]) => value);
|
|
714
|
+
const apiOrigin = (picked?.[1] ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
|
|
715
|
+
assertOriginShape(apiOrigin);
|
|
716
|
+
return {
|
|
717
|
+
token,
|
|
718
|
+
apiOrigin,
|
|
719
|
+
tokenSource: fromFlag ? "flag" : fromEnv ? "env" : ".env",
|
|
720
|
+
originSource: picked && apiOrigin !== DEFAULT_API_ORIGIN ? picked[0] : "default"
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/** 로컬 개발 주소 — http를 받는다 */
|
|
724
|
+
const LOOPBACK = new Set([
|
|
725
|
+
"localhost",
|
|
726
|
+
"127.0.0.1",
|
|
727
|
+
"[::1]"
|
|
728
|
+
]);
|
|
729
|
+
/** api 주소 형식 — https만 받는다(토큰을 싣고 보낸다). localhost·127.0.0.1만 http 예외다 */
|
|
730
|
+
function assertOriginShape(origin) {
|
|
731
|
+
let url;
|
|
732
|
+
try {
|
|
733
|
+
url = new URL(origin);
|
|
734
|
+
} catch {
|
|
735
|
+
throw new CliError(`api 주소 형식이 아닙니다: ${origin}`);
|
|
736
|
+
}
|
|
737
|
+
const loopback = LOOPBACK.has(url.hostname);
|
|
738
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) throw new CliError(`api 주소는 https여야 합니다(localhost만 http 예외): ${origin}. 토큰이 이 주소로 갑니다`);
|
|
739
|
+
}
|
|
740
|
+
/** 사용자에게 보여 줄 오류 — 스택 없이 문장만 찍고 종료 코드 1로 끝난다 */
|
|
741
|
+
var CliError = class extends Error {
|
|
742
|
+
constructor(message, exitCode = 1) {
|
|
743
|
+
super(message);
|
|
744
|
+
this.exitCode = exitCode;
|
|
745
|
+
this.name = "CliError";
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/tree.ts
|
|
751
|
+
/** 걸어 들어가지 않는 폴더 — 설치물·산출물·캐시·VCS(경로 규칙이 어차피 막는다. 크기 때문에 읽지도 않는다) */
|
|
752
|
+
const SKIP_DIRS = new Set([
|
|
753
|
+
"node_modules",
|
|
754
|
+
"dist",
|
|
755
|
+
".output",
|
|
756
|
+
".tanstack",
|
|
757
|
+
".git",
|
|
758
|
+
".wrangler",
|
|
759
|
+
".nitro",
|
|
760
|
+
".vinxi",
|
|
761
|
+
".turbo",
|
|
762
|
+
".cache"
|
|
763
|
+
]);
|
|
764
|
+
const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
765
|
+
function collectTree(root) {
|
|
766
|
+
const files = [];
|
|
767
|
+
const skipped = [];
|
|
768
|
+
const walk = (dir) => {
|
|
769
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
770
|
+
const full = join(dir, entry.name);
|
|
771
|
+
const rel = normalizeStorefrontPath(relative(root, full).split(sep).join("/"));
|
|
772
|
+
if (entry.isDirectory()) {
|
|
773
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
774
|
+
walk(full);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
778
|
+
skipped.push(rel);
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (accessOf(rel) === null) {
|
|
782
|
+
skipped.push(rel);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const bytes = new Uint8Array(readFileSync(full));
|
|
786
|
+
files.push({
|
|
787
|
+
path: rel,
|
|
788
|
+
bytes,
|
|
789
|
+
hash: sha256Hex(bytes)
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
walk(root);
|
|
794
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
795
|
+
return {
|
|
796
|
+
files,
|
|
797
|
+
skipped: skipped.sort(),
|
|
798
|
+
totalBytes: files.reduce((s, f) => s + f.bytes.length, 0)
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/** 크기 한도 — 서버와 같다(파일 1MiB, 전체 20MiB·2,000개) */
|
|
802
|
+
function limitProblems(tree) {
|
|
803
|
+
const out = [];
|
|
804
|
+
for (const file of tree.files) if (file.bytes.length > MAX_FILE_BYTES) out.push(`${file.path}: 파일 하나는 1MiB까지입니다(${formatBytes(file.bytes.length)})`);
|
|
805
|
+
if (tree.totalBytes > MAX_TREE_BYTES) out.push(`작업 트리 전체가 20MiB를 넘습니다(${formatBytes(tree.totalBytes)})`);
|
|
806
|
+
if (tree.files.length > MAX_TREE_FILES) out.push(`파일이 ${MAX_TREE_FILES.toLocaleString()}개를 넘습니다(${tree.files.length}개)`);
|
|
807
|
+
return out;
|
|
808
|
+
}
|
|
809
|
+
function formatBytes(n) {
|
|
810
|
+
if (n < 1024) return `${n}B`;
|
|
811
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KiB`;
|
|
812
|
+
return `${(n / 1024 / 1024).toFixed(1)}MiB`;
|
|
813
|
+
}
|
|
814
|
+
/** 내려받은 압축 파일(tar.gz)을 푼다 — 작업 트리 경로 규칙 안의 파일만 */
|
|
815
|
+
async function unpackArchive(tgz) {
|
|
816
|
+
const tar = await gunzip(tgz, MAX_TREE_BYTES * 2 + 4 * 1024 * 1024);
|
|
817
|
+
const out = /* @__PURE__ */ new Map();
|
|
818
|
+
for (const entry of readTar(tar, MAX_TREE_BYTES * 2)) if (accessOf(entry.path) !== null) out.set(entry.path, entry.bytes);
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
/** 로컬 작업 트리(경로 규칙 안)와 받은 파일의 차이 — 지울 것(원격에 없음)·덮을 것(내용이 다름) */
|
|
822
|
+
function diffLocal(root, files) {
|
|
823
|
+
const remove = [];
|
|
824
|
+
const overwrite = [];
|
|
825
|
+
if (!existsSync(root)) return {
|
|
826
|
+
remove,
|
|
827
|
+
overwrite
|
|
828
|
+
};
|
|
829
|
+
for (const local of collectTree(root).files) {
|
|
830
|
+
const remote = files.get(local.path);
|
|
831
|
+
if (!remote) remove.push(local.path);
|
|
832
|
+
else if (sha256Hex(remote) !== local.hash) overwrite.push(local.path);
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
remove,
|
|
836
|
+
overwrite
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
/** 대소문자만 다른 경로 — 대소문자를 가리지 않는 파일 시스템(macOS·Windows 기본)에서 한 파일로 겹친다 */
|
|
840
|
+
function caseCollisions(paths) {
|
|
841
|
+
const groups = /* @__PURE__ */ new Map();
|
|
842
|
+
for (const path of paths) {
|
|
843
|
+
const key = path.toLowerCase();
|
|
844
|
+
groups.set(key, [...groups.get(key) ?? [], path]);
|
|
845
|
+
}
|
|
846
|
+
return [...groups.values()].filter((group) => group.length > 1);
|
|
847
|
+
}
|
|
848
|
+
/** 링크인가(끊긴 링크 포함). 없으면 false */
|
|
849
|
+
function isLink(path) {
|
|
850
|
+
try {
|
|
851
|
+
return lstatSync(path).isSymbolicLink();
|
|
852
|
+
} catch {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* 받은 파일을 폴더에 쓰고 `remove`를 지운다. 경로 규칙 밖(`.env`·`node_modules` 등)은 건드리지 않는다. 링크를 따라 폴더 밖에 쓰지
|
|
858
|
+
* 않는다 — 대상이 링크면(끊긴 링크 포함) 지우고 쓰고, 상위 폴더가 링크면 거부한다.
|
|
859
|
+
*/
|
|
860
|
+
function writeTree(root, files, remove = []) {
|
|
861
|
+
const base = resolve(root);
|
|
862
|
+
const safeTarget = (path) => {
|
|
863
|
+
const target = resolve(base, path);
|
|
864
|
+
if (!target.startsWith(`${base}${sep}`)) throw new CliError(`작업 트리 밖 경로입니다: ${path}`);
|
|
865
|
+
let dir = dirname(target);
|
|
866
|
+
while (dir.startsWith(`${base}${sep}`)) {
|
|
867
|
+
if (isLink(dir)) throw new CliError(`상위 폴더가 링크라 쓰지 않았습니다: ${relative(base, dir)}`);
|
|
868
|
+
dir = dirname(dir);
|
|
869
|
+
}
|
|
870
|
+
return target;
|
|
871
|
+
};
|
|
872
|
+
for (const path of remove) rmSync(safeTarget(path), { force: true });
|
|
873
|
+
for (const [path, bytes] of files) {
|
|
874
|
+
const target = safeTarget(path);
|
|
875
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
876
|
+
if (isLink(target)) rmSync(target);
|
|
877
|
+
writeFileSync(target, bytes);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/** 트리 지문 — 경로 오름차순 `{path}\t{sha256}\n`의 SHA-256(서버 `treeSha256`과 같은 정의) */
|
|
881
|
+
function treeFingerprint(files) {
|
|
882
|
+
const input = [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map((file) => `${file.path}\t${file.hash}\n`).join("");
|
|
883
|
+
return sha256Hex(new TextEncoder().encode(input));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
//#endregion
|
|
887
|
+
//#region src/checks.ts
|
|
888
|
+
function runLocalChecks(tree) {
|
|
889
|
+
const map = new Map(tree.files.map((file) => [file.path, file.bytes]));
|
|
890
|
+
const report$1 = verifyTree(map);
|
|
891
|
+
const blockingRules = report$1.results.filter((result) => result.status === "violation");
|
|
892
|
+
const problems = [...limitProblems(tree)];
|
|
893
|
+
const warnings = [];
|
|
894
|
+
const text = (path) => {
|
|
895
|
+
const bytes = map.get(path);
|
|
896
|
+
return bytes ? new TextDecoder().decode(bytes) : null;
|
|
897
|
+
};
|
|
898
|
+
const packageJson = text("package.json");
|
|
899
|
+
const lock = text("pnpm-lock.yaml");
|
|
900
|
+
if (packageJson && lock) {
|
|
901
|
+
const drift = lockDrift(packageJson, lock);
|
|
902
|
+
if (drift.length) warnings.push(`package.json의 의존성이 pnpm-lock.yaml과 다릅니다(${drift.slice(0, 5).join(", ")}). 호스팅 빌드는 pnpm-lock.yaml대로 설치하므로 pnpm install로 잠금 파일을 갱신하십시오`);
|
|
903
|
+
} else if (packageJson && !lock) warnings.push("pnpm-lock.yaml이 없습니다. 호스팅 빌드의 설치가 실패할 수 있습니다");
|
|
904
|
+
return {
|
|
905
|
+
report: report$1,
|
|
906
|
+
blockingRules,
|
|
907
|
+
problems,
|
|
908
|
+
warnings
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
const DEPENDENCY_FIELDS = [
|
|
912
|
+
"dependencies",
|
|
913
|
+
"devDependencies",
|
|
914
|
+
"optionalDependencies",
|
|
915
|
+
"peerDependencies"
|
|
916
|
+
];
|
|
917
|
+
/** `package.json`의 의존성 선언(이름 → 지정자). 읽지 못하면 null */
|
|
918
|
+
function declaredDependencies(packageJson) {
|
|
919
|
+
let parsed;
|
|
920
|
+
try {
|
|
921
|
+
parsed = JSON.parse(packageJson);
|
|
922
|
+
} catch {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
926
|
+
const out = /* @__PURE__ */ new Map();
|
|
927
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
928
|
+
const section = parsed[field];
|
|
929
|
+
if (!section || typeof section !== "object") continue;
|
|
930
|
+
for (const [name, spec] of Object.entries(section)) out.set(name, typeof spec === "string" ? spec : "");
|
|
931
|
+
}
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* `package.json`의 선언과 잠금 파일(v9) `importers: .:`의 선언이 다른 이름. 호스팅 빌드는 `--frozen-lockfile`이라 어긋나면 설치에서
|
|
936
|
+
* 실패한다. npm으로 의존성을 더하면 `package-lock.json`만 바뀌어 이렇게 된다.
|
|
937
|
+
*/
|
|
938
|
+
function lockDrift(packageJson, lock) {
|
|
939
|
+
const declared = declaredDependencies(packageJson);
|
|
940
|
+
if (!declared) return [];
|
|
941
|
+
const locked = lockImporterSpecifiers(lock);
|
|
942
|
+
if (!locked) return [];
|
|
943
|
+
const drift = /* @__PURE__ */ new Set();
|
|
944
|
+
for (const [name, spec] of declared) if (locked.get(name) !== spec) drift.add(name);
|
|
945
|
+
for (const name of locked.keys()) if (!declared.has(name)) drift.add(name);
|
|
946
|
+
return [...drift].sort();
|
|
947
|
+
}
|
|
948
|
+
/** 잠금 파일 `importers: .:`의 이름 → specifier. 읽지 못하면 null */
|
|
949
|
+
function lockImporterSpecifiers(lock) {
|
|
950
|
+
const lines = lock.split(/\r?\n/);
|
|
951
|
+
const start = lines.findIndex((line) => line === "importers:");
|
|
952
|
+
if (start < 0) return null;
|
|
953
|
+
const out = /* @__PURE__ */ new Map();
|
|
954
|
+
let inRoot = false;
|
|
955
|
+
let current = null;
|
|
956
|
+
for (const line of lines.slice(start + 1)) {
|
|
957
|
+
if (/^\S/.test(line)) break;
|
|
958
|
+
if (/^ {2}\S/.test(line)) {
|
|
959
|
+
inRoot = /^ {2}\.:\s*$/.test(line);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (!inRoot) continue;
|
|
963
|
+
const name = /^ {6}'?([^'\s][^']*?)'?:\s*$/.exec(line);
|
|
964
|
+
if (name?.[1]) {
|
|
965
|
+
current = name[1];
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const spec = /^ {8}specifier:\s*'?(.*?)'?\s*$/.exec(line);
|
|
969
|
+
if (spec && current) out.set(current, spec[1] ?? "");
|
|
970
|
+
}
|
|
971
|
+
return out;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/commands.ts
|
|
976
|
+
/** 종료 코드 — 0 성공 · 1 실패 · 3 발행이 승인 대기(에이전트 토큰) */
|
|
977
|
+
const EXIT_APPROVAL_PENDING = 3;
|
|
978
|
+
/** 빠진 내용을 다시 올리는 최대 횟수 */
|
|
979
|
+
const MAX_BLOB_ROUNDS = 3;
|
|
980
|
+
/** 올리기 한 번의 내용 합계 상한 — base64(×4/3)가 api 본문 8MiB 안에 든다 */
|
|
981
|
+
const BLOB_BATCH_BYTES = 5 * 1024 * 1024;
|
|
982
|
+
/**
|
|
983
|
+
* 토큰·api 주소 확인 — `--token`은 셸 기록·프로세스 목록에 남으니 알리고, 파일(`.env`·`.sayrenrc`)에서 읽은 기본이 아닌 api 주소는 토큰이
|
|
984
|
+
* 그곳으로 가므로 확인한다(대화형이면 묻고, 아니면 `--api-origin`·`SAYREN_API_ORIGIN`으로 명시해야 한다)
|
|
985
|
+
*/
|
|
986
|
+
async function checkAuthSources(auth, io) {
|
|
987
|
+
if (auth.tokenSource === "flag") io.err("경고: --token은 셸 기록·프로세스 목록에 남습니다. 다음부터는 npx sayren login 또는 SAYREN_TOKEN 환경 변수를 쓰십시오");
|
|
988
|
+
if (auth.originSource !== ".env" && auth.originSource !== "rc") return;
|
|
989
|
+
const from = auth.originSource === ".env" ? ".env의 SAYREN_API_URL" : RC_FILE;
|
|
990
|
+
io.err(`경고: 기본이 아닌 api 주소(${auth.apiOrigin})를 ${from}에서 읽었습니다. 토큰이 이 주소로 갑니다`);
|
|
991
|
+
if (!io.confirm) throw new CliError(`파일에서 읽은 api 주소는 확인이 필요합니다. 맞으면 --api-origin ${auth.apiOrigin}으로 명시하십시오`);
|
|
992
|
+
if (!await io.confirm(`${auth.apiOrigin}으로 보내겠습니까?`)) throw new CliError("취소했습니다");
|
|
993
|
+
}
|
|
994
|
+
async function pull(flags, io) {
|
|
995
|
+
const probeDir = flags.dir ? resolve(io.cwd, flags.dir) : io.cwd;
|
|
996
|
+
const auth = resolveAuth(flags, existsSync(probeDir) ? probeDir : io.cwd, null, io.env);
|
|
997
|
+
await checkAuthSources(auth, io);
|
|
998
|
+
const client = clientOf(auth, io.fetch);
|
|
999
|
+
const hosting = await client.storefront.get();
|
|
1000
|
+
if (!hosting.site) throw new CliError("이 스토어에는 호스팅 사이트가 없습니다. 셀러 콘솔 › 스토어프론트에서 사이트를 만든 뒤 다시 받으십시오");
|
|
1001
|
+
if (!hosting.editingEnabled) throw new CliError("지금은 스토어프론트 소스를 받을 수 없습니다(편집 기능이 꺼져 있습니다)");
|
|
1002
|
+
const source = await client.storefront.getSource();
|
|
1003
|
+
if (flags.project && flags.project !== source.storeId) throw new CliError(`토큰의 스토어(${source.storeId})와 --project(${flags.project})가 다릅니다. 그 스토어에서 발급한 토큰을 쓰십시오`);
|
|
1004
|
+
const store = {
|
|
1005
|
+
storeId: source.storeId,
|
|
1006
|
+
storeCode: source.storeCode
|
|
1007
|
+
};
|
|
1008
|
+
const defaultDir = store.storeCode || source.siteId;
|
|
1009
|
+
const target = !flags.dir && existsSync(join(io.cwd, RC_FILE)) ? io.cwd : resolve(io.cwd, flags.dir ?? defaultDir);
|
|
1010
|
+
const existing = existsSync(target) ? readdirSync(target) : [];
|
|
1011
|
+
if (existing.length > 0) {
|
|
1012
|
+
if (!flags.force) throw new CliError(`폴더가 비어 있지 않습니다: ${relativeOf(io, target)}. 같은 사이트를 받은 폴더면 --force로 원격 작업 트리를 다시 받을 수 있습니다`);
|
|
1013
|
+
let rc = null;
|
|
1014
|
+
try {
|
|
1015
|
+
rc = readRc(target);
|
|
1016
|
+
} catch {
|
|
1017
|
+
rc = null;
|
|
1018
|
+
}
|
|
1019
|
+
if (!rc || rc.siteId !== source.siteId) throw new CliError(`${relativeOf(io, target)}는 이 사이트(${source.siteId})를 받은 폴더가 아닙니다(${RC_FILE} 없음 또는 다른 사이트). 빈 폴더를 지정하십시오`);
|
|
1020
|
+
}
|
|
1021
|
+
io.out(`작업 트리 받기 — ${source.files}개 파일, ${formatBytes(source.bytes)} (압축 ${formatBytes(source.archive.bytes)})`);
|
|
1022
|
+
const chunks = [];
|
|
1023
|
+
for (let part = 0; part < source.archive.parts; part += 1) {
|
|
1024
|
+
const piece = await client.storefront.getSourceArchive({
|
|
1025
|
+
sourceRef: source.sourceRef,
|
|
1026
|
+
part
|
|
1027
|
+
});
|
|
1028
|
+
chunks.push(Buffer.from(piece.content, "base64"));
|
|
1029
|
+
}
|
|
1030
|
+
const tgz = new Uint8Array(Buffer.concat(chunks));
|
|
1031
|
+
if (sha256Hex(tgz) !== source.archive.sha256) throw new CliError("받은 압축 파일이 손상됐습니다. 다시 시도하십시오");
|
|
1032
|
+
const files = await unpackArchive(tgz);
|
|
1033
|
+
const collisions = caseCollisions(files.keys());
|
|
1034
|
+
if (collisions.length > 0) throw new CliError(`대소문자만 다른 경로가 있어 받지 않았습니다(대소문자를 가리지 않는 파일 시스템에서 겹칩니다): ${collisions.map((group) => group.join(" · ")).join(", ")}. 콘솔·AI 편집으로 한쪽 이름을 바꾼 뒤 다시 받으십시오`);
|
|
1035
|
+
const diff = diffLocal(target, files);
|
|
1036
|
+
if (existing.length > 0) {
|
|
1037
|
+
const watched = diff.overwrite.filter((path) => path === "package.json" || path === "vite.config.ts");
|
|
1038
|
+
for (const path of watched) io.out(`알림: 원격의 ${path}가 이 폴더와 다릅니다. 설치 전에 내용을 확인하십시오(npm install --ignore-scripts 권장)`);
|
|
1039
|
+
if (diff.remove.length + diff.overwrite.length > 0) {
|
|
1040
|
+
io.out(`원격 작업 트리로 바꿉니다 — 지울 파일 ${diff.remove.length}개, 덮어쓸 파일 ${diff.overwrite.length}개`);
|
|
1041
|
+
for (const path of diff.remove.slice(0, 20)) io.out(` - ${path}`);
|
|
1042
|
+
if (diff.remove.length > 20) io.out(` … 외 ${diff.remove.length - 20}개`);
|
|
1043
|
+
for (const path of diff.overwrite.slice(0, 20)) io.out(` ~ ${path}`);
|
|
1044
|
+
if (diff.overwrite.length > 20) io.out(` … 외 ${diff.overwrite.length - 20}개`);
|
|
1045
|
+
if (!flags.yes) {
|
|
1046
|
+
if (!io.confirm) throw new CliError("로컬 파일을 지우거나 덮어씁니다. 확인할 수 없는 환경이면 --yes를 함께 주십시오");
|
|
1047
|
+
if (!await io.confirm("계속하시겠습니까?")) {
|
|
1048
|
+
io.out("취소했습니다");
|
|
1049
|
+
return 1;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
mkdirSync(target, { recursive: true });
|
|
1055
|
+
writeTree(target, files, existing.length > 0 ? diff.remove : []);
|
|
1056
|
+
const removed = existing.length > 0 ? diff.remove.length : 0;
|
|
1057
|
+
writeRc(target, {
|
|
1058
|
+
storeId: store.storeId,
|
|
1059
|
+
siteId: source.siteId,
|
|
1060
|
+
baseRef: source.sourceRef,
|
|
1061
|
+
...auth.apiOrigin !== DEFAULT_API_ORIGIN ? { apiOrigin: auth.apiOrigin } : {}
|
|
1062
|
+
});
|
|
1063
|
+
updateEnvFile(target, {
|
|
1064
|
+
SAYREN_TOKEN: auth.token,
|
|
1065
|
+
SAYREN_API_URL: `${auth.apiOrigin}/storefront/v1`,
|
|
1066
|
+
SAYREN_STORE_CODE: store.storeCode
|
|
1067
|
+
});
|
|
1068
|
+
const addedIgnore = ensureGitignore(target);
|
|
1069
|
+
io.out(`받음 → ${relativeOf(io, target)} (${files.size}개${removed ? `, 원격에 없는 ${removed}개 지움` : ""})`);
|
|
1070
|
+
io.out(`${RC_FILE} 작성 — 사이트 ${source.siteId}. 커밋해도 됩니다(비밀 없음)`);
|
|
1071
|
+
io.out(`${ENV_FILE} 작성 — 토큰과 스토어프론트 API 주소. 커밋하지 마십시오${addedIgnore ? "(.gitignore에 더했습니다)" : ""}`);
|
|
1072
|
+
if (source.editSessionOpen) io.out("알림: 콘솔·AI 편집 환경이 열려 있습니다. 배포 전에 그 편집이 끝났는지 확인하십시오");
|
|
1073
|
+
warnTokenLoading(target, io);
|
|
1074
|
+
io.out(`\n다음:\n cd ${relativeOf(io, target)}\n npm install --ignore-scripts # 의존성의 설치 스크립트를 돌리지 않습니다\n npm run dev # http://localhost:4010 — 운영 스토어 데이터로 동작합니다\n npx sayren deploy # 빌드 → 버전 → 발행`);
|
|
1075
|
+
return 0;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* 옛 템플릿의 vite.config.ts는 `.env`의 `SAYREN_*`를 모두 서버 코드 환경에 싣는다 — 토큰(`SAYREN_TOKEN`)도 실린다.
|
|
1079
|
+
* 브라우저 번들에는 들어가지 않지만 서버 코드가 읽을 수 있으므로 알린다(새 템플릿은 토큰을 빼고 싣는다).
|
|
1080
|
+
*/
|
|
1081
|
+
function warnTokenLoading(dir, io) {
|
|
1082
|
+
const path = join(dir, "vite.config.ts");
|
|
1083
|
+
if (!existsSync(path)) return;
|
|
1084
|
+
const text = readFileSync(path, "utf8");
|
|
1085
|
+
if (text.includes("\"SAYREN_\"") && !text.includes("SAYREN_TOKEN")) io.out("알림: vite.config.ts가 .env의 SAYREN_TOKEN까지 개발 서버 환경에 싣습니다. 서버 코드에서 process.env.SAYREN_TOKEN을 읽지 마십시오(최신 템플릿은 싣지 않습니다)");
|
|
1086
|
+
}
|
|
1087
|
+
async function verify(flags, io) {
|
|
1088
|
+
const dir = resolve(io.cwd, flags.dir ?? ".");
|
|
1089
|
+
const tree = collectTree(dir);
|
|
1090
|
+
if (tree.files.length === 0) throw new CliError(`작업 트리 파일이 없습니다: ${relativeOf(io, dir)}`);
|
|
1091
|
+
const checks = runLocalChecks(tree);
|
|
1092
|
+
printChecks(tree, checks, io);
|
|
1093
|
+
return checks.blockingRules.length === 0 && checks.problems.length === 0 ? 0 : 1;
|
|
1094
|
+
}
|
|
1095
|
+
function printChecks(tree, checks, io) {
|
|
1096
|
+
const { report: report$1 } = checks;
|
|
1097
|
+
io.out(`로컬 검사 — 파일 ${tree.files.length}개(${formatBytes(tree.totalBytes)}), 규칙 ${report$1.summary.passed}/${report$1.summary.rules} 통과`);
|
|
1098
|
+
for (const finding of report$1.findings) {
|
|
1099
|
+
const at = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
1100
|
+
io.out(` · 위반 [${finding.ruleId}] ${at} — ${finding.fix}`);
|
|
1101
|
+
}
|
|
1102
|
+
for (const problem of checks.problems) io.out(` · 문제 ${problem}`);
|
|
1103
|
+
for (const warning of checks.warnings) io.out(` · 경고 ${warning}`);
|
|
1104
|
+
const secrets = tree.skipped.filter((path) => /(^|\/)\.env|(^|\/)\.sayrenrc$/.test(path));
|
|
1105
|
+
if (secrets.length) io.out(` · 올리지 않음(비밀): ${secrets.join(", ")}`);
|
|
1106
|
+
const others = tree.skipped.filter((path) => !secrets.includes(path));
|
|
1107
|
+
if (others.length) io.out(` · 올리지 않음(작업 트리 밖): ${others.slice(0, 8).join(", ")}${others.length > 8 ? ` 외 ${others.length - 8}개` : ""}`);
|
|
1108
|
+
}
|
|
1109
|
+
async function deploy(flags, io) {
|
|
1110
|
+
const dir = resolve(io.cwd, flags.dir ?? ".");
|
|
1111
|
+
const rc = readRc(dir);
|
|
1112
|
+
if (!rc) throw new CliError(`${RC_FILE}가 없습니다. 먼저 npx sayren pull로 사이트 소스를 받으십시오`);
|
|
1113
|
+
const auth = resolveAuth(flags, dir, rc, io.env);
|
|
1114
|
+
await checkAuthSources(auth, io);
|
|
1115
|
+
const client = clientOf(auth, io.fetch);
|
|
1116
|
+
const tree = collectTree(dir);
|
|
1117
|
+
const checks = runLocalChecks(tree);
|
|
1118
|
+
printChecks(tree, checks, io);
|
|
1119
|
+
if (checks.blockingRules.length > 0 || checks.problems.length > 0) throw new CliError("로컬 검사를 통과하지 못해 배포하지 않았습니다. 위 항목을 고친 뒤 다시 시도하십시오");
|
|
1120
|
+
const site = (await client.storefront.get()).site;
|
|
1121
|
+
if (!site || site.siteId !== rc.siteId) throw new CliError(`이 폴더의 사이트(${rc.siteId})가 스토어의 지금 사이트와 다릅니다. npx sayren pull로 다시 받으십시오`);
|
|
1122
|
+
if (flags.force && !flags.yes) {
|
|
1123
|
+
if (!io.confirm) throw new CliError("--force는 원격 작업 트리를 덮어씁니다. 확인할 수 없는 환경이면 --yes를 함께 주십시오");
|
|
1124
|
+
if (!await io.confirm("원격 작업 트리(콘솔·AI 편집 포함)를 이 폴더 내용으로 덮어쓰고, 열린 편집 환경을 끕니다. 계속하시겠습니까?")) {
|
|
1125
|
+
io.out("취소했습니다");
|
|
1126
|
+
return 1;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
let replaced;
|
|
1130
|
+
try {
|
|
1131
|
+
replaced = await replaceSource(client, tree, rc, flags.force === true, io);
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
if (error instanceof ApprovalPendingError) {
|
|
1134
|
+
io.out("작업 트리 바꾸기는 승인이 필요합니다(에이전트 토큰이 package.json·vite.config.ts를 바꿉니다). 승인된 뒤 npx sayren deploy를 다시 돌리면 빌드·발행합니다");
|
|
1135
|
+
if (error.approval.approvalUrl) io.out(`승인 요청: ${error.approval.approvalUrl}`);
|
|
1136
|
+
else io.out(`승인 요청 id: ${error.approvalId} (셀러 콘솔 › 설정 › 보안 › 승인 요청)`);
|
|
1137
|
+
return EXIT_APPROVAL_PENDING;
|
|
1138
|
+
}
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
writeRc(dir, {
|
|
1142
|
+
...rc,
|
|
1143
|
+
baseRef: replaced.sourceRef
|
|
1144
|
+
});
|
|
1145
|
+
io.out(replaced.changed ? `작업 트리 올림 — ${replaced.files}개 파일${replaced.endedEditSession ? "(열린 편집 환경을 껐습니다)" : ""}` : "작업 트리가 원격과 같습니다 — 그대로 빌드합니다");
|
|
1146
|
+
let created;
|
|
1147
|
+
try {
|
|
1148
|
+
created = await client.storefront.createVersion({
|
|
1149
|
+
...flags.message ? { message: flags.message } : {},
|
|
1150
|
+
expectedSourceRef: replaced.sourceRef
|
|
1151
|
+
});
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
const code = codeOf(error);
|
|
1154
|
+
if (code === "STOREFRONT_BUILD_IN_PROGRESS") throw new CliError("이 사이트에 끝나지 않은 빌드가 있습니다(콘솔·AI 또는 다른 배포). 작업 트리는 올렸습니다 — npx sayren status로 확인한 뒤 다시 배포하십시오");
|
|
1155
|
+
if (code === "SOURCE_CHANGED") throw new CliError("올린 직후 다른 편집이 끼어 버전을 만들지 않았습니다. npx sayren pull --force로 받아 합친 뒤 다시 배포하십시오");
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
io.out(`버전 v${created.version.number} 빌드 시작`);
|
|
1159
|
+
const build = await waitBuild(client, created.build, io);
|
|
1160
|
+
if (build.status !== "SUCCEEDED") {
|
|
1161
|
+
printBuildFailure(build, io);
|
|
1162
|
+
return 1;
|
|
1163
|
+
}
|
|
1164
|
+
io.out(`빌드 성공 — v${created.version.number}${build.durationMs ? ` (${Math.round(build.durationMs / 1e3)}초)` : ""}`);
|
|
1165
|
+
if (flags.publish === false) {
|
|
1166
|
+
io.out(`발행하지 않았습니다(--no-publish). 콘솔 › 스토어프론트 › 버전에서 v${created.version.number}를 발행할 수 있습니다`);
|
|
1167
|
+
return 0;
|
|
1168
|
+
}
|
|
1169
|
+
try {
|
|
1170
|
+
const published = await client.storefront.publish(created.version.versionId, { expectedPublishedVersionId: site.publishedVersion?.versionId ?? null });
|
|
1171
|
+
io.out(`발행 — v${created.version.number} → ${published.url}`);
|
|
1172
|
+
io.out("반영까지 1분 안팎이 걸립니다");
|
|
1173
|
+
return 0;
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
if (error instanceof ApprovalPendingError) {
|
|
1176
|
+
io.out(`발행은 승인이 필요합니다(에이전트 토큰). 셀러가 승인하면 v${created.version.number}가 발행됩니다`);
|
|
1177
|
+
if (error.approval.approvalUrl) io.out(`승인 요청: ${error.approval.approvalUrl}`);
|
|
1178
|
+
else io.out(`승인 요청 id: ${error.approvalId} (셀러 콘솔 › 설정 › 보안 › 승인 요청)`);
|
|
1179
|
+
return EXIT_APPROVAL_PENDING;
|
|
1180
|
+
}
|
|
1181
|
+
if (codeOf(error) === "STOREFRONT_SITE_CHANGED") throw new CliError(`다른 사람이 그 사이 다른 버전을 발행했습니다. v${created.version.number}는 만들어 뒀습니다 — 콘솔에서 확인한 뒤 발행하십시오`);
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
async function replaceSource(client, tree, rc, force, io) {
|
|
1186
|
+
const files = tree.files.map((file) => ({
|
|
1187
|
+
path: file.path,
|
|
1188
|
+
hash: file.hash,
|
|
1189
|
+
size: file.bytes.length
|
|
1190
|
+
}));
|
|
1191
|
+
const textOf = (path) => {
|
|
1192
|
+
const file = tree.files.find((f) => f.path === path);
|
|
1193
|
+
return file ? new TextDecoder().decode(file.bytes) : void 0;
|
|
1194
|
+
};
|
|
1195
|
+
const packageJson = textOf("package.json");
|
|
1196
|
+
const lockfile = textOf("pnpm-lock.yaml");
|
|
1197
|
+
const body = {
|
|
1198
|
+
...force ? { endEditSession: true } : { expectedBaseRef: rc.baseRef },
|
|
1199
|
+
files,
|
|
1200
|
+
...packageJson === void 0 ? {} : { packageJson },
|
|
1201
|
+
...lockfile === void 0 ? {} : { lockfile }
|
|
1202
|
+
};
|
|
1203
|
+
let uploads = 0;
|
|
1204
|
+
for (;;) try {
|
|
1205
|
+
const result = await client.storefront.replaceSource(body);
|
|
1206
|
+
for (const warning of result.dependencyWarnings) io.out(` · 의존성 경고 ${findingLine(warning)}`);
|
|
1207
|
+
return result;
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
const code = codeOf(error);
|
|
1210
|
+
const details = detailsOf(error);
|
|
1211
|
+
if (code === "SOURCE_BLOBS_MISSING" && uploads < MAX_BLOB_ROUNDS) {
|
|
1212
|
+
await uploadBlobs(client, tree, new Set(Array.isArray(details.hashes) ? details.hashes : []), io);
|
|
1213
|
+
uploads += 1;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (code === "SOURCE_BLOBS_MISSING") throw new CliError("파일 내용을 올렸지만 서버가 찾지 못했습니다. 잠시 뒤 다시 배포하십시오");
|
|
1217
|
+
if (code === "SOURCE_CHANGED") {
|
|
1218
|
+
const remote = await client.storefront.getSource().catch(() => null);
|
|
1219
|
+
if (remote && remote.treeSha256 === treeFingerprint(files)) {
|
|
1220
|
+
io.out("원격 작업 트리가 이미 이 폴더와 같습니다 — 기준을 맞추고 이어서 빌드합니다");
|
|
1221
|
+
return {
|
|
1222
|
+
sourceRef: remote.sourceRef,
|
|
1223
|
+
previousRef: remote.sourceRef,
|
|
1224
|
+
treeSha256: remote.treeSha256,
|
|
1225
|
+
files: remote.files,
|
|
1226
|
+
bytes: remote.bytes,
|
|
1227
|
+
changed: false,
|
|
1228
|
+
endedEditSession: false,
|
|
1229
|
+
dependencyWarnings: []
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
throw new CliError("원격 작업 트리가 pull 이후 바뀌었습니다(콘솔·AI 편집). 덮어쓰지 않았습니다.\n · 원격을 받으려면: npx sayren pull --force (로컬 변경은 먼저 커밋·백업하십시오)\n · 이 폴더 내용으로 덮어쓰려면: npx sayren deploy --force");
|
|
1233
|
+
}
|
|
1234
|
+
if (code === "EDIT_SESSION_OPEN") throw new CliError("콘솔·AI 편집 환경이 열려 있어 올리지 않았습니다. 편집을 끝낸 뒤 다시 시도하거나 --force로 편집 환경을 끄고 진행하십시오");
|
|
1235
|
+
if (code === "DEPENDENCY_REJECTED") throw new CliError(`쓸 수 없는 npm 의존성이 있어 올리지 않았습니다.\n${(Array.isArray(details.rejected) ? details.rejected : []).map((item) => ` · ${findingLine(item)}`).join("\n")}`);
|
|
1236
|
+
if (code === "DEPENDENCY_CHECK_UNAVAILABLE") throw new CliError("의존성 확인을 지금 할 수 없습니다. 잠시 뒤 다시 시도하십시오");
|
|
1237
|
+
if (code === "SOURCE_TOO_MANY_NEW_FILES") throw new CliError(`한 번에 올릴 수 있는 새 파일은 ${String(details.max ?? 800)}개까지입니다. 파일을 나눠 여러 번 배포하십시오(--no-publish로 중간 배포)`);
|
|
1238
|
+
if (code === "INVALID_FILE_PATH") throw new CliError(`올릴 수 없는 경로입니다: ${String(details.path ?? "")}`);
|
|
1239
|
+
throw error;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const REASON_LABEL = {
|
|
1243
|
+
DENYLISTED: "거절 목록",
|
|
1244
|
+
ADVISORY: "보안 권고",
|
|
1245
|
+
MALWARE: "악성 패키지",
|
|
1246
|
+
INVALID_MANIFEST: "선언 오류",
|
|
1247
|
+
INSTALL_SCRIPT: "설치 스크립트",
|
|
1248
|
+
UNSUPPORTED_SOURCE: "지원하지 않는 설치 경로",
|
|
1249
|
+
BUNDLED_DEPENDENCIES: "묶인 의존성"
|
|
1250
|
+
};
|
|
1251
|
+
/** `name@version — 사유: 설명` */
|
|
1252
|
+
function findingLine(item) {
|
|
1253
|
+
return `${item.version ? `${item.name}@${item.version}` : String(item.name ?? "")} — ${item.reason ? REASON_LABEL[item.reason] ?? item.reason : ""}${item.detail ? `: ${item.detail}` : ""}`;
|
|
1254
|
+
}
|
|
1255
|
+
async function uploadBlobs(client, tree, hashes, io) {
|
|
1256
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1257
|
+
const pending = tree.files.filter((file) => {
|
|
1258
|
+
if (!hashes.has(file.hash) || seen.has(file.hash)) return false;
|
|
1259
|
+
seen.add(file.hash);
|
|
1260
|
+
return true;
|
|
1261
|
+
});
|
|
1262
|
+
let batch = [];
|
|
1263
|
+
let bytes = 0;
|
|
1264
|
+
let sent = 0;
|
|
1265
|
+
const flush = async () => {
|
|
1266
|
+
if (batch.length === 0) return;
|
|
1267
|
+
await client.storefront.uploadSourceBlobs({ blobs: batch.map((file) => ({
|
|
1268
|
+
hash: file.hash,
|
|
1269
|
+
content: Buffer.from(file.bytes).toString("base64")
|
|
1270
|
+
})) });
|
|
1271
|
+
sent += batch.length;
|
|
1272
|
+
batch = [];
|
|
1273
|
+
bytes = 0;
|
|
1274
|
+
};
|
|
1275
|
+
for (const file of pending) {
|
|
1276
|
+
if (batch.length >= STOREFRONT_SOURCE_BLOB_BATCH_MAX || bytes + file.bytes.length > BLOB_BATCH_BYTES) await flush();
|
|
1277
|
+
batch.push(file);
|
|
1278
|
+
bytes += file.bytes.length;
|
|
1279
|
+
}
|
|
1280
|
+
await flush();
|
|
1281
|
+
io.out(`바뀐 파일 ${sent}개 올림`);
|
|
1282
|
+
}
|
|
1283
|
+
async function waitBuild(client, initial, io) {
|
|
1284
|
+
const started = Date.now();
|
|
1285
|
+
let build = initial;
|
|
1286
|
+
let printed = 0;
|
|
1287
|
+
for (;;) {
|
|
1288
|
+
for (const stage of build.stages.slice(printed)) io.out(` ${stage.ok ? "✓" : "✗"} ${stage.stage} ${(stage.durationMs / 1e3).toFixed(1)}초`);
|
|
1289
|
+
printed = build.stages.length;
|
|
1290
|
+
if (STOREFRONT_BUILD_TERMINAL_STATUSES.includes(build.status)) return build;
|
|
1291
|
+
if (Date.now() - started > io.buildTimeoutMs) throw new CliError(`빌드가 ${Math.round(io.buildTimeoutMs / 6e4)}분 안에 끝나지 않았습니다. npx sayren status로 결과를 확인하십시오`);
|
|
1292
|
+
await io.sleep(io.pollMs);
|
|
1293
|
+
build = await client.storefront.getBuild(build.buildId);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
function printBuildFailure(build, io) {
|
|
1297
|
+
io.err(`빌드 실패 — ${build.failureStage ?? "알 수 없는 단계"}${build.errorCode ? ` (${build.errorCode})` : ""}`);
|
|
1298
|
+
if (build.failureStage === "DEPENDENCIES") io.err(" npm 의존성 검사에서 막혔습니다. 아래 사유의 패키지를 빼거나 바꾼 뒤 다시 배포하십시오");
|
|
1299
|
+
for (const finding of build.verify?.findings ?? []) {
|
|
1300
|
+
const at = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
1301
|
+
io.err(` · [${finding.ruleId}] ${at} — ${finding.fix}`);
|
|
1302
|
+
}
|
|
1303
|
+
if (build.logTail) {
|
|
1304
|
+
io.err("로그 꼬리:");
|
|
1305
|
+
for (const line of build.logTail.trimEnd().split("\n").slice(-40)) io.err(` ${line}`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
async function status(flags, io) {
|
|
1309
|
+
const dir = resolve(io.cwd, flags.dir ?? ".");
|
|
1310
|
+
const rc = readRc(dir);
|
|
1311
|
+
const auth = resolveAuth(flags, dir, rc, io.env);
|
|
1312
|
+
await checkAuthSources(auth, io);
|
|
1313
|
+
const client = clientOf(auth, io.fetch);
|
|
1314
|
+
const hosting = await client.storefront.get();
|
|
1315
|
+
const site = hosting.site;
|
|
1316
|
+
if (!site) {
|
|
1317
|
+
io.out("호스팅 사이트가 없습니다");
|
|
1318
|
+
return 0;
|
|
1319
|
+
}
|
|
1320
|
+
io.out(`사이트 ${site.siteId} — ${site.url}`);
|
|
1321
|
+
io.out(` 상태 ${site.status} · 공개 ${site.visibility} · 반영 ${site.provisioning.status}`);
|
|
1322
|
+
const published = site.publishedVersion;
|
|
1323
|
+
io.out(published ? ` 발행 v${published.number} (${published.createdVia ?? published.createdByType}${published.publishedAt ? `, ${published.publishedAt}` : ""})` : " 발행 버전 없음");
|
|
1324
|
+
if (hosting.editingEnabled) {
|
|
1325
|
+
const [builds, editing] = await Promise.all([client.storefront.listBuilds({ limit: 3 }), client.storefront.getEditing()]);
|
|
1326
|
+
for (const build of builds) io.out(` 빌드 v${build.versionNumber} ${build.status}${build.failureStage ? ` (${build.failureStage})` : ""} ${build.queuedAt}`);
|
|
1327
|
+
io.out(` 편집 환경 ${editing.session ? `열림(${editing.session.status})` : "닫힘"}`);
|
|
1328
|
+
}
|
|
1329
|
+
if (rc) if (rc.siteId !== site.siteId) io.out(` 이 폴더의 사이트(${rc.siteId})는 지금 사이트가 아닙니다 — 다시 받으십시오`);
|
|
1330
|
+
else io.out(` 이 폴더의 기준 작업 트리 ${rc.baseRef.slice(rc.baseRef.lastIndexOf("/") + 1, -4).slice(0, 12)}`);
|
|
1331
|
+
return 0;
|
|
1332
|
+
}
|
|
1333
|
+
async function login(flags, io, readToken) {
|
|
1334
|
+
const dir = resolve(io.cwd, flags.dir ?? ".");
|
|
1335
|
+
const token = flags.token?.trim() || (await readToken())?.trim();
|
|
1336
|
+
if (!token) throw new CliError("토큰을 입력하지 않았습니다");
|
|
1337
|
+
const hosting = await clientOf(resolveAuth({
|
|
1338
|
+
...flags,
|
|
1339
|
+
token
|
|
1340
|
+
}, dir, readRc(dir), io.env), io.fetch).storefront.get();
|
|
1341
|
+
mkdirSync(dir, { recursive: true });
|
|
1342
|
+
updateEnvFile(dir, { SAYREN_TOKEN: token });
|
|
1343
|
+
const addedIgnore = ensureGitignore(dir);
|
|
1344
|
+
io.out(`토큰을 확인하고(사이트 ${hosting.site?.hostname ?? "없음"}) ${relativeOf(io, join(dir, ENV_FILE))}에 저장했습니다${addedIgnore ? "(.gitignore에 .env를 더했습니다)" : ""}`);
|
|
1345
|
+
return 0;
|
|
1346
|
+
}
|
|
1347
|
+
function relativeOf(io, path) {
|
|
1348
|
+
return relative(io.cwd, path) || ".";
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
//#endregion
|
|
1352
|
+
//#region src/index.ts
|
|
1353
|
+
const USAGE = `sayren ${CLI_VERSION} — 호스팅 스토어프론트 CLI
|
|
1354
|
+
|
|
1355
|
+
사용법:
|
|
1356
|
+
npx sayren login # 토큰을 입력받아 .env에 저장(셸 기록에 남지 않습니다)
|
|
1357
|
+
npx sayren pull --project <storeId> [폴더] [--force [--yes]]
|
|
1358
|
+
npx sayren deploy [--no-publish] [--message <메모>] [--force [--yes]]
|
|
1359
|
+
npx sayren status
|
|
1360
|
+
npx sayren verify
|
|
1361
|
+
|
|
1362
|
+
명령:
|
|
1363
|
+
login 토큰을 확인하고 지금 폴더의 .env에 저장합니다
|
|
1364
|
+
pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .sayrenrc·.env를 씁니다
|
|
1365
|
+
deploy 로컬 검사 → 작업 트리 올리기 → 빌드 → 발행. 원격이 pull 이후 바뀌었으면 덮어쓰지 않습니다
|
|
1366
|
+
status 사이트·발행 버전·최근 빌드·편집 환경
|
|
1367
|
+
verify 로컬 규칙·경로·크기 검사(서버 빌드와 같은 규칙)
|
|
1368
|
+
|
|
1369
|
+
토큰은 SAYREN_TOKEN 환경 변수 → .env 순서로 읽습니다(API 토큰 sy_pat_…, 스코프 storefront:rw).
|
|
1370
|
+
|
|
1371
|
+
옵션:
|
|
1372
|
+
--project <storeId> pull: 토큰의 스토어와 같아야 합니다
|
|
1373
|
+
--dir <폴더> 작업 폴더(기본: 지금 폴더, pull은 스토어 코드)
|
|
1374
|
+
--force pull: 같은 사이트를 받은 폴더에 원격을 다시 받습니다(지울·덮을 파일을 보여 주고 묻습니다)
|
|
1375
|
+
deploy: 원격 변경·열린 편집 환경을 무시하고 덮어씁니다(확인을 묻습니다)
|
|
1376
|
+
--yes --force의 확인을 묻지 않습니다(비대화형)
|
|
1377
|
+
--no-publish deploy: 버전까지만 만들고 발행하지 않습니다
|
|
1378
|
+
--message <메모> deploy: 버전 메모
|
|
1379
|
+
--api-origin <주소> api 주소(기본 https://api.sayren.app, SAYREN_API_ORIGIN). https만, localhost만 http
|
|
1380
|
+
--token <토큰> 토큰을 직접 줍니다. 셸 기록·프로세스 목록에 남으니 login·SAYREN_TOKEN을 권합니다
|
|
1381
|
+
|
|
1382
|
+
종료 코드: 0 성공 · 1 실패 · 3 승인 대기(에이전트 토큰)`;
|
|
1383
|
+
const VALUE_FLAGS = new Set([
|
|
1384
|
+
"token",
|
|
1385
|
+
"project",
|
|
1386
|
+
"dir",
|
|
1387
|
+
"message",
|
|
1388
|
+
"api-origin"
|
|
1389
|
+
]);
|
|
1390
|
+
const BOOL_FLAGS = new Set([
|
|
1391
|
+
"force",
|
|
1392
|
+
"yes",
|
|
1393
|
+
"no-publish",
|
|
1394
|
+
"help",
|
|
1395
|
+
"version"
|
|
1396
|
+
]);
|
|
1397
|
+
function parseArgs(argv) {
|
|
1398
|
+
const out = {
|
|
1399
|
+
command: null,
|
|
1400
|
+
positional: [],
|
|
1401
|
+
flags: {}
|
|
1402
|
+
};
|
|
1403
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1404
|
+
const arg = argv[i];
|
|
1405
|
+
if (arg === "-h") out.flags.help = true;
|
|
1406
|
+
else if (arg === "-v") out.flags.version = true;
|
|
1407
|
+
else if (arg.startsWith("--")) {
|
|
1408
|
+
const body = arg.slice(2);
|
|
1409
|
+
const eq = body.indexOf("=");
|
|
1410
|
+
const name = eq < 0 ? body : body.slice(0, eq);
|
|
1411
|
+
const inline = eq < 0 ? void 0 : body.slice(eq + 1);
|
|
1412
|
+
if (VALUE_FLAGS.has(name)) {
|
|
1413
|
+
const value = inline ?? argv[++i];
|
|
1414
|
+
if (value === void 0 || value.startsWith("--")) throw new CliError(`--${name}에 값이 필요합니다`);
|
|
1415
|
+
out.flags[name] = value;
|
|
1416
|
+
} else if (BOOL_FLAGS.has(name)) out.flags[name] = true;
|
|
1417
|
+
else throw new CliError(`알 수 없는 옵션: --${name}\n\n${USAGE}`);
|
|
1418
|
+
} else if (!out.command) out.command = arg;
|
|
1419
|
+
else out.positional.push(arg);
|
|
1420
|
+
}
|
|
1421
|
+
return out;
|
|
1422
|
+
}
|
|
1423
|
+
function str(value) {
|
|
1424
|
+
return typeof value === "string" ? value : void 0;
|
|
1425
|
+
}
|
|
1426
|
+
async function main(argv, io) {
|
|
1427
|
+
const parsed = parseArgs(argv);
|
|
1428
|
+
if (parsed.flags.version) {
|
|
1429
|
+
io.out(CLI_VERSION);
|
|
1430
|
+
return 0;
|
|
1431
|
+
}
|
|
1432
|
+
if (!parsed.command || parsed.flags.help || parsed.command === "help") {
|
|
1433
|
+
io.out(USAGE);
|
|
1434
|
+
return parsed.command || parsed.flags.help ? 0 : 1;
|
|
1435
|
+
}
|
|
1436
|
+
const common = {
|
|
1437
|
+
token: str(parsed.flags.token),
|
|
1438
|
+
apiOrigin: str(parsed.flags["api-origin"]),
|
|
1439
|
+
dir: str(parsed.flags.dir) ?? parsed.positional[0]
|
|
1440
|
+
};
|
|
1441
|
+
switch (parsed.command) {
|
|
1442
|
+
case "pull": return pull({
|
|
1443
|
+
...common,
|
|
1444
|
+
project: str(parsed.flags.project),
|
|
1445
|
+
force: parsed.flags.force === true,
|
|
1446
|
+
yes: parsed.flags.yes === true
|
|
1447
|
+
}, io);
|
|
1448
|
+
case "deploy": return deploy({
|
|
1449
|
+
...common,
|
|
1450
|
+
force: parsed.flags.force === true,
|
|
1451
|
+
yes: parsed.flags.yes === true,
|
|
1452
|
+
publish: parsed.flags["no-publish"] !== true,
|
|
1453
|
+
message: str(parsed.flags.message)
|
|
1454
|
+
}, io);
|
|
1455
|
+
case "status": return status(common, io);
|
|
1456
|
+
case "verify": return verify(common, io);
|
|
1457
|
+
case "login": return login(common, io, () => ask("API 토큰(sy_pat_…): "));
|
|
1458
|
+
default: throw new CliError(`알 수 없는 명령: ${parsed.command}\n\n${USAGE}`);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async function ask(question) {
|
|
1462
|
+
if (!stdin.isTTY) return null;
|
|
1463
|
+
const rl = createInterface({
|
|
1464
|
+
input: stdin,
|
|
1465
|
+
output: stdout
|
|
1466
|
+
});
|
|
1467
|
+
try {
|
|
1468
|
+
return await rl.question(question);
|
|
1469
|
+
} finally {
|
|
1470
|
+
rl.close();
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
function defaultIo() {
|
|
1474
|
+
return {
|
|
1475
|
+
cwd: process.cwd(),
|
|
1476
|
+
env: process.env,
|
|
1477
|
+
fetch: (input, init) => fetch(input, init),
|
|
1478
|
+
out: (line) => console.log(line),
|
|
1479
|
+
err: (line) => console.error(line),
|
|
1480
|
+
confirm: stdin.isTTY ? async (question) => /^(y|yes|예|네)$/i.test((await ask(`${question} (y/N) `) ?? "").trim()) : null,
|
|
1481
|
+
sleep: (ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)),
|
|
1482
|
+
buildTimeoutMs: 20 * 6e4,
|
|
1483
|
+
pollMs: 3e3
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
/** 오류 표시 — 토큰·요청 본문은 싣지 않는다 */
|
|
1487
|
+
function describeError(error) {
|
|
1488
|
+
if (error instanceof CliError) return error.message;
|
|
1489
|
+
if (error instanceof ApiError) {
|
|
1490
|
+
if (error.status === 401) return "토큰이 올바르지 않거나 만료됐습니다(401). 셀러 콘솔 › 개발자 › API 토큰을 확인하십시오";
|
|
1491
|
+
if (error.status === 403 && error.code === "INSUFFICIENT_SCOPE") return "토큰에 storefront:rw 스코프가 없습니다. 스코프를 더한 토큰을 쓰십시오";
|
|
1492
|
+
if (error.code === "TOO_MANY_REQUESTS") return "요청이 너무 많습니다. 잠시 뒤 다시 시도하십시오";
|
|
1493
|
+
return `${error.message} (${error.code}, HTTP ${error.status})`;
|
|
1494
|
+
}
|
|
1495
|
+
if (error instanceof TypeError && /fetch failed/.test(error.message)) return "api에 연결하지 못했습니다. 네트워크와 --api-origin을 확인하십시오";
|
|
1496
|
+
return error instanceof Error ? error.message : String(error);
|
|
1497
|
+
}
|
|
1498
|
+
if (process.argv[1] && /sayren|index\.(m?js|ts)$/.test(process.argv[1]) && !process.env.VITEST) {
|
|
1499
|
+
const io = defaultIo();
|
|
1500
|
+
main(process.argv.slice(2), io).then((code) => process.exit(code)).catch((error) => {
|
|
1501
|
+
io.err(`오류: ${describeError(error)}`);
|
|
1502
|
+
process.exit(error instanceof CliError ? error.exitCode : 1);
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
//#endregion
|
|
1507
|
+
export { defaultIo, describeError, main, parseArgs };
|