sayren 0.3.0 → 0.3.1

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.
Files changed (2) hide show
  1. package/dist/index.mjs +67 -67
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -9,7 +9,7 @@ import { createHash, randomBytes } from "node:crypto";
9
9
  import { createServer } from "node:http";
10
10
 
11
11
  //#region package.json
12
- var version = "0.3.0";
12
+ var version = "0.3.1";
13
13
 
14
14
  //#endregion
15
15
  //#region src/client.ts
@@ -21,8 +21,8 @@ var version = "0.3.0";
21
21
  const CLI_VERSION = version;
22
22
  const CLI_USER_AGENT = `sayren-cli/${CLI_VERSION}`;
23
23
  /**
24
- * 계정 토큰(#71)을 Bearer로, 고른 스토어(`auth.storeId`)를 `X-Store-Id`로 싣는다. 401이면 `refresh`(자격 증명 파일의 refresh 회전)를
25
- * 한 번 부른 뒤 다시 보낸다. 토큰·스토어는 함수로 읽어 갱신·선택된 값을 따라간다.
24
+ * 계정 토큰(#71)을 Bearer로, 고른 상점(`auth.storeId`)을 `X-Store-Id`로 싣는다. 401이면 `refresh`(자격 증명 파일의 refresh 회전)를
25
+ * 한 번 부른 뒤 다시 보낸다. 토큰·상점은 함수로 읽어 갱신·선택된 값을 따라간다.
26
26
  */
27
27
  function clientOf(auth, fetchImpl = fetch, refresh) {
28
28
  return createStoreClient({
@@ -57,7 +57,7 @@ function detailsOf(error) {
57
57
  * 계약의 원천은 비공개 패키지 `@sayren/contracts`(`accounts.ts`)다. CLI·`@sayren/mcp`는 npm에 게시되므로 그 패키지에 의존하지 않고
58
58
  * 값을 옮겨 두었다 — `account-token.test.ts`가 계약과 대조한다.
59
59
  *
60
- * 여기의 디코딩은 **서명을 보지 않는다**. 모양(헤더 `typ`)으로 경로를 고르고 스토어 범위를 읽는 데만 쓴다. 판정은 api(매 요청 서명·폐기
60
+ * 여기의 디코딩은 **서명을 보지 않는다**. 모양(헤더 `typ`)으로 경로를 고르고 상점 범위를 읽는 데만 쓴다. 판정은 api(매 요청 서명·폐기
61
61
  * 확인)와 게이트웨이(`apps/mcp-gateway/src/auth.ts`, JWKS 서명 확인)가 한다.
62
62
  */
63
63
  /** JWT access token 헤더 `typ`(RFC 9068 §2.1) — 계약 `ACCOUNT_ACCESS_TOKEN_TYPE` */
@@ -68,7 +68,7 @@ const PROD_API_ORIGIN = "https://api.sayren.app";
68
68
  const DEV_API_ORIGIN = "http://localhost:4001";
69
69
  /** CLI의 accounts 클라이언트(public, PKCE, 루프백 임의 포트, 디바이스 코드) — accounts `token-config.ts` `CLI_CLIENT_ID` */
70
70
  const CLI_CLIENT_ID = "sayren-cli";
71
- /** CLI 로그인 스코프 — 스토어 목록·스토어 정보 조회·스토어프론트(pull·deploy) */
71
+ /** CLI 로그인 스코프 — 상점 목록·상점 정보 조회·스토어프론트(pull·deploy) */
72
72
  const CLI_LOGIN_SCOPE = "openid email offline_access stores:read store:r storefront:rw";
73
73
  /** accounts 엔드포인트(Better Auth `basePath` `/api/auth`) */
74
74
  function accountsEndpoints(accountsOrigin) {
@@ -125,24 +125,24 @@ function isAccountToken(token) {
125
125
  return accountTokenClaimsOf(token) !== null;
126
126
  }
127
127
  /**
128
- * 폐기된 옛 스토어 API 토큰(`sy_pat_…`) — #71 R1 뒤 api가 받지 않는다(401 `TOKEN_TYPE_RETIRED`). 받는 쪽은 api에 보내지 않고
128
+ * 폐기된 옛 상점 API 토큰(`sy_pat_…`) — #71 R1 뒤 api가 받지 않는다(401 `TOKEN_TYPE_RETIRED`). 받는 쪽은 api에 보내지 않고
129
129
  * 계정 액세스 토큰을 만들라고 바로 안내한다.
130
130
  */
131
131
  function isRetiredStorePat(token) {
132
132
  return token.startsWith("sy_pat_");
133
133
  }
134
- /** 토큰의 스토어 범위가 스토어 하나면 그 id(헤더 없이 쓸 수 있다). 아니면 null */
134
+ /** 토큰의 상점 범위가 상점 하나면 그 id(헤더 없이 쓸 수 있다). 아니면 null */
135
135
  function singleStoreOf(claims) {
136
136
  const stores = claims?.sayren_stores;
137
137
  return Array.isArray(stores) && stores.length === 1 && typeof stores[0] === "string" ? stores[0] : null;
138
138
  }
139
- /** 이 스토어가 토큰의 범위 안인가 */
139
+ /** 이 상점이 토큰의 범위 안인가 */
140
140
  function storeInScope(claims, storeId) {
141
141
  const stores = claims?.sayren_stores;
142
142
  if (stores === "*") return true;
143
143
  return Array.isArray(stores) && stores.includes(storeId);
144
144
  }
145
- /** 토큰 발급 시 선택한 스토어 id 형식(관리 API `X-Store-Id`) */
145
+ /** 토큰 발급 시 선택한 상점 id 형식(관리 API `X-Store-Id`) */
146
146
  const STORE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
147
147
 
148
148
  //#endregion
@@ -157,13 +157,13 @@ const STORE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
157
157
  * 스스로 OAuth(accounts)를 한다. 계정 토큰은 폴더가 아니라 사람에게 속한다(CLI는 사용자 설정 폴더에 둔다).
158
158
  * - 폐기된 옛 API 토큰(`sy_pat_…`)이 든 `Authorization` 헤더는 쓸 때 지운다(R1 뒤 게이트웨이가 거절한다). 셀러가 직접 넣은 다른
159
159
  * `Authorization`(계정 액세스 토큰)은 그대로 둔다.
160
- * - 다른 서버 항목과 `sayren` 항목의 다른 헤더는 그대로 둔다. `sayren` 항목의 `type`·`url`·스토어 헤더만 바꾼다.
160
+ * - 다른 서버 항목과 `sayren` 항목의 다른 헤더는 그대로 둔다. `sayren` 항목의 `type`·`url`·상점 헤더만 바꾼다.
161
161
  */
162
162
  const MCP_JSON_FILE = ".mcp.json";
163
163
  const MCP_SERVER_NAME = "sayren";
164
164
  /** 원격 MCP 주소. 게이트웨이는 운영 api 하나만 바라본다 — 다른 api 주소를 쓰는 폴더도 이 주소를 쓴다 */
165
165
  const REMOTE_MCP_URL = "https://mcp.sayren.app/mcp";
166
- /** 관리 API의 테넌트 헤더와 같은 이름이다(게이트웨이·api가 토큰의 스토어 범위와 대조한다) */
166
+ /** 관리 API의 테넌트 헤더와 같은 이름이다(게이트웨이·api가 토큰의 상점 범위와 대조한다) */
167
167
  const STORE_ID_HEADER = "X-Store-Id";
168
168
  var McpJsonError = class extends Error {
169
169
  constructor(message) {
@@ -227,7 +227,7 @@ function mergeMcpJson(existing, entry) {
227
227
  removedRetiredToken
228
228
  };
229
229
  }
230
- /** `sayren` 항목의 스토어(`X-Store-Id`). 항목·헤더가 없으면 null이다. 형식이 틀린 파일은 `McpJsonError` */
230
+ /** `sayren` 항목의 상점(`X-Store-Id`). 항목·헤더가 없으면 null이다. 형식이 틀린 파일은 `McpJsonError` */
231
231
  function sayrenStoreIdOf(text) {
232
232
  const root = parse(text);
233
233
  const entry = (isObject(root.mcpServers) ? root.mcpServers : {})[MCP_SERVER_NAME];
@@ -360,7 +360,7 @@ function readMcpText(dir) {
360
360
  const path = join(dir, MCP_FILE);
361
361
  return existsSync(path) ? readFileSync(path, "utf8") : null;
362
362
  }
363
- /** `.mcp.json`의 `sayren` 스토어(`X-Store-Id`). 파일·항목이 없으면 null이다 */
363
+ /** `.mcp.json`의 `sayren` 상점(`X-Store-Id`). 파일·항목이 없으면 null이다 */
364
364
  function readMcpStoreId(dir) {
365
365
  const text = readMcpText(dir);
366
366
  if (text === null) return null;
@@ -953,7 +953,7 @@ async function revokeRefresh(io, entry) {
953
953
  //#endregion
954
954
  //#region src/account-auth.ts
955
955
  /**
956
- * 명령이 토큰을 쓰기 전 준비(#71) — 자격 증명 파일 토큰의 갱신, 계정 토큰의 스토어 고르기.
956
+ * 명령이 토큰을 쓰기 전 준비(#71) — 자격 증명 파일 토큰의 갱신, 계정 토큰의 상점 고르기.
957
957
  */
958
958
  /** 자격 증명 파일에서 토큰을 읽는 함수(`resolveAuth`에 넘긴다) */
959
959
  function credentialTokenReader(env) {
@@ -975,8 +975,8 @@ async function readyAuth(auth, io) {
975
975
  };
976
976
  }
977
977
  /**
978
- * 계정 토큰의 스토어(`X-Store-Id`) — 후보(`--store`·`--project` 등 명령이 준 순서) → `SAYREN_STORE_ID` → 폴더의 상태·`.mcp.json` →
979
- * 토큰의 스토어가 하나면 그 스토어 → `GET /v1/me/stores`(하나면 그 스토어, 여럿이면 대화형 선택, 비대화형이면 오류).
978
+ * 계정 토큰의 상점(`X-Store-Id`) — 후보(`--store`·`--project` 등 명령이 준 순서) → `SAYREN_STORE_ID` → 폴더의 상태·`.mcp.json` →
979
+ * 토큰의 상점이 하나면 그 상점 → `GET /v1/me/stores`(하나면 그 상점, 여럿이면 대화형 선택, 비대화형이면 오류).
980
980
  */
981
981
  async function selectStore(auth, client, io, candidates, folderStore = null) {
982
982
  const claims = accountTokenClaimsOf(auth.token);
@@ -986,8 +986,8 @@ async function selectStore(auth, client, io, candidates, folderStore = null) {
986
986
  folderStore
987
987
  ].find((value) => !!value?.trim());
988
988
  if (explicit) {
989
- if (!STORE_ID_PATTERN.test(explicit)) throw new CliError(`스토어 id 형식이 아닙니다: ${explicit}`);
990
- if (!storeInScope(claims, explicit)) throw new CliError(`스토어 ${explicit}가 이 토큰의 스토어 범위 밖입니다. 그 스토어가 포함되게 다시 로그인하거나(npx sayren login) 다른 토큰을 쓰십시오`);
989
+ if (!STORE_ID_PATTERN.test(explicit)) throw new CliError(`상점 id 형식이 아닙니다: ${explicit}`);
990
+ if (!storeInScope(claims, explicit)) throw new CliError(`상점 ${explicit}가 이 토큰의 상점 범위 밖입니다. 그 상점이 포함되게 다시 로그인하거나(npx sayren login) 다른 토큰을 쓰십시오`);
991
991
  auth.storeId = explicit;
992
992
  return explicit;
993
993
  }
@@ -1000,21 +1000,21 @@ async function selectStore(auth, client, io, candidates, folderStore = null) {
1000
1000
  try {
1001
1001
  stores = (await client.auth.listMyStores()).filter((store) => storeInScope(claims, store.storeId));
1002
1002
  } catch {
1003
- throw new CliError("스토어를 고르지 못했습니다. --store <storeId> 또는 SAYREN_STORE_ID로 스토어를 주십시오");
1003
+ throw new CliError("상점을 고르지 못했습니다. --store <storeId> 또는 SAYREN_STORE_ID로 상점을 주십시오");
1004
1004
  }
1005
- if (stores.length === 0) throw new CliError("이 계정(토큰 범위)에 쓸 수 있는 스토어가 없습니다");
1005
+ if (stores.length === 0) throw new CliError("이 계정(토큰 범위)에 쓸 수 있는 상점이 없습니다");
1006
1006
  if (stores.length === 1) {
1007
1007
  auth.storeId = stores[0].storeId;
1008
1008
  return auth.storeId;
1009
1009
  }
1010
1010
  const labels = stores.map((store) => `${store.storeId} — ${store.storeName} (${store.role})`);
1011
- const picked = io.select ? await io.select("스토어를 고르십시오", labels) : null;
1011
+ const picked = io.select ? await io.select("상점을 고르십시오", labels) : null;
1012
1012
  const index = picked ? labels.indexOf(picked) : -1;
1013
- if (index < 0) throw new CliError(`스토어가 여럿입니다. --store <storeId> 또는 SAYREN_STORE_ID로 고르십시오:\n${labels.map((label) => ` ${label}`).join("\n")}`);
1013
+ if (index < 0) throw new CliError(`상점이 여럿입니다. --store <storeId> 또는 SAYREN_STORE_ID로 고르십시오:\n${labels.map((label) => ` ${label}`).join("\n")}`);
1014
1014
  auth.storeId = stores[index].storeId;
1015
1015
  return auth.storeId;
1016
1016
  }
1017
- /** 계정 로그인 폴더의 `.mcp.json` 안내 — 토큰 없이 주소·스토어만 쓴다 */
1017
+ /** 계정 로그인 폴더의 `.mcp.json` 안내 — 토큰 없이 주소·상점만 쓴다 */
1018
1018
  const ACCOUNT_MCP_NOTE = `${MCP_FILE}에는 토큰을 쓰지 않았습니다 — MCP 클라이언트가 연결할 때 Sayren 계정으로 로그인합니다`;
1019
1019
 
1020
1020
  //#endregion
@@ -1039,7 +1039,7 @@ const RULES = [
1039
1039
  {
1040
1040
  id: "tenant-header",
1041
1041
  title: "테넌트는 `X-Store-Code` 헤더로 보낸다",
1042
- why: "공유 API 호스트에서 스토어를 가르는 값이다. 빠지면 다른 스토어 데이터가 보이거나 404가 난다",
1042
+ why: "공유 API 호스트에서 상점을 가르는 값이다. 빠지면 다른 상점 데이터가 보이거나 404가 난다",
1043
1043
  fix: "`createStorefrontClient`에 `storeCode`를 넘긴다. SDK가 헤더를 붙인다. 서브도메인으로 서비스할 때만 생략한다",
1044
1044
  example: "createStorefrontClient({ baseUrl: API_BASE_URL, storeCode: resolveStoreCode() });",
1045
1045
  doc: "/guides/storefront-sdk",
@@ -1120,7 +1120,7 @@ const RULES = [
1120
1120
  {
1121
1121
  id: "checkout-origin",
1122
1122
  title: "복귀 주소는 코드에 고정하지 않고 배포된 주소(요청 origin)에서 만든다",
1123
- why: "주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 제대로 돌아온다. 셀러 콘솔 설정 › 결제의 결제 도메인은 선택형 허용 목록이다. 비워 두면 https 복귀 주소를 모두 받고, 등록하면 목록 밖의 복귀 주소를 서버가 거절한다",
1123
+ why: "주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 제대로 돌아온다. 상점 플랫폼 설정 › 결제의 결제 도메인은 선택형 허용 목록이다. 비워 두면 https 복귀 주소를 모두 받고, 등록하면 목록 밖의 복귀 주소를 서버가 거절한다",
1124
1124
  fix: "서버 함수에서 `getRequestUrl()`의 origin으로 복귀 주소를 만든다. 결제 도메인을 등록해 두었다면 배포 도메인도 목록에 넣는다(테스트 결제는 localhost가 항상 허용된다)",
1125
1125
  example: "const returnUrl = new URL(PAYMENT_RETURN_PATH, new URL(getRequestUrl()).origin);\nawait api.checkout.startPayment(checkoutId, { option, returnUrl: returnUrl.toString() });",
1126
1126
  doc: "/guides/payment-settings",
@@ -1165,8 +1165,8 @@ const RULES = [
1165
1165
  {
1166
1166
  id: "buyer-signup",
1167
1167
  title: "이메일·비밀번호 로그인을 받으면 가입 화면도 둔다(`auth.signUp`)",
1168
- why: "비밀번호 로그인만 있고 가입이 없으면 소셜 로그인을 켜지 않은 스토어에서 새 구매자가 계정을 만들 수 없다",
1169
- fix: "스토어 `loginMethods`에 `password`가 있으면 가입 화면을 둔다. 약관·개인정보 수집이용 동의(필수)와 마케팅 수신(선택)을 받아 서버 함수에서 `auth.signUp({ email, password, name, agreements, cartToken })`을 부르고, 받은 토큰 쌍을 HttpOnly 쿠키에 담는다",
1168
+ why: "비밀번호 로그인만 있고 가입이 없으면 소셜 로그인을 켜지 않은 상점에서 새 구매자가 계정을 만들 수 없다",
1169
+ fix: "상점 `loginMethods`에 `password`가 있으면 가입 화면을 둔다. 약관·개인정보 수집이용 동의(필수)와 마케팅 수신(선택)을 받아 서버 함수에서 `auth.signUp({ email, password, name, agreements, cartToken })`을 부르고, 받은 토큰 쌍을 HttpOnly 쿠키에 담는다",
1170
1170
  example: "const tokens = await authFor().signUp({\n email, password, name,\n agreements: { terms: true, privacy: true, marketing },\n cartToken: readCartToken() ?? undefined, // 비회원 장바구니를 합친다\n});\nsignIn(tokens);",
1171
1171
  doc: "/guides/storefront-auth",
1172
1172
  templateFile: "src/routes/signup.tsx"
@@ -1183,7 +1183,7 @@ const RULES = [
1183
1183
  {
1184
1184
  id: "analytics-start",
1185
1185
  title: "방문 분석은 브라우저에서 한 번 시작한다(`@sayren/storefront-sdk/analytics`)",
1186
- why: "빠지면 셀러 콘솔 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다",
1186
+ why: "빠지면 상점 플랫폼 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다",
1187
1187
  fix: "루트 컴포넌트의 effect에서 `createAnalytics`를 한 번 부르고(설정은 loader가 내려준다), 화면은 그 인스턴스의 `track`으로 행동을 남긴다. 장바구니·결제 시작·구매는 서버가 기록하므로 보내지 않는다",
1188
1188
  example: "import { createAnalytics } from \"@sayren/storefront-sdk/analytics\";\n\nuseEffect(() => {\n startAnalytics({ apiBaseUrl, storeCode, debug }); // 브라우저에서 한 번\n}, []);",
1189
1189
  doc: "/guides/storefront-analytics",
@@ -1209,8 +1209,8 @@ const RULES = [
1209
1209
  },
1210
1210
  {
1211
1211
  id: "checkout-availability",
1212
- title: "주문을 받지 않는 스토어면 주문 버튼을 그리지 않는다(`checkoutAvailable`)",
1213
- why: "결제 수단이 없거나 공개한 쇼핑몰의 실결제가 아직 열리지 않은 스토어는 결제 요청이 409 `PAYMENT_NOT_CONFIGURED`다. 버튼을 그대로 두면 구매자가 담고 주문서까지 가서야 결제가 안 된다는 것을 안다",
1212
+ title: "주문을 받지 않는 상점이면 주문 버튼을 그리지 않는다(`checkoutAvailable`)",
1213
+ why: "결제 수단이 없거나 공개한 쇼핑몰의 실결제가 아직 열리지 않은 상점은 결제 요청이 409 `PAYMENT_NOT_CONFIGURED`다. 버튼을 그대로 두면 구매자가 담고 주문서까지 가서야 결제가 안 된다는 것을 안다",
1214
1214
  fix: "`GET /store`(`api.store.get()`)의 `checkoutAvailable`이 false면 장바구니 담기·바로구매·주문 버튼과 헤더의 장바구니를 숨기고 「지금은 온라인 주문을 받지 않습니다」를 보인다. `customerCenterPhone`이 있으면 문의 전화를 함께 보인다",
1215
1215
  example: "const store = await apiFor().store.get();\nif (!store.checkoutAvailable) {\n // 주문 버튼 대신 안내\n return <p>지금은 온라인 주문을 받지 않습니다</p>;\n}",
1216
1216
  doc: "/guides/storefront-sdk",
@@ -1236,14 +1236,14 @@ const SERVER_FILE = /\.server\.(ts|tsx)$/;
1236
1236
  /** 토큰을 브라우저 저장소에 쓰는 코드 */
1237
1237
  const BROWSER_TOKEN_STORAGE = /(localStorage|sessionStorage)\.setItem\(\s*[`'"][^`'"]*([Tt]oken|refresh|session|sayren_member)/;
1238
1238
  /**
1239
- * 직접 입력값이 새는 자리 — URL 검색 파라미터(`search`·`searchParams`), 브라우저 저장소, 콘솔.
1239
+ * 직접 입력값이 새는 자리 — URL 검색 파라미터(`search`·`searchParams`), 브라우저 저장소, 상점 플랫폼.
1240
1240
  * 같은 식 안에서 `customInputs`가 보일 때만 잡는다.
1241
1241
  */
1242
1242
  const CUSTOM_INPUT_LEAKS = [
1243
1243
  [/search\s*[:=]\s*\{\{?[^}]*\bcustomInputs\b/, "URL 검색 파라미터"],
1244
1244
  [/searchParams\.(set|append)\([^)]*\bcustomInputs\b/, "URL 검색 파라미터"],
1245
1245
  [/(localStorage|sessionStorage)\.setItem\([^)]*\bcustomInputs\b/, "브라우저 저장소"],
1246
- [/console\.(log|info|warn|error|debug)\([^)]*\bcustomInputs\b/, "콘솔 로그"]
1246
+ [/console\.(log|info|warn|error|debug)\([^)]*\bcustomInputs\b/, "상점 플랫폼 로그"]
1247
1247
  ];
1248
1248
  /**
1249
1249
  * 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
@@ -1317,7 +1317,7 @@ function verifySources(files) {
1317
1317
  if (!matches(/\b(auth|authFor\(\))\.signOut\(|\.signOut\(\s*accessToken\b/)) inProject("buyer-signout", "로그아웃에서 리프레시 토큰을 폐기하는 코드가 없다(`auth.signOut(accessToken)`)");
1318
1318
  if (!has("reconsentRequired")) inProject("buyer-reconsent", "재동의가 필요한 구매자를 처리하는 코드가 없다(`reconsentRequired`)");
1319
1319
  }
1320
- if (has("startPayment(") && !has("checkoutAvailable")) inProject("checkout-availability", "결제를 시작하지만 `GET /store`의 `checkoutAvailable`을 읽는 코드가 없다. 주문을 받지 않는 스토어에서도 주문 버튼이 보인다");
1320
+ if (has("startPayment(") && !has("checkoutAvailable")) inProject("checkout-availability", "결제를 시작하지만 `GET /store`의 `checkoutAvailable`을 읽는 코드가 없다. 주문을 받지 않는 상점에서도 주문 버튼이 보인다");
1321
1321
  if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) inProject("analytics-start", "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다");
1322
1322
  const clientQuery = matches(/\buse(Suspense)?Query\(/);
1323
1323
  if (clientQuery && !has("ensureQueryData(") && !has("prefetchQuery(")) inFile("loader-first", clientQuery, "TanStack Query로 데이터를 받지만 loader에서 `queryClient.ensureQueryData`로 미리 받는 코드가 없다. 서버 렌더에 데이터가 빠진다", /\buse(Suspense)?Query\(/);
@@ -1348,7 +1348,7 @@ function report(finding) {
1348
1348
  /** 위반을 고치는 순서 — 에이전트가 무엇부터 할지 묻지 않게 한다 */
1349
1349
  function nextSteps(findings, target) {
1350
1350
  if (!findings.length) return target === "remote" ? [
1351
- "사용자에게 콘솔 스토어프론트 공간의 [미리보기 열기]로 화면을 확인해 달라고 전한다.",
1351
+ "사용자에게 상점 플랫폼 스토어프론트 공간의 [미리보기 열기]로 화면을 확인해 달라고 전한다.",
1352
1352
  "`create_version`으로 버전을 만들고 `get_build`로 빌드 결과를 확인한다.",
1353
1353
  "정적 검사가 잡지 못하는 것은 실제 결제 한 번이 잡는다. 발행 전에 테스트 결제를 권한다."
1354
1354
  ] : [
@@ -1858,7 +1858,7 @@ const MAX_BLOB_ROUNDS = 3;
1858
1858
  /** 올리기 한 번의 내용 합계 상한 — base64(×4/3)가 api 본문 8MiB 안에 든다 */
1859
1859
  const BLOB_BATCH_BYTES = 5 * 1024 * 1024;
1860
1860
  /**
1861
- * 토큰·api 주소를 정하고(자격 증명 파일 포함) 준비한 관리 API 클라이언트 — 스토어를 고른다(`candidates` 먼저).
1861
+ * 토큰·api 주소를 정하고(자격 증명 파일 포함) 준비한 관리 API 클라이언트 — 상점을 고른다(`candidates` 먼저).
1862
1862
  */
1863
1863
  async function apiFor(flags, dir, state, io, candidates = []) {
1864
1864
  const auth = resolveAuth(flags, dir, state, io.env, credentialTokenReader(io.env));
@@ -1895,10 +1895,10 @@ async function pull(flags, io) {
1895
1895
  migrateLegacy(authDir, io.out);
1896
1896
  const { auth, client } = await apiFor(flags, authDir, null, io, [flags.project]);
1897
1897
  const hosting = await client.storefront.get();
1898
- if (!hosting.site) throw new CliError("이 스토어에는 호스팅 사이트가 없습니다. 셀러 콘솔 › 스토어프론트에서 사이트를 만든 뒤 다시 받으십시오");
1898
+ if (!hosting.site) throw new CliError("이 상점에는 호스팅 사이트가 없습니다. 상점 플랫폼 › 스토어프론트에서 사이트를 만든 뒤 다시 받으십시오");
1899
1899
  if (!hosting.editingEnabled) throw new CliError("지금은 스토어프론트 소스를 받을 수 없습니다(편집 기능이 꺼져 있습니다)");
1900
1900
  const source = await client.storefront.getSource();
1901
- if (flags.project && flags.project !== source.storeId) throw new CliError(`받은 스토어(${source.storeId})와 --project(${flags.project})가 다릅니다. --project 또는 SAYREN_STORE_ID를 확인하십시오`);
1901
+ if (flags.project && flags.project !== source.storeId) throw new CliError(`받은 상점(${source.storeId})과 --project(${flags.project})가 다릅니다. --project 또는 SAYREN_STORE_ID를 확인하십시오`);
1902
1902
  const store = {
1903
1903
  storeId: source.storeId,
1904
1904
  storeCode: source.storeCode
@@ -1941,7 +1941,7 @@ async function pull(flags, io) {
1941
1941
  if (sha256Hex(tgz) !== source.archive.sha256) throw new CliError("받은 압축 파일이 손상됐습니다. 다시 시도하십시오");
1942
1942
  const files = await unpackArchive(tgz);
1943
1943
  const collisions = caseCollisions(files.keys());
1944
- if (collisions.length > 0) throw new CliError(`대소문자만 다른 경로가 있어 받지 않았습니다(대소문자를 가리지 않는 파일 시스템에서 겹칩니다): ${collisions.map((group) => group.join(" · ")).join(", ")}. 콘솔·AI 편집으로 한쪽 이름을 바꾼 뒤 다시 받으십시오`);
1944
+ if (collisions.length > 0) throw new CliError(`대소문자만 다른 경로가 있어 받지 않았습니다(대소문자를 가리지 않는 파일 시스템에서 겹칩니다): ${collisions.map((group) => group.join(" · ")).join(", ")}. 상점 플랫폼·AI 편집으로 한쪽 이름을 바꾼 뒤 다시 받으십시오`);
1945
1945
  const diff = diffLocal(target, files);
1946
1946
  if (existing.length > 0) {
1947
1947
  const watched = diff.overwrite.filter((path) => path === "package.json" || path === "vite.config.ts");
@@ -1978,12 +1978,12 @@ async function pull(flags, io) {
1978
1978
  const written = writeMcpJson(target, { storeId: store.storeId });
1979
1979
  io.out(`받음 → ${relativeOf(io, target)} (${files.size}개${removed ? `, 원격에 없는 ${removed}개 지움` : ""})`);
1980
1980
  if (written === "retired-token-removed") io.out(`알림: ${RETIRED_MCP_TOKEN_NOTICE}`);
1981
- io.out(`${MCP_FILE} 작성 — sayren 원격 MCP(${REMOTE_MCP_URL})와 스토어. ${ACCOUNT_MCP_NOTE}`);
1982
- if (auth.apiOrigin !== DEFAULT_API_ORIGIN) io.out(`알림: 원격 MCP는 운영 api(${DEFAULT_API_ORIGIN})에만 연결됩니다. ${auth.apiOrigin}의 스토어로는 ${MCP_FILE}의 연결이 거부됩니다`);
1983
- io.out(`${ENV_FILE} 작성 — 스토어프론트 API 주소·스토어 코드(개발 서버용)`);
1981
+ io.out(`${MCP_FILE} 작성 — sayren 원격 MCP(${REMOTE_MCP_URL})와 상점. ${ACCOUNT_MCP_NOTE}`);
1982
+ if (auth.apiOrigin !== DEFAULT_API_ORIGIN) io.out(`알림: 원격 MCP는 운영 api(${DEFAULT_API_ORIGIN})에만 연결됩니다. ${auth.apiOrigin}의 상점으로는 ${MCP_FILE}의 연결이 거부됩니다`);
1983
+ io.out(`${ENV_FILE} 작성 — 스토어프론트 API 주소·상점 코드(개발 서버용)`);
1984
1984
  io.out(`${STATE_FILE} 작성 — 사이트 ${source.siteId}·받은 작업 트리${addedIgnore.length ? ` (.gitignore에 ${addedIgnore.join(" ")} 추가)` : ""}`);
1985
- if (source.editSessionOpen) io.out("알림: 콘솔·AI 편집 환경이 열려 있습니다. 배포 전에 그 편집이 끝났는지 확인하십시오");
1986
- io.out(`\n다음:\n cd ${relativeOf(io, target)}\n npm install --ignore-scripts # 의존성의 설치 스크립트를 돌리지 않습니다\n npm run dev # http://localhost:4010 — 운영 스토어 데이터로 동작합니다\n npx sayren deploy # 빌드 → 버전 → 발행`);
1985
+ if (source.editSessionOpen) io.out("알림: 상점 플랫폼·AI 편집 환경이 열려 있습니다. 배포 전에 그 편집이 끝났는지 확인하십시오");
1986
+ io.out(`\n다음:\n cd ${relativeOf(io, target)}\n npm install --ignore-scripts # 의존성의 설치 스크립트를 돌리지 않습니다\n npm run dev # http://localhost:4010 — 운영 상점 데이터로 동작합니다\n npx sayren deploy # 빌드 → 버전 → 발행`);
1987
1987
  return 0;
1988
1988
  }
1989
1989
  async function verify(flags, io) {
@@ -2028,10 +2028,10 @@ async function deploy(flags, io) {
2028
2028
  printChecks(tree, checks, io);
2029
2029
  if (checks.blockingRules.length > 0 || checks.problems.length > 0) throw new CliError("로컬 검사를 통과하지 못해 배포하지 않았습니다. 위 항목을 고친 뒤 다시 시도하십시오");
2030
2030
  const site = (await client.storefront.get()).site;
2031
- if (!site || site.siteId !== rc.siteId) throw new CliError(`이 폴더의 사이트(${rc.siteId})가 스토어의 지금 사이트와 다릅니다. npx sayren pull로 다시 받으십시오`);
2031
+ if (!site || site.siteId !== rc.siteId) throw new CliError(`이 폴더의 사이트(${rc.siteId})가 상점의 지금 사이트와 다릅니다. npx sayren pull로 다시 받으십시오`);
2032
2032
  if (flags.force && !flags.yes) {
2033
2033
  if (!io.confirm) throw new CliError("--force는 원격 작업 트리를 덮어씁니다. 확인할 수 없는 환경이면 --yes를 함께 주십시오");
2034
- if (!await io.confirm("원격 작업 트리(콘솔·AI 편집 포함)를 이 폴더 내용으로 덮어쓰고, 열린 편집 환경을 끕니다. 계속하시겠습니까?")) {
2034
+ if (!await io.confirm("원격 작업 트리(상점 플랫폼·AI 편집 포함)를 이 폴더 내용으로 덮어쓰고, 열린 편집 환경을 끕니다. 계속하시겠습니까?")) {
2035
2035
  io.out("취소했습니다");
2036
2036
  return 1;
2037
2037
  }
@@ -2043,7 +2043,7 @@ async function deploy(flags, io) {
2043
2043
  if (error instanceof ApprovalPendingError) {
2044
2044
  io.out("작업 트리 바꾸기는 승인이 필요합니다(에이전트 토큰이 package.json·vite.config.ts를 바꿉니다). 승인된 뒤 npx sayren deploy를 다시 돌리면 빌드·발행합니다");
2045
2045
  if (error.approval.approvalUrl) io.out(`승인 요청: ${error.approval.approvalUrl}`);
2046
- else io.out(`승인 요청 id: ${error.approvalId} (셀러 콘솔 › 설정 › 보안 › 승인 요청)`);
2046
+ else io.out(`승인 요청 id: ${error.approvalId} (상점 플랫폼 › 설정 › 보안 › 승인 요청)`);
2047
2047
  return EXIT_APPROVAL_PENDING;
2048
2048
  }
2049
2049
  throw error;
@@ -2061,7 +2061,7 @@ async function deploy(flags, io) {
2061
2061
  });
2062
2062
  } catch (error) {
2063
2063
  const code = codeOf(error);
2064
- if (code === "STOREFRONT_BUILD_IN_PROGRESS") throw new CliError("이 사이트에 끝나지 않은 빌드가 있습니다(콘솔·AI 또는 다른 배포). 작업 트리는 올렸습니다 — npx sayren status로 확인한 뒤 다시 배포하십시오");
2064
+ if (code === "STOREFRONT_BUILD_IN_PROGRESS") throw new CliError("이 사이트에 끝나지 않은 빌드가 있습니다(상점 플랫폼·AI 또는 다른 배포). 작업 트리는 올렸습니다 — npx sayren status로 확인한 뒤 다시 배포하십시오");
2065
2065
  if (code === "SOURCE_CHANGED") throw new CliError("올린 직후 다른 편집이 끼어 버전을 만들지 않았습니다. npx sayren pull --force로 받아 합친 뒤 다시 배포하십시오");
2066
2066
  throw error;
2067
2067
  }
@@ -2073,7 +2073,7 @@ async function deploy(flags, io) {
2073
2073
  }
2074
2074
  io.out(`빌드 성공 — v${created.version.number}${build.durationMs ? ` (${Math.round(build.durationMs / 1e3)}초)` : ""}`);
2075
2075
  if (flags.publish === false) {
2076
- io.out(`발행하지 않았습니다(--no-publish). 콘솔 › 스토어프론트 › 버전에서 v${created.version.number}를 발행할 수 있습니다`);
2076
+ io.out(`발행하지 않았습니다(--no-publish). 상점 플랫폼 › 스토어프론트 › 버전에서 v${created.version.number}를 발행할 수 있습니다`);
2077
2077
  return 0;
2078
2078
  }
2079
2079
  try {
@@ -2085,10 +2085,10 @@ async function deploy(flags, io) {
2085
2085
  if (error instanceof ApprovalPendingError) {
2086
2086
  io.out(`발행은 승인이 필요합니다(에이전트 토큰). 셀러가 승인하면 v${created.version.number}가 발행됩니다`);
2087
2087
  if (error.approval.approvalUrl) io.out(`승인 요청: ${error.approval.approvalUrl}`);
2088
- else io.out(`승인 요청 id: ${error.approvalId} (셀러 콘솔 › 설정 › 보안 › 승인 요청)`);
2088
+ else io.out(`승인 요청 id: ${error.approvalId} (상점 플랫폼 › 설정 › 보안 › 승인 요청)`);
2089
2089
  return EXIT_APPROVAL_PENDING;
2090
2090
  }
2091
- if (codeOf(error) === "STOREFRONT_SITE_CHANGED") throw new CliError(`다른 사람이 그 사이 다른 버전을 발행했습니다. v${created.version.number}는 만들어 뒀습니다 — 콘솔에서 확인한 뒤 발행하십시오`);
2091
+ if (codeOf(error) === "STOREFRONT_SITE_CHANGED") throw new CliError(`다른 사람이 그 사이 다른 버전을 발행했습니다. v${created.version.number}는 만들어 뒀습니다 — 상점 플랫폼에서 확인한 뒤 발행하십시오`);
2092
2092
  throw error;
2093
2093
  }
2094
2094
  }
@@ -2139,9 +2139,9 @@ async function replaceSource(client, tree, rc, force, io) {
2139
2139
  dependencyWarnings: []
2140
2140
  };
2141
2141
  }
2142
- throw new CliError("원격 작업 트리가 pull 이후 바뀌었습니다(콘솔·AI 편집). 덮어쓰지 않았습니다.\n · 원격을 받으려면: npx sayren pull --force (로컬 변경은 먼저 커밋·백업하십시오)\n · 이 폴더 내용으로 덮어쓰려면: npx sayren deploy --force");
2142
+ throw new CliError("원격 작업 트리가 pull 이후 바뀌었습니다(상점 플랫폼·AI 편집). 덮어쓰지 않았습니다.\n · 원격을 받으려면: npx sayren pull --force (로컬 변경은 먼저 커밋·백업하십시오)\n · 이 폴더 내용으로 덮어쓰려면: npx sayren deploy --force");
2143
2143
  }
2144
- if (code === "EDIT_SESSION_OPEN") throw new CliError("콘솔·AI 편집 환경이 열려 있어 올리지 않았습니다. 편집을 끝낸 뒤 다시 시도하거나 --force로 편집 환경을 끄고 진행하십시오");
2144
+ if (code === "EDIT_SESSION_OPEN") throw new CliError("상점 플랫폼·AI 편집 환경이 열려 있어 올리지 않았습니다. 편집을 끝낸 뒤 다시 시도하거나 --force로 편집 환경을 끄고 진행하십시오");
2145
2145
  if (code === "DEPENDENCY_REJECTED") throw new CliError(`쓸 수 없는 npm 의존성이 있어 올리지 않았습니다.\n${(Array.isArray(details.rejected) ? details.rejected : []).map((item) => ` · ${findingLine(item)}`).join("\n")}`);
2146
2146
  if (code === "DEPENDENCY_CHECK_UNAVAILABLE") throw new CliError("의존성 확인을 지금 할 수 없습니다. 잠시 뒤 다시 시도하십시오");
2147
2147
  if (code === "SOURCE_TOO_MANY_NEW_FILES") throw new CliError(`한 번에 올릴 수 있는 새 파일은 ${String(details.max ?? 800)}개까지입니다. 파일을 나눠 여러 번 배포하십시오(--no-publish로 중간 배포)`);
@@ -2297,7 +2297,7 @@ async function pasteAccountToken(flags, token, io) {
2297
2297
  await writeAccountMcpJson(dir, state, io);
2298
2298
  return 0;
2299
2299
  }
2300
- /** 쓸 수 있는 스토어 수 — api가 계정 토큰을 아직 받지 않거나 `stores:read`가 없으면 알리기만 한다 */
2300
+ /** 쓸 수 있는 상점 수 — api가 계정 토큰을 아직 받지 않거나 `stores:read`가 없으면 알리기만 한다 */
2301
2301
  async function reportStores(apiOrigin, token, io) {
2302
2302
  const auth = {
2303
2303
  token,
@@ -2309,16 +2309,16 @@ async function reportStores(apiOrigin, token, io) {
2309
2309
  const stores = await clientOf(auth, io.fetch).auth.listMyStores();
2310
2310
  const claims = accountTokenClaimsOf(token);
2311
2311
  const scoped = claims?.sayren_stores === "*" || !Array.isArray(claims?.sayren_stores) ? stores : stores.filter((store) => claims.sayren_stores.includes(store.storeId));
2312
- io.out(`쓸 수 있는 스토어 ${scoped.length}개`);
2312
+ io.out(`쓸 수 있는 상점 ${scoped.length}개`);
2313
2313
  } catch {
2314
- io.err("알림: 스토어 목록을 불러오지 못했습니다(권한 stores:read 또는 api 연결을 확인하십시오)");
2314
+ io.err("알림: 상점 목록을 불러오지 못했습니다(권한 stores:read 또는 api 연결을 확인하십시오)");
2315
2315
  }
2316
2316
  }
2317
- /** 받은 폴더면 `.mcp.json`을 토큰 없는 항목(주소·스토어)으로 맞춘다. 폐기된 옛 토큰 헤더는 지우고 알린다 */
2317
+ /** 받은 폴더면 `.mcp.json`을 토큰 없는 항목(주소·상점)으로 맞춘다. 폐기된 옛 토큰 헤더는 지우고 알린다 */
2318
2318
  async function writeAccountMcpJson(dir, state, io) {
2319
2319
  if (!state || !isPulledDir(dir)) return;
2320
2320
  if (writeMcpJson(dir, { storeId: state.storeId }) === "retired-token-removed") io.err(`알림: ${RETIRED_MCP_TOKEN_NOTICE}`);
2321
- io.out(`${relativeOf(io, join(dir, MCP_FILE))} 작성 — 원격 MCP 주소와 스토어. ${ACCOUNT_MCP_NOTE}`);
2321
+ io.out(`${relativeOf(io, join(dir, MCP_FILE))} 작성 — 원격 MCP 주소와 상점. ${ACCOUNT_MCP_NOTE}`);
2322
2322
  }
2323
2323
  /** `sayren logout` — refresh를 폐기하고(실패는 알리기만) 자격 증명 파일의 항목을 지운다 */
2324
2324
  async function logout(flags, io) {
@@ -2335,7 +2335,7 @@ async function logout(flags, io) {
2335
2335
  if (!revoked) io.err("알림: 계정 서버에서 로그인을 폐기하지 못했습니다(토큰은 이 컴퓨터에서 지웠습니다)");
2336
2336
  return 0;
2337
2337
  }
2338
- /** `sayren whoami` — 쓰는 토큰의 종류·계정·스토어 범위 */
2338
+ /** `sayren whoami` — 쓰는 토큰의 종류·계정·상점 범위 */
2339
2339
  async function whoami(flags, io) {
2340
2340
  const dir = resolve(io.cwd, flags.dir ?? ".");
2341
2341
  const auth = resolveAuth(flags, dir, existsSync(dir) ? readState(dir) : null, io.env, credentialTokenReader(io.env));
@@ -2345,9 +2345,9 @@ async function whoami(flags, io) {
2345
2345
  const entry = auth.tokenSource === "credentials" ? readCredential(io.env, auth.apiOrigin) : null;
2346
2346
  const email = (await client.auth.me().catch(() => null))?.email ?? entry?.email ?? "확인 불가";
2347
2347
  const kind = claims?.sayren_kind === "pat" ? "액세스 토큰" : "로그인";
2348
- const scope = claims?.sayren_stores === "*" ? "모든 스토어" : Array.isArray(claims?.sayren_stores) ? claims.sayren_stores.join(", ") || "없음" : "확인 불가";
2348
+ const scope = claims?.sayren_stores === "*" ? "모든 상점" : Array.isArray(claims?.sayren_stores) ? claims.sayren_stores.join(", ") || "없음" : "확인 불가";
2349
2349
  io.out(`${email} — ${kind}(${auth.tokenSource === "credentials" ? credentialsPathOf(io.env) : auth.tokenSource})`);
2350
- io.out(` 스토어 범위 ${scope}`);
2350
+ io.out(` 상점 범위 ${scope}`);
2351
2351
  if (claims?.scope) io.out(` 권한 ${claims.scope}`);
2352
2352
  return 0;
2353
2353
  }
@@ -2372,26 +2372,26 @@ const USAGE = `sayren ${CLI_VERSION} — 호스팅 스토어프론트 CLI
2372
2372
  명령:
2373
2373
  login Sayren 계정으로 로그인합니다. 로그인 토큰은 사용자 설정 폴더(~/.config/sayren/credentials.json)에 둡니다
2374
2374
  logout 로그인을 폐기하고 이 컴퓨터의 토큰을 지웁니다
2375
- whoami 쓰는 계정·토큰 종류·스토어 범위
2376
- pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .mcp.json·.env·.sayren/state.json을 씁니다
2375
+ whoami 쓰는 계정·토큰 종류·상점 범위
2376
+ pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 상점 코드)로 받고 .mcp.json·.env·.sayren/state.json을 씁니다
2377
2377
  deploy 로컬 검사 → 작업 트리 올리기 → 빌드 → 발행. 원격이 pull 이후 바뀌었으면 덮어쓰지 않습니다
2378
2378
  status 사이트·발행 버전·최근 빌드·편집 환경
2379
2379
  verify 로컬 규칙·경로·크기 검사(서버 빌드와 같은 규칙)
2380
2380
 
2381
2381
  토큰은 Sayren 계정 토큰만 받고 --token → SAYREN_TOKEN 환경 변수 → 로그인(credentials.json) 순서로 읽습니다.
2382
- 계정 토큰은 계정에 발급됩니다 — 스토어는 --store → SAYREN_STORE_ID → 폴더 설정 순서로 고릅니다(하나면 자동).
2382
+ 계정 토큰은 계정에 발급됩니다 — 상점은 --store → SAYREN_STORE_ID → 폴더 설정 순서로 고릅니다(하나면 자동).
2383
2383
  옛 API 토큰(sy_pat_…)은 더 쓸 수 없습니다. .mcp.json에는 토큰을 쓰지 않습니다(MCP 클라이언트가 스스로 로그인합니다).
2384
2384
  .mcp.json·.env·.sayren/은 .gitignore에 더하고 배포에 올리지 않습니다.
2385
2385
 
2386
2386
  옵션:
2387
- --project <storeId> pull: 받을 스토어(--store와 같습니다)
2388
- --dir <폴더> 작업 폴더(기본: 지금 폴더, pull은 스토어 코드)
2387
+ --project <storeId> pull: 받을 상점(--store와 같습니다)
2388
+ --dir <폴더> 작업 폴더(기본: 지금 폴더, pull은 상점 코드)
2389
2389
  --force pull: 같은 사이트를 받은 폴더에 원격을 다시 받습니다(지울·덮을 파일을 보여 주고 묻습니다)
2390
2390
  deploy: 원격 변경·열린 편집 환경을 무시하고 덮어씁니다(확인을 묻습니다)
2391
2391
  --yes --force의 확인을 묻지 않습니다(비대화형)
2392
2392
  --no-publish deploy: 버전까지만 만들고 발행하지 않습니다
2393
2393
  --message <메모> deploy: 버전 메모
2394
- --store <storeId> 다룰 스토어(SAYREN_STORE_ID)
2394
+ --store <storeId> 다룰 상점(SAYREN_STORE_ID)
2395
2395
  --device login: 브라우저 대신 기기 코드로 로그인합니다(SSH 등)
2396
2396
  --paste login: 액세스 토큰을 입력받습니다
2397
2397
  --api-origin <주소> api 주소(기본 https://api.sayren.app, SAYREN_API_ORIGIN). https만, localhost만 http
@@ -2554,7 +2554,7 @@ function describeError(error) {
2554
2554
  if (error instanceof ApiError) {
2555
2555
  if (error.status === 401 && error.code === "TOKEN_TYPE_RETIRED") return RETIRED_TOKEN_MESSAGE;
2556
2556
  if (error.status === 401) return "토큰이 올바르지 않거나 만료됐습니다(401). npx sayren login으로 다시 로그인하거나 새 액세스 토큰을 쓰십시오";
2557
- if (error.status === 403 && error.code === "STORE_NOT_GRANTED") return "이 토큰의 스토어 범위 밖입니다. 그 스토어를 포함해 다시 로그인하십시오(npx sayren login)";
2557
+ if (error.status === 403 && error.code === "STORE_NOT_GRANTED") return "이 토큰의 상점 범위 밖입니다. 그 상점을 포함해 다시 로그인하십시오(npx sayren login)";
2558
2558
  if (error.status === 403 && error.code === "INSUFFICIENT_SCOPE") return "토큰에 storefront:rw 스코프가 없습니다. 스코프를 더한 토큰을 쓰십시오";
2559
2559
  if (error.code === "TOO_MANY_REQUESTS") return "요청이 너무 많습니다. 잠시 뒤 다시 시도하십시오";
2560
2560
  return `${error.message} (${error.code}, HTTP ${error.status})`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sayren",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "sayren 스토어프론트 CLI — 호스팅 중인 사이트의 소스를 내려받고(pull) 로컬에서 고친 뒤 배포(deploy)합니다",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "node": ">=20.10"
14
14
  },
15
15
  "dependencies": {
16
- "@sayren/store-sdk": "^0.28.0"
16
+ "@sayren/store-sdk": "^0.28.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",