sayren 0.2.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 +1246 -511
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,28 +1,35 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
2
3
  import { stdin, stdout } from "node:process";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  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 { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join, relative, resolve, sep } from "node:path";
7
- import { spawnSync } from "node:child_process";
8
- import { createHash } from "node:crypto";
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { createServer } from "node:http";
9
10
 
10
11
  //#region package.json
11
- var version = "0.2.0";
12
+ var version = "0.3.1";
12
13
 
13
14
  //#endregion
14
15
  //#region src/client.ts
15
16
  /**
16
17
  * 관리 API 클라이언트 — `@sayren/store-sdk`(계약의 원천)를 쓴다. User-Agent `sayren-cli/{버전}`은 api가 버전의 만든 경로
17
- * (`createdVia: CLI`)를 적는 표시다(권한과 무관하다. 에이전트 PAT면 행위자는 여전히 에이전트이고 발행은 승인 대기다).
18
+ * (`createdVia: CLI`)를 적는 표시다(권한과 무관하다. 에이전트용 액세스 토큰이면 행위자는 여전히 에이전트이고 발행은 승인 대기다).
18
19
  * 토큰은 헤더에만 싣고 화면·로그에 찍지 않는다.
19
20
  */
20
21
  const CLI_VERSION = version;
21
22
  const CLI_USER_AGENT = `sayren-cli/${CLI_VERSION}`;
22
- function clientOf(auth, fetchImpl = fetch) {
23
+ /**
24
+ * 계정 토큰(#71)을 Bearer로, 고른 상점(`auth.storeId`)을 `X-Store-Id`로 싣는다. 401이면 `refresh`(자격 증명 파일의 refresh 회전)를
25
+ * 한 번 부른 뒤 다시 보낸다. 토큰·상점은 함수로 읽어 갱신·선택된 값을 따라간다.
26
+ */
27
+ function clientOf(auth, fetchImpl = fetch, refresh) {
23
28
  return createStoreClient({
24
29
  baseUrl: `${auth.apiOrigin}/v1`,
25
- auth: { apiToken: auth.token },
30
+ accessToken: () => auth.token,
31
+ storeId: () => auth.storeId ?? null,
32
+ ...refresh ? { refresh } : {},
26
33
  fetch: (input, init) => {
27
34
  const headers = new Headers(init?.headers);
28
35
  headers.set("user-agent", CLI_USER_AGENT);
@@ -41,6 +48,103 @@ function detailsOf(error) {
41
48
  return error instanceof ApiError ? error.details ?? {} : {};
42
49
  }
43
50
 
51
+ //#endregion
52
+ //#region ../mcp/src/account-token.ts
53
+ /**
54
+ * 계정 토큰(#71) — accounts가 서명한 RFC 9068 JWT access token(`typ: at+jwt`)을 알아보는 도구와 accounts 주소.
55
+ *
56
+ * CLI(`sayren login`)·stdio MCP·원격 게이트웨이가 같이 쓴다. **Node 전용 API를 쓰지 않는다**(게이트웨이 Worker 번들에 들어간다).
57
+ * 계약의 원천은 비공개 패키지 `@sayren/contracts`(`accounts.ts`)다. CLI·`@sayren/mcp`는 npm에 게시되므로 그 패키지에 의존하지 않고
58
+ * 값을 옮겨 두었다 — `account-token.test.ts`가 계약과 대조한다.
59
+ *
60
+ * 여기의 디코딩은 **서명을 보지 않는다**. 모양(헤더 `typ`)으로 경로를 고르고 상점 범위를 읽는 데만 쓴다. 판정은 api(매 요청 서명·폐기
61
+ * 확인)와 게이트웨이(`apps/mcp-gateway/src/auth.ts`, JWKS 서명 확인)가 한다.
62
+ */
63
+ /** JWT access token 헤더 `typ`(RFC 9068 §2.1) — 계약 `ACCOUNT_ACCESS_TOKEN_TYPE` */
64
+ const ACCOUNT_ACCESS_TOKEN_TYPE = "at+jwt";
65
+ const PROD_ACCOUNTS_ORIGIN = "https://accounts.sayren.app";
66
+ const DEV_ACCOUNTS_ORIGIN = "http://localhost:4008";
67
+ const PROD_API_ORIGIN = "https://api.sayren.app";
68
+ const DEV_API_ORIGIN = "http://localhost:4001";
69
+ /** CLI의 accounts 클라이언트(public, PKCE, 루프백 임의 포트, 디바이스 코드) — accounts `token-config.ts` `CLI_CLIENT_ID` */
70
+ const CLI_CLIENT_ID = "sayren-cli";
71
+ /** CLI 로그인 스코프 — 상점 목록·상점 정보 조회·스토어프론트(pull·deploy) */
72
+ const CLI_LOGIN_SCOPE = "openid email offline_access stores:read store:r storefront:rw";
73
+ /** accounts 엔드포인트(Better Auth `basePath` `/api/auth`) */
74
+ function accountsEndpoints(accountsOrigin) {
75
+ const base = `${accountsOrigin.replace(/\/+$/, "")}/api/auth`;
76
+ return {
77
+ authorize: `${base}/oauth2/authorize`,
78
+ token: `${base}/oauth2/token`,
79
+ revoke: `${base}/oauth2/revoke`,
80
+ deviceCode: `${base}/device/code`,
81
+ jwks: `${base}/jwks`
82
+ };
83
+ }
84
+ /**
85
+ * api origin에 맞는 accounts origin — 운영 api면 운영 accounts, 로컬 api(4001)면 로컬 accounts(4008). 그 밖은 모른다(null) —
86
+ * 부르는 쪽이 명시 값(`--accounts-origin`·`SAYREN_ACCOUNTS_ORIGIN`)을 요구한다.
87
+ */
88
+ function accountsOriginFor(apiOrigin) {
89
+ const origin = apiOrigin.replace(/\/+$/, "");
90
+ if (origin === PROD_API_ORIGIN) return PROD_ACCOUNTS_ORIGIN;
91
+ if (origin === DEV_API_ORIGIN || origin === "http://127.0.0.1:4001") return DEV_ACCOUNTS_ORIGIN;
92
+ return null;
93
+ }
94
+ function base64UrlDecode(segment) {
95
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
96
+ const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));
97
+ const bytes = new Uint8Array(binary.length);
98
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
99
+ return new TextDecoder().decode(bytes);
100
+ }
101
+ const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
102
+ /** 헤더·클레임을 읽는다(서명은 보지 않는다). JWT가 아니면 null */
103
+ function decodeJwt(token) {
104
+ if (token.length > 8192 || !JWT_RE.test(token)) return null;
105
+ const [header, payload] = token.split(".");
106
+ try {
107
+ const parsedHeader = JSON.parse(base64UrlDecode(header));
108
+ const parsedClaims = JSON.parse(base64UrlDecode(payload));
109
+ if (!parsedHeader || typeof parsedHeader !== "object" || !parsedClaims || typeof parsedClaims !== "object") return null;
110
+ return {
111
+ header: parsedHeader,
112
+ claims: parsedClaims
113
+ };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+ /** 계정 토큰(헤더 `typ: at+jwt`)이면 클레임, 아니면 null(서명은 보지 않는다) */
119
+ function accountTokenClaimsOf(token) {
120
+ const decoded = decodeJwt(token);
121
+ if (!decoded || decoded.header.typ !== ACCOUNT_ACCESS_TOKEN_TYPE) return null;
122
+ return decoded.claims;
123
+ }
124
+ function isAccountToken(token) {
125
+ return accountTokenClaimsOf(token) !== null;
126
+ }
127
+ /**
128
+ * 폐기된 옛 상점 API 토큰(`sy_pat_…`) — #71 R1 뒤 api가 받지 않는다(401 `TOKEN_TYPE_RETIRED`). 받는 쪽은 api에 보내지 않고
129
+ * 계정 액세스 토큰을 만들라고 바로 안내한다.
130
+ */
131
+ function isRetiredStorePat(token) {
132
+ return token.startsWith("sy_pat_");
133
+ }
134
+ /** 토큰의 상점 범위가 상점 하나면 그 id(헤더 없이 쓸 수 있다). 아니면 null */
135
+ function singleStoreOf(claims) {
136
+ const stores = claims?.sayren_stores;
137
+ return Array.isArray(stores) && stores.length === 1 && typeof stores[0] === "string" ? stores[0] : null;
138
+ }
139
+ /** 이 상점이 토큰의 범위 안인가 */
140
+ function storeInScope(claims, storeId) {
141
+ const stores = claims?.sayren_stores;
142
+ if (stores === "*") return true;
143
+ return Array.isArray(stores) && stores.includes(storeId);
144
+ }
145
+ /** 토큰 발급 시 선택한 상점 id 형식(관리 API `X-Store-Id`) */
146
+ const STORE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
147
+
44
148
  //#endregion
45
149
  //#region ../mcp/src/mcp-json.ts
46
150
  /**
@@ -49,15 +153,17 @@ function detailsOf(error) {
49
153
  * `npx sayren pull`·`login`(packages/cli)과 `sayren-mcp create`가 같은 함수로 쓴다. 파일 입출력은 부르는 쪽이 하고 여기는 문자열만
50
154
  * 다룬다 — 게이트웨이(Worker)가 헤더 이름을 이 파일에서 가져가므로 Node 전용 API를 쓰지 않는다.
51
155
  *
52
- * - 토큰 값을 파일에 그대로 쓴다(2026-09-29 사용자 결정). 그래서 `.mcp.json`은 `.gitignore` 대상이고 배포에 올리지 않는다.
53
- * - 다른 서버 항목과 `sayren` 항목의 다른 헤더는 그대로 둔다. `sayren` 항목의 `type`·`url`·토큰·스토어 헤더만 바꾼다.
54
- * - 스토어 헤더(`X-Store-Id`)는 게이트웨이가 토큰의 스토어와 대조한다(다르면 403). 토큰을 다른 스토어 것으로 바꿔 넣는 실수를 막는다.
156
+ * - **토큰을 쓰지 않는다(#71)** — 항목은 주소와 `X-Store-Id`뿐이고 MCP 클라이언트가 게이트웨이의 401 + `resource_metadata`를 보고
157
+ * 스스로 OAuth(accounts)를 한다. 계정 토큰은 폴더가 아니라 사람에게 속한다(CLI는 사용자 설정 폴더에 둔다).
158
+ * - 폐기된 옛 API 토큰(`sy_pat_…`)이 든 `Authorization` 헤더는 쓸 때 지운다(R1 뒤 게이트웨이가 거절한다). 셀러가 직접 넣은 다른
159
+ * `Authorization`(계정 액세스 토큰)은 그대로 둔다.
160
+ * - 다른 서버 항목과 `sayren` 항목의 다른 헤더는 그대로 둔다. `sayren` 항목의 `type`·`url`·상점 헤더만 바꾼다.
55
161
  */
56
162
  const MCP_JSON_FILE = ".mcp.json";
57
163
  const MCP_SERVER_NAME = "sayren";
58
164
  /** 원격 MCP 주소. 게이트웨이는 운영 api 하나만 바라본다 — 다른 api 주소를 쓰는 폴더도 이 주소를 쓴다 */
59
165
  const REMOTE_MCP_URL = "https://mcp.sayren.app/mcp";
60
- /** 관리 API의 테넌트 헤더와 같은 이름이다(api `StoreTokenGuard`가 토큰의 스토어와 대조한다) */
166
+ /** 관리 API의 테넌트 헤더와 같은 이름이다(게이트웨이·api가 토큰의 상점 범위와 대조한다) */
61
167
  const STORE_ID_HEADER = "X-Store-Id";
62
168
  var McpJsonError = class extends Error {
63
169
  constructor(message) {
@@ -82,26 +188,30 @@ function headerKey(headers, name) {
82
188
  const lower = name.toLowerCase();
83
189
  return Object.keys(headers).find((key) => key.toLowerCase() === lower);
84
190
  }
191
+ /** `Authorization` 헤더 값이 폐기된 옛 API 토큰(`Bearer sy_pat_…`)인가 */
192
+ function isRetiredAuthorization(value) {
193
+ return typeof value === "string" && /^Bearer\s+sy_pat_/i.test(value.trim());
194
+ }
85
195
  /**
86
- * `sayren` 항목을 넣거나 바꾼 `.mcp.json` 본문. `existing`이 없으면 새 파일이다.
196
+ * `sayren` 항목을 넣거나 바꾼 `.mcp.json` 본문. `existing`이 없으면 새 파일이다. 폐기된 옛 토큰 헤더는 지운다(`removedRetiredToken`).
87
197
  * 형식이 틀린 파일은 덮어쓰지 않고 `McpJsonError`다(셀러가 손으로 쓴 다른 서버 설정을 지우지 않게).
88
198
  */
89
- function mergeMcpJson(existing, credentials) {
199
+ function mergeMcpJson(existing, entry) {
90
200
  const root = existing?.trim() ? parse(existing) : {};
91
201
  const servers = isObject(root.mcpServers) ? { ...root.mcpServers } : {};
92
202
  const previous = isObject(servers[MCP_SERVER_NAME]) ? servers[MCP_SERVER_NAME] : {};
93
203
  const headers = isObject(previous.headers) ? { ...previous.headers } : {};
94
- for (const name of ["Authorization", STORE_ID_HEADER]) {
95
- const key = headerKey(headers, name);
96
- if (key) delete headers[key];
97
- }
204
+ const storeKey = headerKey(headers, STORE_ID_HEADER);
205
+ if (storeKey) delete headers[storeKey];
206
+ const authKey = headerKey(headers, "Authorization");
207
+ const removedRetiredToken = !!authKey && isRetiredAuthorization(headers[authKey]);
208
+ if (authKey && removedRetiredToken) delete headers[authKey];
98
209
  const next = {
99
210
  ...previous,
100
211
  type: "http",
101
212
  url: REMOTE_MCP_URL,
102
213
  headers: {
103
- Authorization: `Bearer ${credentials.token}`,
104
- ...credentials.storeId ? { [STORE_ID_HEADER]: credentials.storeId } : {},
214
+ ...entry.storeId ? { [STORE_ID_HEADER]: entry.storeId } : {},
105
215
  ...headers
106
216
  }
107
217
  };
@@ -109,38 +219,30 @@ function mergeMcpJson(existing, credentials) {
109
219
  delete next.args;
110
220
  delete next.env;
111
221
  servers[MCP_SERVER_NAME] = next;
112
- return `${JSON.stringify({
113
- ...root,
114
- mcpServers: servers
115
- }, null, 2)}\n`;
222
+ return {
223
+ text: `${JSON.stringify({
224
+ ...root,
225
+ mcpServers: servers
226
+ }, null, 2)}\n`,
227
+ removedRetiredToken
228
+ };
116
229
  }
117
- /** `sayren` 항목의 토큰·스토어. 항목·헤더가 없으면 null 값이다. 형식이 틀린 파일은 `McpJsonError` */
118
- function sayrenCredentialsOf(text) {
230
+ /** `sayren` 항목의 상점(`X-Store-Id`). 항목·헤더가 없으면 null이다. 형식이 틀린 파일은 `McpJsonError` */
231
+ function sayrenStoreIdOf(text) {
119
232
  const root = parse(text);
120
233
  const entry = (isObject(root.mcpServers) ? root.mcpServers : {})[MCP_SERVER_NAME];
121
234
  const headers = isObject(entry) && isObject(entry.headers) ? entry.headers : {};
122
- const authKey = headerKey(headers, "Authorization");
123
235
  const storeKey = headerKey(headers, STORE_ID_HEADER);
124
- const auth = authKey ? headers[authKey] : void 0;
125
- const match = typeof auth === "string" ? /^Bearer\s+(\S+)\s*$/i.exec(auth) : null;
126
236
  const store = storeKey ? headers[storeKey] : void 0;
127
- return {
128
- token: match?.[1] ?? null,
129
- storeId: typeof store === "string" && store.trim() ? store.trim() : null
130
- };
237
+ return typeof store === "string" && store.trim() ? store.trim() : null;
131
238
  }
132
239
 
133
240
  //#endregion
134
241
  //#region ../mcp/src/mcp-json-file.ts
135
242
  /**
136
243
  * `.mcp.json` 파일 쓰기(Node) — `sayren` CLI(packages/cli)와 `sayren-mcp create`가 같이 쓴다. 형식은 `mcp-json.ts`(Node 없음)다.
137
- *
138
- * 토큰을 파일에 그대로 쓰므로 두 경우에는 쓰지 않는다.
139
- * - git이 추적 중인 `.mcp.json`(팀이 커밋해 둔 설정): `.gitignore`는 추적 중인 파일을 막지 못해 다음 커밋에 토큰이 실린다.
140
- * - 심볼릭 링크: 링크를 따라 폴더 밖 파일에 토큰을 쓰지 않는다.
244
+ * 항목에 토큰을 쓰지 않는다(#71 R1). 심볼릭 링크는 따라가지 않는다(폴더 밖 파일을 덮지 않게).
141
245
  */
142
- /** 추적 중인 `.mcp.json`에 쓰지 않았을 때의 안내 */
143
- const TRACKED_MCP_JSON_NOTICE = `${MCP_JSON_FILE}가 git에 커밋돼 있어 토큰을 쓰지 않았습니다. 토큰을 파일에 두지 않으려면 SAYREN_TOKEN 환경 변수로 주십시오. 파일에 두려면 git rm --cached ${MCP_JSON_FILE}로 추적을 해제하고 .gitignore에 더한 뒤 다시 실행하십시오`;
144
246
  function git(dir, args) {
145
247
  if (!existsSync(dir)) return null;
146
248
  const result = spawnSync("git", args, {
@@ -160,51 +262,767 @@ function isGitTracked(dir, name) {
160
262
  name
161
263
  ]) === 0;
162
264
  }
163
- /** git이 이 파일을 무시하는가. git이 없거나 저장소가 아니면 null */
164
- function isGitIgnored(dir, name) {
165
- const status$1 = git(dir, [
166
- "check-ignore",
167
- "-q",
168
- "--",
169
- name
170
- ]);
171
- if (status$1 === 0) return true;
172
- if (status$1 === 1) return false;
173
- return null;
174
- }
175
265
  /** 심볼릭 링크면 쓰지 않는다(링크를 따라 폴더 밖 파일을 덮지 않게). 권한은 600이다 */
176
266
  function writePrivateFile(path, text) {
177
267
  let link = false;
178
268
  try {
179
269
  link = lstatSync(path).isSymbolicLink();
180
270
  } catch {
181
- link = false;
271
+ link = false;
272
+ }
273
+ if (link) throw new McpJsonError(`${path}가 심볼릭 링크라 쓰지 않았습니다. 링크를 지운 뒤 다시 실행하십시오`);
274
+ writeFileSync(path, text, { mode: 384 });
275
+ try {
276
+ chmodSync(path, 384);
277
+ } catch {}
278
+ }
279
+ /** `.mcp.json`에서 폐기된 옛 API 토큰 헤더를 지웠을 때의 안내 */
280
+ const RETIRED_MCP_TOKEN_NOTICE = `${MCP_JSON_FILE}의 sayren 항목에서 옛 API 토큰(sy_pat_)을 지웠습니다. 옛 토큰은 더 쓸 수 없습니다 — MCP 클라이언트가 연결할 때 Sayren 계정으로 로그인합니다`;
281
+ /**
282
+ * `sayren` 항목을 넣는다(다른 서버는 둔다). 폐기된 옛 토큰 헤더를 지웠으면 `"retired-token-removed"`다(`RETIRED_MCP_TOKEN_NOTICE`).
283
+ * 형식이 틀린 파일·심볼릭 링크는 `McpJsonError`다.
284
+ */
285
+ function writeMcpJsonFile(dir, entry) {
286
+ const path = join(dir, MCP_JSON_FILE);
287
+ let existing = null;
288
+ try {
289
+ if (!lstatSync(path).isSymbolicLink()) existing = readFileSync(path, "utf8");
290
+ } catch {
291
+ existing = null;
292
+ }
293
+ const { text, removedRetiredToken } = mergeMcpJson(existing, entry);
294
+ writePrivateFile(path, text);
295
+ return removedRetiredToken ? "retired-token-removed" : "written";
296
+ }
297
+
298
+ //#endregion
299
+ //#region src/config.ts
300
+ /**
301
+ * 설정 (2026-09-29 사용자 결정: `.sayrenrc`를 없애고 받은 폴더에 원격 MCP `.mcp.json`을 둔다)
302
+ *
303
+ * - `.sayren/state.json`: pull·deploy가 쓰는 비밀 아닌 상태(`storeId`·`siteId`·`baseRef`, 기본이 아닌 api 주소). `.gitignore` 대상이고
304
+ * 올리지 않는다. `baseRef`가 deploy의 `expectedBaseRef`다(원격이 pull 이후 바뀌었으면 `SOURCE_CHANGED`).
305
+ * - `.mcp.json`: Claude Code 형식의 `sayren` 원격 MCP 항목. 주소와 `X-Store-Id`만 있고 토큰은 없다(#71 — MCP 클라이언트가
306
+ * 연결할 때 스스로 로그인한다). 옛 폴더의 폐기된 토큰(`sy_pat_…`) 헤더는 쓸 때 지운다. `.gitignore` 대상이고 올리지 않는다.
307
+ * - `.env`: 스토어프론트 템플릿 개발 서버가 읽는 값(`SAYREN_API_URL`·`SAYREN_STORE_CODE`)만 둔다. 토큰을 두지 않는다.
308
+ * - 계정 토큰은 폴더가 아니라 사람에게 속한다 — 사용자 설정 폴더의 `credentials.json`(`credentials.ts`)에 둔다.
309
+ *
310
+ * 토큰은 계정 토큰(accounts JWT)만 받는다. `--token` → `SAYREN_TOKEN` 환경 변수 → 자격 증명 파일(`npx sayren login`) 순서다.
311
+ * 옛 API 토큰(`sy_pat_…`)은 R1에서 폐기되어 api에 보내지 않고 거절한다(`RETIRED_TOKEN_MESSAGE`).
312
+ * api 주소는 `--api-origin` → `SAYREN_API_ORIGIN` → `.env`의
313
+ * `SAYREN_API_URL` → 상태 파일 → 기본값 순서이고, 파일에서 온 기본이 아닌 주소는 확인한다(`checkAuthSources`).
314
+ * 옛 폴더(`.sayrenrc`, `.env`의 `SAYREN_TOKEN`)는 한 번 정리한다(`migrateLegacy`).
315
+ */
316
+ const STATE_DIR = ".sayren";
317
+ const STATE_FILE = `${STATE_DIR}/state.json`;
318
+ /** 옛 상태 파일(0.1.x) — 읽어서 옮기고 지운다 */
319
+ const LEGACY_RC_FILE = ".sayrenrc";
320
+ const ENV_FILE = ".env";
321
+ const MCP_FILE = MCP_JSON_FILE;
322
+ const DEFAULT_API_ORIGIN = "https://api.sayren.app";
323
+ function parseState(text, name) {
324
+ let parsed;
325
+ try {
326
+ parsed = JSON.parse(text);
327
+ } catch {
328
+ throw new CliError(`${name}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
329
+ }
330
+ if (!parsed || typeof parsed !== "object") throw new CliError(`${name} 형식이 아닙니다`);
331
+ const raw = parsed;
332
+ if (typeof raw.storeId !== "string" || typeof raw.siteId !== "string" || typeof raw.baseRef !== "string") throw new CliError(`${name}에 storeId·siteId·baseRef가 없습니다. npx sayren pull로 다시 받으십시오`);
333
+ return {
334
+ storeId: raw.storeId,
335
+ siteId: raw.siteId,
336
+ baseRef: raw.baseRef,
337
+ ...typeof raw.apiOrigin === "string" ? { apiOrigin: raw.apiOrigin } : {}
338
+ };
339
+ }
340
+ function readState(dir) {
341
+ const path = join(dir, STATE_FILE);
342
+ if (!existsSync(path)) return null;
343
+ return parseState(readFileSync(path, "utf8"), STATE_FILE);
344
+ }
345
+ function writeState(dir, state) {
346
+ const out = {
347
+ storeId: state.storeId,
348
+ siteId: state.siteId,
349
+ baseRef: state.baseRef
350
+ };
351
+ if (state.apiOrigin && state.apiOrigin !== DEFAULT_API_ORIGIN) out.apiOrigin = state.apiOrigin;
352
+ mkdirSync(join(dir, STATE_DIR), { recursive: true });
353
+ writeFileSync(join(dir, STATE_FILE), `${JSON.stringify(out, null, 2)}\n`);
354
+ }
355
+ /** 받은 폴더인가 — 상태 파일 또는 옛 `.sayrenrc`가 있다 */
356
+ function isPulledDir(dir) {
357
+ return existsSync(join(dir, STATE_FILE)) || existsSync(join(dir, LEGACY_RC_FILE));
358
+ }
359
+ function readMcpText(dir) {
360
+ const path = join(dir, MCP_FILE);
361
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
362
+ }
363
+ /** `.mcp.json`의 `sayren` 상점(`X-Store-Id`). 파일·항목이 없으면 null이다 */
364
+ function readMcpStoreId(dir) {
365
+ const text = readMcpText(dir);
366
+ if (text === null) return null;
367
+ try {
368
+ return sayrenStoreIdOf(text);
369
+ } catch (error) {
370
+ if (error instanceof McpJsonError) throw new CliError(error.message);
371
+ throw error;
372
+ }
373
+ }
374
+ /**
375
+ * `.mcp.json`에 `sayren` 원격 MCP 항목(주소·`X-Store-Id`)을 쓴다. 다른 서버는 그대로 둔다. 파일 권한은 600이다.
376
+ * 폐기된 옛 토큰 헤더를 지웠으면 `"retired-token-removed"`다(`RETIRED_MCP_TOKEN_NOTICE`).
377
+ */
378
+ function writeMcpJson(dir, entry) {
379
+ try {
380
+ return writeMcpJsonFile(dir, entry);
381
+ } catch (error) {
382
+ if (error instanceof McpJsonError) throw new CliError(`${error.message}. 고치거나 옮긴 뒤 다시 시도하십시오(덮어쓰지 않았습니다)`);
383
+ throw error;
384
+ }
385
+ }
386
+ /** 권한 600. 심볼릭 링크면 거부한다(링크를 따라 폴더 밖 파일을 덮지 않게) */
387
+ function writePrivate(path, text) {
388
+ try {
389
+ writePrivateFile(path, text);
390
+ } catch (error) {
391
+ if (error instanceof McpJsonError) throw new CliError(error.message);
392
+ throw error;
393
+ }
394
+ }
395
+ /**
396
+ * 0.1.x가 받은 폴더를 한 번 정리한다. `.sayrenrc` → `.sayren/state.json`(옛 파일은 지운다), `.env`의 폐기된 `SAYREN_TOKEN`(`sy_pat_…`)은
397
+ * 지운다(R1 뒤 쓸 수 없다). 다른 값이 든 `SAYREN_TOKEN` 줄은 두고(CLI는 읽지 않는다) 알린다. 정리했으면 한 줄로 알린다.
398
+ */
399
+ function migrateLegacy(dir, notify) {
400
+ if (!isPulledDir(dir)) return;
401
+ const done = [];
402
+ const rcPath = join(dir, LEGACY_RC_FILE);
403
+ if (existsSync(rcPath)) {
404
+ const tracked = isGitTracked(dir, LEGACY_RC_FILE);
405
+ const hasState = existsSync(join(dir, STATE_FILE));
406
+ if (!hasState) writeState(dir, parseState(readFileSync(rcPath, "utf8"), LEGACY_RC_FILE));
407
+ if (!tracked) {
408
+ rmSync(rcPath, { force: true });
409
+ done.push(`${LEGACY_RC_FILE} → ${STATE_FILE}`);
410
+ } else if (!hasState) done.push(`${LEGACY_RC_FILE} → ${STATE_FILE}(${LEGACY_RC_FILE}는 더 쓰지 않으니 커밋에서 지워도 됩니다)`);
411
+ }
412
+ const envToken = readEnvFile(dir).get("SAYREN_TOKEN")?.trim();
413
+ if (envToken !== void 0) if (!envToken || isRetiredStorePat(envToken)) {
414
+ updateEnvFile(dir, {}, ["SAYREN_TOKEN"]);
415
+ done.push(`${ENV_FILE}의 옛 SAYREN_TOKEN 삭제(옛 API 토큰은 더 쓸 수 없습니다)`);
416
+ } else notify(`알림: ${ENV_FILE}의 SAYREN_TOKEN은 읽지 않습니다. npx sayren login으로 로그인하거나 SAYREN_TOKEN 환경 변수로 주십시오`);
417
+ if (done.length > 0) {
418
+ const ignored = ensureGitignore(dir);
419
+ notify(`알림: 옛 설정을 정리했습니다 — ${done.join(", ")}${ignored.length ? ` (.gitignore에 ${ignored.join(" ")} 추가)` : ""}`);
420
+ }
421
+ }
422
+ /** `.env` 읽기 — `KEY=VALUE` 줄만(따옴표는 벗긴다). 없으면 빈 값 */
423
+ function readEnvFile(dir) {
424
+ const path = join(dir, ENV_FILE);
425
+ const out = /* @__PURE__ */ new Map();
426
+ if (!existsSync(path)) return out;
427
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
428
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
429
+ if (!m?.[1]) continue;
430
+ let value = m[2] ?? "";
431
+ if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
432
+ out.set(m[1], value);
433
+ }
434
+ return out;
435
+ }
436
+ /** `.env`에 값들을 쓴다. 다른 줄은 그대로 두고 같은 키만 바꾸거나(`values`) 지운다(`remove`). 파일 권한은 600이다 */
437
+ function updateEnvFile(dir, values, remove = []) {
438
+ const path = join(dir, ENV_FILE);
439
+ const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
440
+ const pending = new Map(Object.entries(values));
441
+ const next = [];
442
+ for (const line of lines) {
443
+ const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
444
+ if (key && remove.includes(key)) continue;
445
+ if (key && pending.has(key)) {
446
+ next.push(`${key}=${pending.get(key)}`);
447
+ pending.delete(key);
448
+ continue;
449
+ }
450
+ next.push(line);
451
+ }
452
+ while (next.length > 0 && next[next.length - 1] === "") next.pop();
453
+ for (const [key, value] of pending) next.push(`${key}=${value}`);
454
+ writePrivate(path, next.length ? `${next.join("\n")}\n` : "");
455
+ }
456
+ /** `.gitignore`에 꼭 있어야 하는 줄 — 원격 MCP 설정(`.mcp.json`, 옛 폴더는 토큰이 남아 있을 수 있다)·개발 서버 값(`.env`)·CLI 상태(`.sayren/`) */
457
+ const REQUIRED_IGNORES = [
458
+ ENV_FILE,
459
+ MCP_FILE,
460
+ `${STATE_DIR}/`
461
+ ];
462
+ const IGNORE_ALIASES = {
463
+ [ENV_FILE]: [
464
+ ".env",
465
+ ".env*",
466
+ "/.env"
467
+ ],
468
+ [MCP_FILE]: [".mcp.json", "/.mcp.json"],
469
+ [`${STATE_DIR}/`]: [
470
+ ".sayren",
471
+ ".sayren/",
472
+ "/.sayren",
473
+ "/.sayren/"
474
+ ]
475
+ };
476
+ /** `.gitignore`에 빠진 줄(`.env`·`.mcp.json`·`.sayren/`)을 더한다. 더한 줄을 돌려준다 */
477
+ function ensureGitignore(dir) {
478
+ const path = join(dir, ".gitignore");
479
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
480
+ const lines = new Set(text.split(/\r?\n/).map((line) => line.trim()));
481
+ const missing = REQUIRED_IGNORES.filter((entry) => !(IGNORE_ALIASES[entry] ?? [entry]).some((alias) => lines.has(alias)));
482
+ if (missing.length === 0) return [];
483
+ writeFileSync(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${missing.map((entry) => `${entry}\n`).join("")}`);
484
+ return missing;
485
+ }
486
+ /** 스토어프론트 API 주소(`…/storefront/v1`)에서 api origin을 읽는다 */
487
+ function originOfStorefrontUrl(url) {
488
+ if (!url) return null;
489
+ return /^(https?:\/\/[^/]+)\/storefront\/v1\/?$/.exec(url.trim())?.[1] ?? null;
490
+ }
491
+ /** api 주소 — `--api-origin` → `SAYREN_API_ORIGIN` → `.env`의 `SAYREN_API_URL` → 상태 파일 → 기본값 */
492
+ function resolveApiOrigin(flags, dir, state, env) {
493
+ const dotenv = readEnvFile(dir);
494
+ const picked = [
495
+ ["flag", flags.apiOrigin?.trim()],
496
+ ["env", env.SAYREN_API_ORIGIN?.trim()],
497
+ [".env", originOfStorefrontUrl(dotenv.get("SAYREN_API_URL"))],
498
+ ["state", state?.apiOrigin]
499
+ ].find(([, value]) => value);
500
+ const apiOrigin = (picked?.[1] ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
501
+ assertOriginShape(apiOrigin);
502
+ return {
503
+ apiOrigin,
504
+ originSource: picked && apiOrigin !== DEFAULT_API_ORIGIN ? picked[0] : "default"
505
+ };
506
+ }
507
+ /** 옛 API 토큰(`sy_pat_…`)을 줬을 때의 안내 */
508
+ const RETIRED_TOKEN_MESSAGE = "옛 API 토큰(sy_pat_)은 더 쓸 수 없습니다. npx sayren login으로 Sayren 계정에 로그인하거나, Sayren 계정 › 액세스 토큰에서 새 액세스 토큰을 만들어 쓰십시오";
509
+ /** 계정 액세스 토큰(JWT)이 아닐 때의 안내 */
510
+ const NOT_ACCOUNT_TOKEN_MESSAGE = "Sayren 계정 액세스 토큰이 아닙니다. npx sayren login으로 로그인하거나, Sayren 계정 › 액세스 토큰에서 만든 토큰을 쓰십시오";
511
+ /** 계정 토큰만 받는다 — 옛 API 토큰·다른 값은 api에 보내기 전에 거절한다 */
512
+ function assertAccountToken(token) {
513
+ if (isRetiredStorePat(token)) throw new CliError(RETIRED_TOKEN_MESSAGE);
514
+ if (!isAccountToken(token)) throw new CliError(NOT_ACCOUNT_TOKEN_MESSAGE);
515
+ }
516
+ /**
517
+ * 토큰·api 주소를 정한다. `credentialToken`은 api 주소가 정해진 뒤 자격 증명 파일에서 읽는 함수다(부르는 쪽이 넣는다 — 테스트·Io의
518
+ * env로 경로가 정해진다). 자격 증명 파일의 토큰은 만료됐을 수 있다 — 명령이 쓰기 전에 갱신한다(`readyAuth`).
519
+ */
520
+ function resolveAuth(flags, dir, state, env = process.env, credentialToken = () => null) {
521
+ const { apiOrigin, originSource } = resolveApiOrigin(flags, dir, state, env);
522
+ const fromFlag = flags.token?.trim();
523
+ const fromEnv = env.SAYREN_TOKEN?.trim();
524
+ const fromCredentials = fromFlag || fromEnv ? null : credentialToken(apiOrigin);
525
+ const token = fromFlag || fromEnv || fromCredentials;
526
+ if (!token) throw new CliError("토큰이 없습니다. npx sayren login으로 Sayren 계정에 로그인하거나 SAYREN_TOKEN 환경 변수·--token으로 액세스 토큰을 주십시오");
527
+ assertAccountToken(token);
528
+ return {
529
+ token,
530
+ apiOrigin,
531
+ tokenSource: fromFlag ? "flag" : fromEnv ? "env" : "credentials",
532
+ originSource
533
+ };
534
+ }
535
+ /** 로컬 개발 주소 — http를 받는다 */
536
+ const LOOPBACK = new Set([
537
+ "localhost",
538
+ "127.0.0.1",
539
+ "[::1]"
540
+ ]);
541
+ /** api 주소 형식 — https만 받는다(토큰을 싣고 보낸다). localhost·127.0.0.1만 http 예외다 */
542
+ function assertOriginShape(origin) {
543
+ let url;
544
+ try {
545
+ url = new URL(origin);
546
+ } catch {
547
+ throw new CliError(`api 주소 형식이 아닙니다: ${origin}`);
548
+ }
549
+ const loopback = LOOPBACK.has(url.hostname);
550
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) throw new CliError(`api 주소는 https여야 합니다(localhost만 http 예외): ${origin}. 토큰이 이 주소로 갑니다`);
551
+ }
552
+ /** 사용자에게 보여 줄 오류 — 스택 없이 문장만 찍고 종료 코드 1로 끝난다 */
553
+ var CliError = class extends Error {
554
+ constructor(message, exitCode = 1) {
555
+ super(message);
556
+ this.exitCode = exitCode;
557
+ this.name = "CliError";
558
+ }
559
+ };
560
+
561
+ //#endregion
562
+ //#region src/credentials.ts
563
+ /**
564
+ * 계정 토큰 자격 증명 파일(#71) — `$XDG_CONFIG_HOME/sayren/credentials.json`(macOS·Linux 기본 `~/.config/sayren/`, Windows
565
+ * `%APPDATA%\sayren\`). 계정 토큰은 폴더가 아니라 사람에게 속하므로 프로젝트 폴더(`.mcp.json`)에 두지 않는다.
566
+ *
567
+ * - api origin별 한 항목: `{ accessToken, refreshToken?, expiresAt, scope, email?, accountsOrigin, kind }`.
568
+ * - 권한 600, 심볼릭 링크 거부(#60 `writePrivateFile`), git이 추적 중인 파일에는 쓰지 않는다(dotfiles 저장소 등).
569
+ * - 갱신은 파일 잠금(`credentials.json.lock`, O_EXCL)으로 직렬화한다 — CLI 둘이 같은 refresh를 동시에 쓰면 accounts의 재사용 감지로
570
+ * 패밀리 전체가 폐기된다. 잠금을 얻은 뒤 파일을 다시 읽는다(그 사이 다른 프로세스가 회전했으면 그 토큰을 쓴다).
571
+ * - 경로는 **Io의 env에서만** 정한다 — 테스트(env 없음)가 사용자의 실제 파일에 닿지 않는다.
572
+ */
573
+ const CREDENTIALS_FILE = "credentials.json";
574
+ /** 잠금이 이보다 오래됐으면 죽은 프로세스의 것으로 보고 지운다 */
575
+ const STALE_LOCK_MS = 3e4;
576
+ /** 잠금을 기다리는 최대 시간 */
577
+ const LOCK_WAIT_MS = 15e3;
578
+ /** 설정 폴더 — env에 근거가 없으면 null(자격 증명 파일을 쓰지 않는다) */
579
+ function configDirOf(env, platform = process.platform) {
580
+ if (env.XDG_CONFIG_HOME?.trim()) return join(env.XDG_CONFIG_HOME.trim(), "sayren");
581
+ if (platform === "win32") return env.APPDATA?.trim() ? join(env.APPDATA.trim(), "sayren") : null;
582
+ return env.HOME?.trim() ? join(env.HOME.trim(), ".config", "sayren") : null;
583
+ }
584
+ function credentialsPathOf(env) {
585
+ const dir = configDirOf(env);
586
+ return dir ? join(dir, CREDENTIALS_FILE) : null;
587
+ }
588
+ function readFile(path) {
589
+ if (!existsSync(path)) return {};
590
+ if (lstatSync(path).isSymbolicLink()) throw new CliError(`${path}가 심볼릭 링크라 읽지 않았습니다. 링크를 지운 뒤 다시 로그인하십시오`);
591
+ let parsed;
592
+ try {
593
+ parsed = JSON.parse(readFileSync(path, "utf8"));
594
+ } catch {
595
+ throw new CliError(`${path}를 읽지 못했습니다. 파일을 지우고 다시 로그인하십시오`);
596
+ }
597
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
598
+ const out = {};
599
+ for (const [origin, value] of Object.entries(parsed)) {
600
+ const entry = value;
601
+ if (entry && typeof entry.accessToken === "string" && typeof entry.expiresAt === "number" && typeof entry.accountsOrigin === "string" && (entry.kind === "oauth" || entry.kind === "pat")) out[origin] = entry;
602
+ }
603
+ return out;
604
+ }
605
+ function writeFile(path, file) {
606
+ const dir = dirname(path);
607
+ mkdirSync(dir, {
608
+ recursive: true,
609
+ mode: 448
610
+ });
611
+ if (isGitTracked(dir, CREDENTIALS_FILE)) throw new CliError(`${path}가 git에 커밋돼 있어 토큰을 쓰지 않았습니다. 추적을 해제하거나 XDG_CONFIG_HOME으로 다른 폴더를 지정하십시오`);
612
+ try {
613
+ writePrivateFile(path, `${JSON.stringify(file, null, 2)}\n`);
614
+ } catch (error) {
615
+ throw new CliError(error instanceof Error ? error.message : String(error));
616
+ }
617
+ }
618
+ function readCredential(env, apiOrigin) {
619
+ const path = credentialsPathOf(env);
620
+ if (!path) return null;
621
+ return readFile(path)[apiOrigin] ?? null;
622
+ }
623
+ function saveCredential(env, apiOrigin, entry) {
624
+ const path = credentialsPathOf(env);
625
+ if (!path) throw new CliError("자격 증명을 저장할 폴더를 정하지 못했습니다(HOME·XDG_CONFIG_HOME 없음). SAYREN_TOKEN 환경 변수로 토큰을 주십시오");
626
+ const file = readFile(path);
627
+ file[apiOrigin] = entry;
628
+ writeFile(path, file);
629
+ return path;
630
+ }
631
+ function deleteCredential(env, apiOrigin) {
632
+ const path = credentialsPathOf(env);
633
+ if (!path) return false;
634
+ const file = readFile(path);
635
+ if (!file[apiOrigin]) return false;
636
+ delete file[apiOrigin];
637
+ writeFile(path, file);
638
+ return true;
639
+ }
640
+ /**
641
+ * 파일 잠금 안에서 `fn`을 부른다(O_EXCL로 잠금 파일을 만든다). 오래된 잠금(30초)은 지운다. 잠금을 못 얻으면 오류다.
642
+ */
643
+ async function withCredentialLock(env, sleep, fn, now = () => Date.now()) {
644
+ const path = credentialsPathOf(env);
645
+ if (!path) return fn();
646
+ mkdirSync(dirname(path), {
647
+ recursive: true,
648
+ mode: 448
649
+ });
650
+ const lock = `${path}.lock`;
651
+ const started = now();
652
+ for (;;) try {
653
+ closeSync(openSync(lock, "wx", 384));
654
+ break;
655
+ } catch (error) {
656
+ if (error.code !== "EEXIST") throw error;
657
+ try {
658
+ if (now() - statSync(lock).mtimeMs > STALE_LOCK_MS) {
659
+ rmSync(lock, { force: true });
660
+ continue;
661
+ }
662
+ } catch {
663
+ continue;
664
+ }
665
+ if (now() - started > LOCK_WAIT_MS) throw new CliError(`다른 sayren 명령이 로그인을 갱신하고 있습니다. 잠시 뒤 다시 시도하십시오(${lock})`);
666
+ await sleep(200);
667
+ }
668
+ try {
669
+ return await fn();
670
+ } finally {
671
+ rmSync(lock, { force: true });
672
+ }
673
+ }
674
+
675
+ //#endregion
676
+ //#region src/oauth.ts
677
+ /**
678
+ * `sayren login`의 OAuth(#71) — accounts가 발급자다. 클라이언트 `sayren-cli`(public, PKCE S256).
679
+ *
680
+ * - 기본은 루프백(RFC 8252 §7.3): 127.0.0.1 임의 포트에 콜백 서버를 열고 브라우저로 authorize를 연다. state가 맞는 콜백 하나만 받고,
681
+ * 5분 안에 오지 않으면 끝낸다. 콜백 화면은 「로그인을 마쳤습니다. 터미널로 돌아가십시오.」뿐이다.
682
+ * - 브라우저를 열 수 없으면(SSH, Linux에 DISPLAY 없음, 열기 실패) 디바이스 코드(RFC 8628)다. `interval`을 지키고 `slow_down`이면 5초 늘린다.
683
+ * - `resource`(RFC 8707)는 api origin이다(accounts의 API 리소스 식별자와 같다). 싣지 않으면 JWT가 아닌 불투명 토큰이 온다.
684
+ * - 토큰은 화면·로그에 찍지 않는다.
685
+ */
686
+ const LOGIN_TIMEOUT_MS = 5 * 6e4;
687
+ const CALLBACK_PAGE = "로그인을 마쳤습니다. 터미널로 돌아가십시오.";
688
+ /** 만료 전 갱신 여유 */
689
+ const REFRESH_SKEW_MS = 6e4;
690
+ /** accounts origin — `--accounts-origin`·`SAYREN_ACCOUNTS_ORIGIN` → api origin에 정해진 값(운영·로컬) */
691
+ function resolveAccountsOrigin(apiOrigin, flag, env) {
692
+ const explicit = (flag ?? env.SAYREN_ACCOUNTS_ORIGIN)?.trim().replace(/\/+$/, "");
693
+ if (explicit) {
694
+ assertOriginShape(explicit);
695
+ return new URL(explicit).origin;
696
+ }
697
+ const known = accountsOriginFor(apiOrigin);
698
+ if (!known) throw new CliError(`이 api 주소(${apiOrigin})의 계정 서버를 모릅니다. --accounts-origin 또는 SAYREN_ACCOUNTS_ORIGIN으로 주십시오`);
699
+ return known;
700
+ }
701
+ /** 브라우저를 열 수 없는 환경 — SSH 접속, Linux에 화면(DISPLAY·WAYLAND_DISPLAY)이 없음 */
702
+ function headlessEnvironment(env, platform = process.platform) {
703
+ if (env.SSH_CONNECTION || env.SSH_TTY) return true;
704
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
705
+ return false;
706
+ }
707
+ function pkcePair() {
708
+ const verifier = randomBytes(32).toString("base64url");
709
+ return {
710
+ verifier,
711
+ challenge: createHash("sha256").update(verifier).digest("base64url")
712
+ };
713
+ }
714
+ /** 토큰 요청 제한 시간 — 계정 서버가 응답하지 않아도 CLI가 멈추지 않게 */
715
+ const TOKEN_REQUEST_TIMEOUT_MS = 1e4;
716
+ /** accounts 토큰 엔드포인트(form). 오류는 OAuth `error` 코드를 담은 CliError */
717
+ async function tokenRequest(io, accountsOrigin, form) {
718
+ let response;
719
+ try {
720
+ response = await io.fetch(accountsEndpoints(accountsOrigin).token, {
721
+ method: "POST",
722
+ headers: {
723
+ "content-type": "application/x-www-form-urlencoded",
724
+ accept: "application/json"
725
+ },
726
+ body: new URLSearchParams(form).toString(),
727
+ redirect: "error",
728
+ signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS)
729
+ });
730
+ } catch {
731
+ throw new CliError("계정 서버에 연결하지 못했습니다. 네트워크를 확인하십시오");
732
+ }
733
+ const body = await response.json().catch(() => null);
734
+ if (response.ok && body?.access_token) return {
735
+ ok: true,
736
+ body
737
+ };
738
+ return {
739
+ ok: false,
740
+ error: body?.error ?? `http_${response.status}`,
741
+ description: body?.error_description
742
+ };
743
+ }
744
+ function entryOf(body, accountsOrigin, now, previous) {
745
+ const idClaims = body.id_token ? decodeJwt(body.id_token)?.claims : null;
746
+ const email = typeof idClaims?.email === "string" ? idClaims.email : previous?.email;
747
+ return {
748
+ accessToken: body.access_token,
749
+ ...body.refresh_token ?? previous?.refreshToken ? { refreshToken: body.refresh_token ?? previous?.refreshToken } : {},
750
+ expiresAt: now + (body.expires_in ?? 900) * 1e3,
751
+ ...body.scope ?? previous?.scope ? { scope: body.scope ?? previous?.scope } : {},
752
+ ...email ? { email } : {},
753
+ accountsOrigin,
754
+ kind: "oauth"
755
+ };
756
+ }
757
+ /**
758
+ * 127.0.0.1 임의 포트 콜백 서버. state가 맞는 `/callback` 하나를 받으면 code로 풀린다. 다른 경로·state 불일치는 거부하고 계속 기다린다
759
+ * (다른 탭·다른 프로그램이 부른 요청으로 흐름이 끝나지 않게). error 파라미터(거부 등)면 오류로 끝난다.
760
+ */
761
+ async function startLoopback(state, timeoutMs = LOGIN_TIMEOUT_MS) {
762
+ let resolveCode = () => {};
763
+ let rejectCode = () => {};
764
+ const code = new Promise((resolve$1, reject) => {
765
+ resolveCode = resolve$1;
766
+ rejectCode = reject;
767
+ });
768
+ const html = (text) => `<!doctype html><meta charset="utf-8"><title>Sayren CLI</title><p style="font-family:sans-serif">${text}</p>`;
769
+ const server = createServer((request, response) => {
770
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
771
+ if (url.pathname !== "/callback") {
772
+ response.writeHead(404).end();
773
+ return;
774
+ }
775
+ if (url.searchParams.get("state") !== state) {
776
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
777
+ response.end(html("로그인 요청이 맞지 않습니다. 터미널에서 다시 시작하십시오."));
778
+ return;
779
+ }
780
+ const error = url.searchParams.get("error");
781
+ const received = url.searchParams.get("code");
782
+ response.writeHead(error || !received ? 400 : 200, {
783
+ "content-type": "text/html; charset=utf-8",
784
+ "cache-control": "no-store"
785
+ });
786
+ if (error || !received) {
787
+ response.end(html("로그인을 마치지 못했습니다. 터미널을 확인하십시오."));
788
+ rejectCode(new CliError(error === "access_denied" ? "로그인을 거부했습니다" : `로그인을 마치지 못했습니다(${error ?? "code 없음"})`));
789
+ return;
790
+ }
791
+ response.end(html(CALLBACK_PAGE));
792
+ resolveCode(received);
793
+ });
794
+ await new Promise((resolve$1, reject) => {
795
+ server.once("error", reject);
796
+ server.listen(0, "127.0.0.1", () => resolve$1());
797
+ });
798
+ const address = server.address();
799
+ const port = typeof address === "object" && address ? address.port : 0;
800
+ const timer = setTimeout(() => rejectCode(new CliError("5분 안에 로그인을 마치지 않아 끝냈습니다. 다시 시도하십시오")), timeoutMs);
801
+ timer.unref?.();
802
+ const close = () => {
803
+ clearTimeout(timer);
804
+ server.close();
805
+ server.closeAllConnections?.();
806
+ };
807
+ return {
808
+ redirectUri: `http://127.0.0.1:${port}/callback`,
809
+ code,
810
+ close
811
+ };
812
+ }
813
+ /** 브라우저 로그인(루프백 + PKCE). 브라우저를 열지 못했으면 null(부르는 쪽이 디바이스 코드로 넘어간다) */
814
+ async function browserLogin(io, input) {
815
+ if (!io.openBrowser) return null;
816
+ const state = randomBytes(16).toString("base64url");
817
+ const { verifier, challenge } = pkcePair();
818
+ const loopback = await startLoopback(state, input.timeoutMs);
819
+ try {
820
+ const authorize = new URL(accountsEndpoints(input.accountsOrigin).authorize);
821
+ authorize.search = new URLSearchParams({
822
+ response_type: "code",
823
+ client_id: CLI_CLIENT_ID,
824
+ redirect_uri: loopback.redirectUri,
825
+ scope: CLI_LOGIN_SCOPE,
826
+ state,
827
+ code_challenge: challenge,
828
+ code_challenge_method: "S256",
829
+ resource: input.apiOrigin
830
+ }).toString();
831
+ if (!await io.openBrowser(authorize.toString())) return null;
832
+ io.out("브라우저에서 Sayren 계정으로 로그인하십시오. 열리지 않으면 아래 주소를 여십시오:");
833
+ io.out(` ${authorize.toString()}`);
834
+ const code = await loopback.code;
835
+ const now = io.now?.() ?? Date.now();
836
+ const result = await tokenRequest(io, input.accountsOrigin, {
837
+ grant_type: "authorization_code",
838
+ code,
839
+ redirect_uri: loopback.redirectUri,
840
+ code_verifier: verifier,
841
+ client_id: CLI_CLIENT_ID,
842
+ resource: input.apiOrigin
843
+ });
844
+ if (!result.ok) throw new CliError(`로그인을 마치지 못했습니다(${result.error})`);
845
+ return entryOf(result.body, input.accountsOrigin, now);
846
+ } finally {
847
+ loopback.close();
848
+ }
849
+ }
850
+ /** RFC 8628 — 주소·코드를 보이고 승인될 때까지 폴링한다 */
851
+ async function deviceLogin(io, input) {
852
+ let response;
853
+ try {
854
+ response = await io.fetch(accountsEndpoints(input.accountsOrigin).deviceCode, {
855
+ method: "POST",
856
+ headers: {
857
+ "content-type": "application/x-www-form-urlencoded",
858
+ accept: "application/json"
859
+ },
860
+ body: new URLSearchParams({
861
+ client_id: CLI_CLIENT_ID,
862
+ scope: CLI_LOGIN_SCOPE,
863
+ resource: input.apiOrigin
864
+ }).toString(),
865
+ redirect: "error"
866
+ });
867
+ } catch {
868
+ throw new CliError("계정 서버에 연결하지 못했습니다. 네트워크를 확인하십시오");
869
+ }
870
+ const device = await response.json().catch(() => null);
871
+ if (!response.ok || !device?.device_code || !device.user_code || !device.verification_uri) throw new CliError(`기기 로그인을 시작하지 못했습니다(HTTP ${response.status})`);
872
+ io.out("다른 기기의 브라우저에서 아래 주소를 열고 코드를 확인하십시오:");
873
+ io.out(` ${device.verification_uri}`);
874
+ io.out(` 코드: ${device.user_code}`);
875
+ const now = () => io.now?.() ?? Date.now();
876
+ const deadline = now() + (device.expires_in ?? 600) * 1e3;
877
+ let intervalMs = Math.max(1, device.interval ?? 5) * 1e3;
878
+ while (now() < deadline) {
879
+ await io.sleep(intervalMs);
880
+ const result = await tokenRequest(io, input.accountsOrigin, {
881
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
882
+ device_code: device.device_code,
883
+ client_id: CLI_CLIENT_ID
884
+ });
885
+ if (result.ok) return entryOf(result.body, input.accountsOrigin, now());
886
+ if (result.error === "authorization_pending") continue;
887
+ if (result.error === "slow_down") {
888
+ intervalMs += 5e3;
889
+ continue;
890
+ }
891
+ if (result.error === "access_denied") throw new CliError("기기 연결을 거부했습니다");
892
+ if (result.error === "expired_token") break;
893
+ throw new CliError(`기기 로그인을 마치지 못했습니다(${result.error})`);
894
+ }
895
+ throw new CliError("코드가 만료됐습니다. 다시 로그인하십시오");
896
+ }
897
+ /**
898
+ * 쓸 수 있는 access token — 만료 60초 전이면(또는 `force`) refresh로 갱신한다. 갱신은 파일 잠금 안에서 하고, 잠금을 얻은 뒤 파일을
899
+ * 다시 읽어 그 사이 다른 프로세스가 회전한 토큰을 쓴다. refresh가 거부되면(만료·폐기·재사용) 항목을 지우고 다시 로그인을 요구한다.
900
+ */
901
+ async function freshAccessToken(io, apiOrigin, options = {}) {
902
+ const now = () => io.now?.() ?? Date.now();
903
+ const current = readCredential(io.env, apiOrigin);
904
+ if (!current) return null;
905
+ const valid = (entry) => entry.expiresAt - REFRESH_SKEW_MS > now();
906
+ if (!options.force && valid(current)) return current;
907
+ if (!current.refreshToken) {
908
+ if (valid(current)) return current;
909
+ throw new CliError("액세스 토큰이 만료됐습니다. 새 토큰을 만들거나 npx sayren login으로 다시 로그인하십시오");
910
+ }
911
+ return withCredentialLock(io.env, io.sleep, async () => {
912
+ const latest = readCredential(io.env, apiOrigin);
913
+ if (!latest) return null;
914
+ if (latest.accessToken !== current.accessToken && valid(latest)) return latest;
915
+ if (!latest.refreshToken) return valid(latest) ? latest : null;
916
+ const result = await tokenRequest(io, latest.accountsOrigin, {
917
+ grant_type: "refresh_token",
918
+ refresh_token: latest.refreshToken,
919
+ client_id: CLI_CLIENT_ID,
920
+ resource: apiOrigin
921
+ });
922
+ if (!result.ok) {
923
+ if (result.error === "invalid_grant") {
924
+ deleteCredential(io.env, apiOrigin);
925
+ throw new CliError("로그인이 만료됐습니다. npx sayren login으로 다시 로그인하십시오");
926
+ }
927
+ throw new CliError(`로그인을 갱신하지 못했습니다(${result.error})`);
928
+ }
929
+ const next = entryOf(result.body, latest.accountsOrigin, now(), latest);
930
+ saveCredential(io.env, apiOrigin, next);
931
+ return next;
932
+ });
933
+ }
934
+ /** RFC 7009 — refresh를 폐기한다(실패는 무시). public 클라이언트라 client_id만 싣는다 */
935
+ async function revokeRefresh(io, entry) {
936
+ if (!entry.refreshToken) return true;
937
+ try {
938
+ return (await io.fetch(accountsEndpoints(entry.accountsOrigin).revoke, {
939
+ method: "POST",
940
+ headers: { "content-type": "application/x-www-form-urlencoded" },
941
+ body: new URLSearchParams({
942
+ token: entry.refreshToken,
943
+ token_type_hint: "refresh_token",
944
+ client_id: CLI_CLIENT_ID
945
+ }).toString(),
946
+ redirect: "error"
947
+ })).ok;
948
+ } catch {
949
+ return false;
182
950
  }
183
- if (link) throw new McpJsonError(`${path}가 심볼릭 링크라 쓰지 않았습니다. 링크를 지운 뒤 다시 실행하십시오`);
184
- writeFileSync(path, text, { mode: 384 });
185
- try {
186
- chmodSync(path, 384);
187
- } catch {}
951
+ }
952
+
953
+ //#endregion
954
+ //#region src/account-auth.ts
955
+ /**
956
+ * 명령이 토큰을 쓰기 전 준비(#71) — 자격 증명 파일 토큰의 갱신, 계정 토큰의 상점 고르기.
957
+ */
958
+ /** 자격 증명 파일에서 토큰을 읽는 함수(`resolveAuth`에 넘긴다) */
959
+ function credentialTokenReader(env) {
960
+ return (apiOrigin) => readCredential(env, apiOrigin)?.accessToken ?? null;
188
961
  }
189
962
  /**
190
- * `sayren` 항목을 넣는다(다른 서버는 둔다). git이 추적 중인 파일이면 쓰지 않고 `"tracked"`다.
191
- * 형식이 틀린 파일·심볼릭 링크는 `McpJsonError`다.
963
+ * 토큰 준비 — 자격 증명 파일 토큰이면 만료 60초 전에 갱신한다. 401 재시도용 갱신 함수를 돌려준다(자격 증명 파일 토큰만).
192
964
  */
193
- function writeMcpJsonFile(dir, credentials) {
194
- if (isGitTracked(dir, MCP_JSON_FILE)) return "tracked";
195
- const path = join(dir, MCP_JSON_FILE);
196
- let existing = null;
965
+ async function readyAuth(auth, io) {
966
+ if (auth.tokenSource !== "credentials") return void 0;
967
+ const fresh = await freshAccessToken(io, auth.apiOrigin);
968
+ if (!fresh) throw new CliError("로그인 정보가 없습니다. npx sayren login으로 다시 로그인하십시오");
969
+ auth.token = fresh.accessToken;
970
+ return async () => {
971
+ const next = await freshAccessToken(io, auth.apiOrigin, { force: true });
972
+ if (!next) return false;
973
+ auth.token = next.accessToken;
974
+ return true;
975
+ };
976
+ }
977
+ /**
978
+ * 계정 토큰의 상점(`X-Store-Id`) — 후보(`--store`·`--project` 등 명령이 준 순서) → `SAYREN_STORE_ID` → 폴더의 상태·`.mcp.json` →
979
+ * 토큰의 상점이 하나면 그 상점 → `GET /v1/me/stores`(하나면 그 상점, 여럿이면 대화형 선택, 비대화형이면 오류).
980
+ */
981
+ async function selectStore(auth, client, io, candidates, folderStore = null) {
982
+ const claims = accountTokenClaimsOf(auth.token);
983
+ const explicit = [
984
+ ...candidates,
985
+ io.env.SAYREN_STORE_ID?.trim(),
986
+ folderStore
987
+ ].find((value) => !!value?.trim());
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) 다른 토큰을 쓰십시오`);
991
+ auth.storeId = explicit;
992
+ return explicit;
993
+ }
994
+ const single = singleStoreOf(claims);
995
+ if (single) {
996
+ auth.storeId = single;
997
+ return single;
998
+ }
999
+ let stores;
197
1000
  try {
198
- if (!lstatSync(path).isSymbolicLink()) existing = readFileSync(path, "utf8");
1001
+ stores = (await client.auth.listMyStores()).filter((store) => storeInScope(claims, store.storeId));
199
1002
  } catch {
200
- existing = null;
1003
+ throw new CliError("상점을 고르지 못했습니다. --store <storeId> 또는 SAYREN_STORE_ID로 상점을 주십시오");
201
1004
  }
202
- writePrivateFile(path, mergeMcpJson(existing, credentials));
203
- return "written";
204
- }
1005
+ if (stores.length === 0) throw new CliError("이 계정(토큰 범위)에 쓸 수 있는 상점이 없습니다");
1006
+ if (stores.length === 1) {
1007
+ auth.storeId = stores[0].storeId;
1008
+ return auth.storeId;
1009
+ }
1010
+ const labels = stores.map((store) => `${store.storeId} — ${store.storeName} (${store.role})`);
1011
+ const picked = io.select ? await io.select("상점을 고르십시오", labels) : null;
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")}`);
1014
+ auth.storeId = stores[index].storeId;
1015
+ return auth.storeId;
1016
+ }
1017
+ /** 계정 로그인 폴더의 `.mcp.json` 안내 — 토큰 없이 주소·상점만 쓴다 */
1018
+ const ACCOUNT_MCP_NOTE = `${MCP_FILE}에는 토큰을 쓰지 않았습니다 — MCP 클라이언트가 연결할 때 Sayren 계정으로 로그인합니다`;
205
1019
 
206
1020
  //#endregion
207
1021
  //#region ../mcp/src/rules.ts
1022
+ /** 규칙의 심각도 — 없으면 `error` */
1023
+ function severityOf(rule) {
1024
+ return rule?.severity ?? "error";
1025
+ }
208
1026
  /** 문서 사이트 — `doc` 경로를 절대 주소로 만들 때 쓴다 */
209
1027
  const DOCS_BASE_URL = "https://docs.sayren.app";
210
1028
  const docUrl = (doc) => `${DOCS_BASE_URL}${doc}`;
@@ -221,7 +1039,7 @@ const RULES = [
221
1039
  {
222
1040
  id: "tenant-header",
223
1041
  title: "테넌트는 `X-Store-Code` 헤더로 보낸다",
224
- why: "공유 API 호스트에서 스토어를 가르는 값이다. 빠지면 다른 스토어 데이터가 보이거나 404가 난다",
1042
+ why: "공유 API 호스트에서 상점을 가르는 값이다. 빠지면 다른 상점 데이터가 보이거나 404가 난다",
225
1043
  fix: "`createStorefrontClient`에 `storeCode`를 넘긴다. SDK가 헤더를 붙인다. 서브도메인으로 서비스할 때만 생략한다",
226
1044
  example: "createStorefrontClient({ baseUrl: API_BASE_URL, storeCode: resolveStoreCode() });",
227
1045
  doc: "/guides/storefront-sdk",
@@ -302,7 +1120,7 @@ const RULES = [
302
1120
  {
303
1121
  id: "checkout-origin",
304
1122
  title: "복귀 주소는 코드에 고정하지 않고 배포된 주소(요청 origin)에서 만든다",
305
- why: "주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 제대로 돌아온다. 셀러 콘솔 설정 › 결제의 결제 도메인은 선택형 허용 목록이다. 비워 두면 https 복귀 주소를 모두 받고, 등록하면 목록 밖의 복귀 주소를 서버가 거절한다",
1123
+ why: "주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 제대로 돌아온다. 상점 플랫폼 설정 › 결제의 결제 도메인은 선택형 허용 목록이다. 비워 두면 https 복귀 주소를 모두 받고, 등록하면 목록 밖의 복귀 주소를 서버가 거절한다",
306
1124
  fix: "서버 함수에서 `getRequestUrl()`의 origin으로 복귀 주소를 만든다. 결제 도메인을 등록해 두었다면 배포 도메인도 목록에 넣는다(테스트 결제는 localhost가 항상 허용된다)",
307
1125
  example: "const returnUrl = new URL(PAYMENT_RETURN_PATH, new URL(getRequestUrl()).origin);\nawait api.checkout.startPayment(checkoutId, { option, returnUrl: returnUrl.toString() });",
308
1126
  doc: "/guides/payment-settings",
@@ -347,8 +1165,8 @@ const RULES = [
347
1165
  {
348
1166
  id: "buyer-signup",
349
1167
  title: "이메일·비밀번호 로그인을 받으면 가입 화면도 둔다(`auth.signUp`)",
350
- why: "비밀번호 로그인만 있고 가입이 없으면 소셜 로그인을 켜지 않은 스토어에서 새 구매자가 계정을 만들 수 없다",
351
- 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 쿠키에 담는다",
352
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);",
353
1171
  doc: "/guides/storefront-auth",
354
1172
  templateFile: "src/routes/signup.tsx"
@@ -365,7 +1183,7 @@ const RULES = [
365
1183
  {
366
1184
  id: "analytics-start",
367
1185
  title: "방문 분석은 브라우저에서 한 번 시작한다(`@sayren/storefront-sdk/analytics`)",
368
- why: "빠지면 셀러 콘솔 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다",
1186
+ why: "빠지면 상점 플랫폼 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다",
369
1187
  fix: "루트 컴포넌트의 effect에서 `createAnalytics`를 한 번 부르고(설정은 loader가 내려준다), 화면은 그 인스턴스의 `track`으로 행동을 남긴다. 장바구니·결제 시작·구매는 서버가 기록하므로 보내지 않는다",
370
1188
  example: "import { createAnalytics } from \"@sayren/storefront-sdk/analytics\";\n\nuseEffect(() => {\n startAnalytics({ apiBaseUrl, storeCode, debug }); // 브라우저에서 한 번\n}, []);",
371
1189
  doc: "/guides/storefront-analytics",
@@ -388,6 +1206,16 @@ const RULES = [
388
1206
  example: "// 서버 함수 본문으로 보낸다\nawait addToCart({ data: { productId, optionId, quantity, addons, customInputs } });\n\n// 바로구매 — 값은 HttpOnly 쿠키, 주소에는 표시만\nwriteDirectLine(line); // 서버 함수 안\nnavigate({ to: \"/checkout\", search: { direct: 1, productId } });",
389
1207
  doc: "/guides/product-options",
390
1208
  templateFile: "src/lib/direct-checkout.server.ts"
1209
+ },
1210
+ {
1211
+ id: "checkout-availability",
1212
+ title: "주문을 받지 않는 상점이면 주문 버튼을 그리지 않는다(`checkoutAvailable`)",
1213
+ why: "결제 수단이 없거나 공개한 쇼핑몰의 실결제가 아직 열리지 않은 상점은 결제 요청이 409 `PAYMENT_NOT_CONFIGURED`다. 버튼을 그대로 두면 구매자가 담고 주문서까지 가서야 결제가 안 된다는 것을 안다",
1214
+ fix: "`GET /store`(`api.store.get()`)의 `checkoutAvailable`이 false면 장바구니 담기·바로구매·주문 버튼과 헤더의 장바구니를 숨기고 「지금은 온라인 주문을 받지 않습니다」를 보인다. `customerCenterPhone`이 있으면 문의 전화를 함께 보인다",
1215
+ example: "const store = await apiFor().store.get();\nif (!store.checkoutAvailable) {\n // 주문 버튼 대신 안내\n return <p>지금은 온라인 주문을 받지 않습니다</p>;\n}",
1216
+ doc: "/guides/storefront-sdk",
1217
+ templateFile: "src/lib/ordering.ts",
1218
+ severity: "warning"
391
1219
  }
392
1220
  ];
393
1221
  /**
@@ -408,14 +1236,14 @@ const SERVER_FILE = /\.server\.(ts|tsx)$/;
408
1236
  /** 토큰을 브라우저 저장소에 쓰는 코드 */
409
1237
  const BROWSER_TOKEN_STORAGE = /(localStorage|sessionStorage)\.setItem\(\s*[`'"][^`'"]*([Tt]oken|refresh|session|sayren_member)/;
410
1238
  /**
411
- * 직접 입력값이 새는 자리 — URL 검색 파라미터(`search`·`searchParams`), 브라우저 저장소, 콘솔.
1239
+ * 직접 입력값이 새는 자리 — URL 검색 파라미터(`search`·`searchParams`), 브라우저 저장소, 상점 플랫폼.
412
1240
  * 같은 식 안에서 `customInputs`가 보일 때만 잡는다.
413
1241
  */
414
1242
  const CUSTOM_INPUT_LEAKS = [
415
1243
  [/search\s*[:=]\s*\{\{?[^}]*\bcustomInputs\b/, "URL 검색 파라미터"],
416
1244
  [/searchParams\.(set|append)\([^)]*\bcustomInputs\b/, "URL 검색 파라미터"],
417
1245
  [/(localStorage|sessionStorage)\.setItem\([^)]*\bcustomInputs\b/, "브라우저 저장소"],
418
- [/console\.(log|info|warn|error|debug)\([^)]*\bcustomInputs\b/, "콘솔 로그"]
1246
+ [/console\.(log|info|warn|error|debug)\([^)]*\bcustomInputs\b/, "상점 플랫폼 로그"]
419
1247
  ];
420
1248
  /**
421
1249
  * 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
@@ -489,6 +1317,7 @@ function verifySources(files) {
489
1317
  if (!matches(/\b(auth|authFor\(\))\.signOut\(|\.signOut\(\s*accessToken\b/)) inProject("buyer-signout", "로그아웃에서 리프레시 토큰을 폐기하는 코드가 없다(`auth.signOut(accessToken)`)");
490
1318
  if (!has("reconsentRequired")) inProject("buyer-reconsent", "재동의가 필요한 구매자를 처리하는 코드가 없다(`reconsentRequired`)");
491
1319
  }
1320
+ if (has("startPayment(") && !has("checkoutAvailable")) inProject("checkout-availability", "결제를 시작하지만 `GET /store`의 `checkoutAvailable`을 읽는 코드가 없다. 주문을 받지 않는 상점에서도 주문 버튼이 보인다");
492
1321
  if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) inProject("analytics-start", "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다");
493
1322
  const clientQuery = matches(/\buse(Suspense)?Query\(/);
494
1323
  if (clientQuery && !has("ensureQueryData(") && !has("prefetchQuery(")) inFile("loader-first", clientQuery, "TanStack Query로 데이터를 받지만 loader에서 `queryClient.ensureQueryData`로 미리 받는 코드가 없다. 서버 렌더에 데이터가 빠진다", /\buse(Suspense)?Query\(/);
@@ -512,13 +1341,14 @@ function report(finding) {
512
1341
  example: rule?.example ?? "",
513
1342
  doc: rule?.doc ?? "",
514
1343
  docUrl: rule ? docUrl(rule.doc) : "",
515
- ...rule?.templateFile ? { templateFile: rule.templateFile } : {}
1344
+ ...rule?.templateFile ? { templateFile: rule.templateFile } : {},
1345
+ severity: severityOf(rule)
516
1346
  };
517
1347
  }
518
1348
  /** 위반을 고치는 순서 — 에이전트가 무엇부터 할지 묻지 않게 한다 */
519
1349
  function nextSteps(findings, target) {
520
1350
  if (!findings.length) return target === "remote" ? [
521
- "사용자에게 콘솔 스토어프론트 공간의 [미리보기 열기]로 화면을 확인해 달라고 전한다.",
1351
+ "사용자에게 상점 플랫폼 스토어프론트 공간의 [미리보기 열기]로 화면을 확인해 달라고 전한다.",
522
1352
  "`create_version`으로 버전을 만들고 `get_build`로 빌드 결과를 확인한다.",
523
1353
  "정적 검사가 잡지 못하는 것은 실제 결제 한 번이 잡는다. 발행 전에 테스트 결제를 권한다."
524
1354
  ] : [
@@ -549,12 +1379,28 @@ function reportFromFindings(findings, checkedFiles, target = "local") {
549
1379
  const results = RULES.map((rule) => ({
550
1380
  ruleId: rule.id,
551
1381
  status: counts.has(rule.id) ? "violation" : "pass",
552
- count: counts.get(rule.id) ?? 0
1382
+ count: counts.get(rule.id) ?? 0,
1383
+ severity: severityOf(rule)
553
1384
  }));
554
1385
  const violated = results.filter((result) => result.status === "violation").length;
555
1386
  const reported = findings.map(report);
1387
+ const blocking = reported.filter((finding) => finding.severity === "error");
1388
+ if (findings.length > 0 && blocking.length === 0) return {
1389
+ ok: true,
1390
+ checkedFiles,
1391
+ summary: {
1392
+ rules: RULES.length,
1393
+ passed: RULES.length - violated,
1394
+ violated,
1395
+ findings: findings.length
1396
+ },
1397
+ results,
1398
+ findings: reported,
1399
+ message: `막는 위반은 없습니다. 권고 ${findings.length}건이 있습니다 — findings의 fix대로 고치기를 권합니다`,
1400
+ nextSteps: nextSteps([], target)
1401
+ };
556
1402
  return {
557
- ok: findings.length === 0,
1403
+ ok: blocking.length === 0,
558
1404
  checkedFiles,
559
1405
  summary: {
560
1406
  rules: RULES.length,
@@ -649,370 +1495,120 @@ function formatProblem(path) {
649
1495
  }
650
1496
  /** 위치 규칙: 어떤 모드로 이 경로에 닿을 수 있는가 */
651
1497
  function accessOf(path) {
652
- if (formatProblem(path) !== null) return null;
653
- const segments = path.split("/");
654
- const [first] = segments;
655
- if (!first) return null;
656
- if (segments.length === 1) {
657
- if (WRITABLE_ROOT_FILES.has(first)) return "write";
658
- if (READONLY_ROOT_FILES.has(first)) return "read";
659
- return null;
660
- }
661
- if (!WRITABLE_DIRS.has(first)) return null;
662
- if (segments.some((s) => s.startsWith("."))) return null;
663
- return "write";
664
- }
665
-
666
- //#endregion
667
- //#region ../../apps/hosting-sandbox/src/tar.ts
668
- const BLOCK = 512;
669
- const enc = new TextEncoder();
670
- const dec = new TextDecoder();
671
- function readString(buf, offset, length) {
672
- let end = offset;
673
- while (end < offset + length && buf[end] !== 0) end++;
674
- return dec.decode(buf.subarray(offset, end));
675
- }
676
- function readOctal(buf, offset, length) {
677
- const s = readString(buf, offset, length).trim();
678
- if (!s) return 0;
679
- if (!/^[0-7]+$/.test(s)) throw new Error("tar 숫자 필드 형식 오류");
680
- return Number.parseInt(s, 8);
681
- }
682
- function parsePax(text) {
683
- const out = {};
684
- let i = 0;
685
- while (i < text.length) {
686
- const sp = text.indexOf(" ", i);
687
- if (sp < 0) break;
688
- const len = Number(text.slice(i, sp));
689
- if (!Number.isInteger(len) || len <= 0) break;
690
- const record = text.slice(sp + 1, i + len - 1);
691
- const eq = record.indexOf("=");
692
- if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
693
- i += len;
694
- }
695
- return out;
696
- }
697
- /** tar 바이트 → 일반 파일 항목. `maxBytes`는 풀어낸 파일 합계 상한(압축 폭탄 방어) */
698
- function readTar(buf, maxBytes = Number.POSITIVE_INFINITY) {
699
- const out = [];
700
- let off = 0;
701
- let longName = null;
702
- let paxPath = null;
703
- let total = 0;
704
- while (off + BLOCK <= buf.length) {
705
- const h = buf.subarray(off, off + BLOCK);
706
- if (h.every((b) => b === 0)) break;
707
- const size = readOctal(h, 124, 12);
708
- const type = String.fromCharCode(h[156] ?? 0);
709
- const dataStart = off + BLOCK;
710
- const dataEnd = dataStart + size;
711
- if (dataEnd > buf.length) throw new Error("tar가 잘렸습니다");
712
- const data = buf.subarray(dataStart, dataEnd);
713
- off = dataStart + Math.ceil(size / BLOCK) * BLOCK;
714
- if (type === "L") {
715
- longName = readString(data, 0, data.length);
716
- continue;
717
- }
718
- if (type === "x") {
719
- paxPath = parsePax(dec.decode(data)).path ?? null;
720
- continue;
721
- }
722
- if (type === "g") continue;
723
- let path = longName ?? paxPath;
724
- longName = null;
725
- paxPath = null;
726
- if (path === null) {
727
- const name = readString(h, 0, 100);
728
- const prefix = readString(h, 345, 155);
729
- path = prefix ? `${prefix}/${name}` : name;
730
- }
731
- if (type !== "0" && type !== "\0") continue;
732
- while (path.startsWith("./")) path = path.slice(2);
733
- if (!path) continue;
734
- total += size;
735
- if (total > maxBytes) throw new Error("tar 합계가 상한을 넘습니다");
736
- out.push({
737
- path,
738
- bytes: data.slice()
739
- });
740
- }
741
- return out;
742
- }
743
- /** gunzip. 풀린 크기가 `maxBytes`를 넘으면 멈춘다 */
744
- async function gunzip(bytes, maxBytes) {
745
- const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")).getReader();
746
- const chunks = [];
747
- let total = 0;
748
- for (;;) {
749
- const { done, value } = await reader.read();
750
- if (done) break;
751
- total += value.length;
752
- if (total > maxBytes) {
753
- await reader.cancel();
754
- throw new Error("압축을 푼 크기가 상한을 넘습니다");
755
- }
756
- chunks.push(value);
757
- }
758
- const out = new Uint8Array(total);
759
- let off = 0;
760
- for (const c of chunks) {
761
- out.set(c, off);
762
- off += c.length;
763
- }
764
- return out;
765
- }
766
-
767
- //#endregion
768
- //#region src/config.ts
769
- /**
770
- * 설정 (2026-09-29 사용자 결정: `.sayrenrc`를 없애고 받은 폴더에 원격 MCP `.mcp.json`을 둔다)
771
- *
772
- * - `.sayren/state.json`: pull·deploy가 쓰는 비밀 아닌 상태(`storeId`·`siteId`·`baseRef`, 기본이 아닌 api 주소). `.gitignore` 대상이고
773
- * 올리지 않는다. `baseRef`가 deploy의 `expectedBaseRef`다(원격이 pull 이후 바뀌었으면 `SOURCE_CHANGED`).
774
- * - `.mcp.json`: Claude Code 형식의 `sayren` 원격 MCP 항목. **토큰이 여기 있다**(Authorization 헤더). `.gitignore` 대상이고 올리지 않는다.
775
- * - `.env`: 스토어프론트 템플릿 개발 서버가 읽는 값(`SAYREN_API_URL`·`SAYREN_STORE_CODE`)만 둔다. 토큰을 두지 않는다.
776
- *
777
- * 토큰은 `--token` → `SAYREN_TOKEN` 환경 변수 → `.mcp.json` 순서다. api 주소는 `--api-origin` → `SAYREN_API_ORIGIN` → `.env`의
778
- * `SAYREN_API_URL` → 상태 파일 → 기본값 순서이고, 파일에서 온 기본이 아닌 주소는 확인한다(`checkAuthSources`).
779
- * 옛 폴더(`.sayrenrc`, `.env`의 `SAYREN_TOKEN`)는 한 번 옮긴다(`migrateLegacy`).
780
- */
781
- const STATE_DIR = ".sayren";
782
- const STATE_FILE = `${STATE_DIR}/state.json`;
783
- /** 옛 상태 파일(0.1.x) — 읽어서 옮기고 지운다 */
784
- const LEGACY_RC_FILE = ".sayrenrc";
785
- const ENV_FILE = ".env";
786
- const MCP_FILE = MCP_JSON_FILE;
787
- const DEFAULT_API_ORIGIN = "https://api.sayren.app";
788
- function parseState(text, name) {
789
- let parsed;
790
- try {
791
- parsed = JSON.parse(text);
792
- } catch {
793
- throw new CliError(`${name}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
794
- }
795
- if (!parsed || typeof parsed !== "object") throw new CliError(`${name} 형식이 아닙니다`);
796
- const raw = parsed;
797
- if (typeof raw.storeId !== "string" || typeof raw.siteId !== "string" || typeof raw.baseRef !== "string") throw new CliError(`${name}에 storeId·siteId·baseRef가 없습니다. npx sayren pull로 다시 받으십시오`);
798
- return {
799
- storeId: raw.storeId,
800
- siteId: raw.siteId,
801
- baseRef: raw.baseRef,
802
- ...typeof raw.apiOrigin === "string" ? { apiOrigin: raw.apiOrigin } : {}
803
- };
804
- }
805
- function readState(dir) {
806
- const path = join(dir, STATE_FILE);
807
- if (!existsSync(path)) return null;
808
- return parseState(readFileSync(path, "utf8"), STATE_FILE);
809
- }
810
- function writeState(dir, state) {
811
- const out = {
812
- storeId: state.storeId,
813
- siteId: state.siteId,
814
- baseRef: state.baseRef
815
- };
816
- if (state.apiOrigin && state.apiOrigin !== DEFAULT_API_ORIGIN) out.apiOrigin = state.apiOrigin;
817
- mkdirSync(join(dir, STATE_DIR), { recursive: true });
818
- writeFileSync(join(dir, STATE_FILE), `${JSON.stringify(out, null, 2)}\n`);
819
- }
820
- /** 받은 폴더인가 — 상태 파일 또는 옛 `.sayrenrc`가 있다 */
821
- function isPulledDir(dir) {
822
- return existsSync(join(dir, STATE_FILE)) || existsSync(join(dir, LEGACY_RC_FILE));
823
- }
824
- function readMcpText(dir) {
825
- const path = join(dir, MCP_FILE);
826
- return existsSync(path) ? readFileSync(path, "utf8") : null;
827
- }
828
- /** `.mcp.json`의 `sayren` 토큰·스토어. 파일·항목이 없으면 null 값이다 */
829
- function readMcpCredentials(dir) {
830
- const text = readMcpText(dir);
831
- if (text === null) return {
832
- token: null,
833
- storeId: null
834
- };
835
- try {
836
- return sayrenCredentialsOf(text);
837
- } catch (error) {
838
- if (error instanceof McpJsonError) throw new CliError(error.message);
839
- throw error;
840
- }
841
- }
842
- /** `.mcp.json`에 `sayren` 원격 MCP 항목을 쓴다. 다른 서버는 그대로 둔다. 파일 권한은 600이다(토큰) */
843
- /**
844
- * git이 추적 중인 `.mcp.json`(팀이 커밋해 둔 설정)에는 토큰을 쓰지 않고 `"tracked"`다 — 부르는 쪽이 `TRACKED_MCP_JSON_NOTICE`로 안내한다.
845
- * `.gitignore`는 추적 중인 파일을 막지 못한다.
846
- */
847
- function writeMcpJson(dir, credentials) {
848
- try {
849
- return writeMcpJsonFile(dir, credentials);
850
- } catch (error) {
851
- if (error instanceof McpJsonError) throw new CliError(`${error.message}. 고치거나 옮긴 뒤 다시 시도하십시오(덮어쓰지 않았습니다)`);
852
- throw error;
853
- }
854
- }
855
- /** 권한 600. 심볼릭 링크면 거부한다(링크를 따라 폴더 밖 파일을 덮지 않게) */
856
- function writePrivate(path, text) {
857
- try {
858
- writePrivateFile(path, text);
859
- } catch (error) {
860
- if (error instanceof McpJsonError) throw new CliError(error.message);
861
- throw error;
862
- }
863
- }
864
- /**
865
- * 0.1.x가 받은 폴더를 한 번 옮긴다. `.sayrenrc` → `.sayren/state.json`(옛 파일은 지운다), `.env`의 `SAYREN_TOKEN` → `.mcp.json`
866
- * (`.env`에서 그 줄을 지운다). `.mcp.json`에 이미 토큰이 있으면 그 값을 쓰고 `.env` 줄만 지운다. 옮겼으면 한 줄로 알린다.
867
- */
868
- function migrateLegacy(dir, notify) {
869
- if (!isPulledDir(dir)) return;
870
- const done = [];
871
- const rcPath = join(dir, LEGACY_RC_FILE);
872
- if (existsSync(rcPath)) {
873
- const tracked = isGitTracked(dir, LEGACY_RC_FILE);
874
- const hasState = existsSync(join(dir, STATE_FILE));
875
- if (!hasState) writeState(dir, parseState(readFileSync(rcPath, "utf8"), LEGACY_RC_FILE));
876
- if (!tracked) {
877
- rmSync(rcPath, { force: true });
878
- done.push(`${LEGACY_RC_FILE} → ${STATE_FILE}`);
879
- } else if (!hasState) done.push(`${LEGACY_RC_FILE} → ${STATE_FILE}(${LEGACY_RC_FILE}는 더 쓰지 않으니 커밋에서 지워도 됩니다)`);
880
- }
881
- const envToken = readEnvFile(dir).get("SAYREN_TOKEN");
882
- if (envToken !== void 0) {
883
- const current = readMcpCredentials(dir);
884
- let moved = true;
885
- if (envToken.trim() && !current.token) if (writeMcpJson(dir, {
886
- token: envToken.trim(),
887
- storeId: current.storeId ?? readState(dir)?.storeId ?? null
888
- }) === "tracked") {
889
- moved = false;
890
- notify(`알림: ${ENV_FILE}의 SAYREN_TOKEN을 옮기지 못했습니다. ${TRACKED_MCP_JSON_NOTICE}`);
891
- } else done.push(`${ENV_FILE}의 SAYREN_TOKEN → ${MCP_FILE}`);
892
- else done.push(`${ENV_FILE}의 SAYREN_TOKEN 삭제(${MCP_FILE}의 토큰을 씁니다)`);
893
- if (moved) updateEnvFile(dir, {}, ["SAYREN_TOKEN"]);
894
- }
895
- if (done.length > 0) {
896
- const ignored = ensureGitignore(dir);
897
- notify(`알림: 옛 설정을 옮겼습니다 — ${done.join(", ")}${ignored.length ? ` (.gitignore에 ${ignored.join(" ")} 추가)` : ""}`);
1498
+ if (formatProblem(path) !== null) return null;
1499
+ const segments = path.split("/");
1500
+ const [first] = segments;
1501
+ if (!first) return null;
1502
+ if (segments.length === 1) {
1503
+ if (WRITABLE_ROOT_FILES.has(first)) return "write";
1504
+ if (READONLY_ROOT_FILES.has(first)) return "read";
1505
+ return null;
898
1506
  }
1507
+ if (!WRITABLE_DIRS.has(first)) return null;
1508
+ if (segments.some((s) => s.startsWith("."))) return null;
1509
+ return "write";
899
1510
  }
900
- /** `.env` 읽기 — `KEY=VALUE` 줄만(따옴표는 벗긴다). 없으면 빈 값 */
901
- function readEnvFile(dir) {
902
- const path = join(dir, ENV_FILE);
903
- const out = /* @__PURE__ */ new Map();
904
- if (!existsSync(path)) return out;
905
- for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
906
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
907
- if (!m?.[1]) continue;
908
- let value = m[2] ?? "";
909
- if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
910
- out.set(m[1], value);
1511
+
1512
+ //#endregion
1513
+ //#region ../../apps/hosting-sandbox/src/tar.ts
1514
+ const BLOCK = 512;
1515
+ const enc = new TextEncoder();
1516
+ const dec = new TextDecoder();
1517
+ function readString(buf, offset, length) {
1518
+ let end = offset;
1519
+ while (end < offset + length && buf[end] !== 0) end++;
1520
+ return dec.decode(buf.subarray(offset, end));
1521
+ }
1522
+ function readOctal(buf, offset, length) {
1523
+ const s = readString(buf, offset, length).trim();
1524
+ if (!s) return 0;
1525
+ if (!/^[0-7]+$/.test(s)) throw new Error("tar 숫자 필드 형식 오류");
1526
+ return Number.parseInt(s, 8);
1527
+ }
1528
+ function parsePax(text) {
1529
+ const out = {};
1530
+ let i = 0;
1531
+ while (i < text.length) {
1532
+ const sp = text.indexOf(" ", i);
1533
+ if (sp < 0) break;
1534
+ const len = Number(text.slice(i, sp));
1535
+ if (!Number.isInteger(len) || len <= 0) break;
1536
+ const record = text.slice(sp + 1, i + len - 1);
1537
+ const eq = record.indexOf("=");
1538
+ if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
1539
+ i += len;
911
1540
  }
912
1541
  return out;
913
1542
  }
914
- /** `.env`에 값들을 쓴다. 다른 줄은 그대로 두고 같은 키만 바꾸거나(`values`) 지운다(`remove`). 파일 권한은 600이다 */
915
- function updateEnvFile(dir, values, remove = []) {
916
- const path = join(dir, ENV_FILE);
917
- const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
918
- const pending = new Map(Object.entries(values));
919
- const next = [];
920
- for (const line of lines) {
921
- const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
922
- if (key && remove.includes(key)) continue;
923
- if (key && pending.has(key)) {
924
- next.push(`${key}=${pending.get(key)}`);
925
- pending.delete(key);
1543
+ /** tar 바이트 → 일반 파일 항목. `maxBytes`는 풀어낸 파일 합계 상한(압축 폭탄 방어) */
1544
+ function readTar(buf, maxBytes = Number.POSITIVE_INFINITY) {
1545
+ const out = [];
1546
+ let off = 0;
1547
+ let longName = null;
1548
+ let paxPath = null;
1549
+ let total = 0;
1550
+ while (off + BLOCK <= buf.length) {
1551
+ const h = buf.subarray(off, off + BLOCK);
1552
+ if (h.every((b) => b === 0)) break;
1553
+ const size = readOctal(h, 124, 12);
1554
+ const type = String.fromCharCode(h[156] ?? 0);
1555
+ const dataStart = off + BLOCK;
1556
+ const dataEnd = dataStart + size;
1557
+ if (dataEnd > buf.length) throw new Error("tar가 잘렸습니다");
1558
+ const data = buf.subarray(dataStart, dataEnd);
1559
+ off = dataStart + Math.ceil(size / BLOCK) * BLOCK;
1560
+ if (type === "L") {
1561
+ longName = readString(data, 0, data.length);
926
1562
  continue;
927
1563
  }
928
- next.push(line);
1564
+ if (type === "x") {
1565
+ paxPath = parsePax(dec.decode(data)).path ?? null;
1566
+ continue;
1567
+ }
1568
+ if (type === "g") continue;
1569
+ let path = longName ?? paxPath;
1570
+ longName = null;
1571
+ paxPath = null;
1572
+ if (path === null) {
1573
+ const name = readString(h, 0, 100);
1574
+ const prefix = readString(h, 345, 155);
1575
+ path = prefix ? `${prefix}/${name}` : name;
1576
+ }
1577
+ if (type !== "0" && type !== "\0") continue;
1578
+ while (path.startsWith("./")) path = path.slice(2);
1579
+ if (!path) continue;
1580
+ total += size;
1581
+ if (total > maxBytes) throw new Error("tar 합계가 상한을 넘습니다");
1582
+ out.push({
1583
+ path,
1584
+ bytes: data.slice()
1585
+ });
929
1586
  }
930
- while (next.length > 0 && next[next.length - 1] === "") next.pop();
931
- for (const [key, value] of pending) next.push(`${key}=${value}`);
932
- writePrivate(path, next.length ? `${next.join("\n")}\n` : "");
933
- }
934
- /** `.gitignore`에 꼭 있어야 하는 줄 — 토큰(`.mcp.json`)·개발 서버 값(`.env`)·CLI 상태(`.sayren/`) */
935
- const REQUIRED_IGNORES = [
936
- ENV_FILE,
937
- MCP_FILE,
938
- `${STATE_DIR}/`
939
- ];
940
- const IGNORE_ALIASES = {
941
- [ENV_FILE]: [
942
- ".env",
943
- ".env*",
944
- "/.env"
945
- ],
946
- [MCP_FILE]: [".mcp.json", "/.mcp.json"],
947
- [`${STATE_DIR}/`]: [
948
- ".sayren",
949
- ".sayren/",
950
- "/.sayren",
951
- "/.sayren/"
952
- ]
953
- };
954
- /** `.gitignore`에 빠진 줄(`.env`·`.mcp.json`·`.sayren/`)을 더한다. 더한 줄을 돌려준다 */
955
- function ensureGitignore(dir) {
956
- const path = join(dir, ".gitignore");
957
- const text = existsSync(path) ? readFileSync(path, "utf8") : "";
958
- const lines = new Set(text.split(/\r?\n/).map((line) => line.trim()));
959
- const missing = REQUIRED_IGNORES.filter((entry) => !(IGNORE_ALIASES[entry] ?? [entry]).some((alias) => lines.has(alias)));
960
- if (missing.length === 0) return [];
961
- writeFileSync(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${missing.map((entry) => `${entry}\n`).join("")}`);
962
- return missing;
963
- }
964
- /** 스토어프론트 API 주소(`…/storefront/v1`)에서 api origin을 읽는다 */
965
- function originOfStorefrontUrl(url) {
966
- if (!url) return null;
967
- return /^(https?:\/\/[^/]+)\/storefront\/v1\/?$/.exec(url.trim())?.[1] ?? null;
968
- }
969
- function resolveAuth(flags, dir, state, env = process.env) {
970
- const dotenv = readEnvFile(dir);
971
- const fromFlag = flags.token?.trim();
972
- const fromEnv = env.SAYREN_TOKEN?.trim();
973
- const fromFile = fromFlag || fromEnv ? null : readMcpCredentials(dir).token;
974
- const token = fromFlag || fromEnv || fromFile;
975
- if (!token) throw new CliError(`토큰이 없습니다. npx sayren login으로 ${MCP_FILE}에 저장하거나 SAYREN_TOKEN 환경 변수·--token으로 주십시오(셀러 콘솔 › 개발자 › API 토큰, 스코프 storefront:rw)`);
976
- const picked = [
977
- ["flag", flags.apiOrigin?.trim()],
978
- ["env", env.SAYREN_API_ORIGIN?.trim()],
979
- [".env", originOfStorefrontUrl(dotenv.get("SAYREN_API_URL"))],
980
- ["state", state?.apiOrigin]
981
- ].find(([, value]) => value);
982
- const apiOrigin = (picked?.[1] ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
983
- assertOriginShape(apiOrigin);
984
- return {
985
- token,
986
- apiOrigin,
987
- tokenSource: fromFlag ? "flag" : fromEnv ? "env" : "mcp",
988
- originSource: picked && apiOrigin !== DEFAULT_API_ORIGIN ? picked[0] : "default"
989
- };
1587
+ return out;
990
1588
  }
991
- /** 로컬 개발 주소 — http를 받는다 */
992
- const LOOPBACK = new Set([
993
- "localhost",
994
- "127.0.0.1",
995
- "[::1]"
996
- ]);
997
- /** api 주소 형식 — https만 받는다(토큰을 싣고 보낸다). localhost·127.0.0.1만 http 예외다 */
998
- function assertOriginShape(origin) {
999
- let url;
1000
- try {
1001
- url = new URL(origin);
1002
- } catch {
1003
- throw new CliError(`api 주소 형식이 아닙니다: ${origin}`);
1589
+ /** gunzip. 풀린 크기가 `maxBytes`를 넘으면 멈춘다 */
1590
+ async function gunzip(bytes, maxBytes) {
1591
+ const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")).getReader();
1592
+ const chunks = [];
1593
+ let total = 0;
1594
+ for (;;) {
1595
+ const { done, value } = await reader.read();
1596
+ if (done) break;
1597
+ total += value.length;
1598
+ if (total > maxBytes) {
1599
+ await reader.cancel();
1600
+ throw new Error("압축을 푼 크기가 상한을 넘습니다");
1601
+ }
1602
+ chunks.push(value);
1004
1603
  }
1005
- const loopback = LOOPBACK.has(url.hostname);
1006
- if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) throw new CliError(`api 주소는 https여야 합니다(localhost만 http 예외): ${origin}. 토큰이 이 주소로 갑니다`);
1007
- }
1008
- /** 사용자에게 보여 줄 오류 — 스택 없이 문장만 찍고 종료 코드 1로 끝난다 */
1009
- var CliError = class extends Error {
1010
- constructor(message, exitCode = 1) {
1011
- super(message);
1012
- this.exitCode = exitCode;
1013
- this.name = "CliError";
1604
+ const out = new Uint8Array(total);
1605
+ let off = 0;
1606
+ for (const c of chunks) {
1607
+ out.set(c, off);
1608
+ off += c.length;
1014
1609
  }
1015
- };
1610
+ return out;
1611
+ }
1016
1612
 
1017
1613
  //#endregion
1018
1614
  //#region src/tree.ts
@@ -1170,7 +1766,7 @@ function treeFingerprint(files) {
1170
1766
  function runLocalChecks(tree) {
1171
1767
  const map = new Map(tree.files.map((file) => [file.path, file.bytes]));
1172
1768
  const report$1 = verifyTree(map);
1173
- const blockingRules = report$1.results.filter((result) => result.status === "violation");
1769
+ const blockingRules = report$1.results.filter((result) => result.status === "violation" && result.severity !== "warning");
1174
1770
  const problems = [...limitProblems(tree)];
1175
1771
  const warnings = [];
1176
1772
  const text = (path) => {
@@ -1262,6 +1858,26 @@ const MAX_BLOB_ROUNDS = 3;
1262
1858
  /** 올리기 한 번의 내용 합계 상한 — base64(×4/3)가 api 본문 8MiB 안에 든다 */
1263
1859
  const BLOB_BATCH_BYTES = 5 * 1024 * 1024;
1264
1860
  /**
1861
+ * 토큰·api 주소를 정하고(자격 증명 파일 포함) 준비한 관리 API 클라이언트 — 상점을 고른다(`candidates` 먼저).
1862
+ */
1863
+ async function apiFor(flags, dir, state, io, candidates = []) {
1864
+ const auth = resolveAuth(flags, dir, state, io.env, credentialTokenReader(io.env));
1865
+ await checkAuthSources(auth, io);
1866
+ const refresh = await readyAuth(auth, io);
1867
+ const client = clientOf(auth, io.fetch, refresh);
1868
+ let folderStore = state?.storeId ?? null;
1869
+ if (!folderStore) try {
1870
+ folderStore = readMcpStoreId(dir);
1871
+ } catch {
1872
+ folderStore = null;
1873
+ }
1874
+ await selectStore(auth, client, io, [flags.store, ...candidates], folderStore);
1875
+ return {
1876
+ auth,
1877
+ client
1878
+ };
1879
+ }
1880
+ /**
1265
1881
  * 토큰·api 주소 확인 — `--token`은 셸 기록·프로세스 목록에 남으니 알리고, 파일(`.env`·`.sayren/state.json`)에서 읽은 기본이 아닌 api
1266
1882
  * 주소는 토큰이 그곳으로 가므로 확인한다(대화형이면 묻고, 아니면 `--api-origin`·`SAYREN_API_ORIGIN`으로 명시해야 한다)
1267
1883
  */
@@ -1277,14 +1893,12 @@ async function pull(flags, io) {
1277
1893
  const probeDir = flags.dir ? resolve(io.cwd, flags.dir) : io.cwd;
1278
1894
  const authDir = existsSync(probeDir) ? probeDir : io.cwd;
1279
1895
  migrateLegacy(authDir, io.out);
1280
- const auth = resolveAuth(flags, authDir, null, io.env);
1281
- await checkAuthSources(auth, io);
1282
- const client = clientOf(auth, io.fetch);
1896
+ const { auth, client } = await apiFor(flags, authDir, null, io, [flags.project]);
1283
1897
  const hosting = await client.storefront.get();
1284
- if (!hosting.site) throw new CliError("이 스토어에는 호스팅 사이트가 없습니다. 셀러 콘솔 › 스토어프론트에서 사이트를 만든 뒤 다시 받으십시오");
1898
+ if (!hosting.site) throw new CliError("이 상점에는 호스팅 사이트가 없습니다. 상점 플랫폼 › 스토어프론트에서 사이트를 만든 뒤 다시 받으십시오");
1285
1899
  if (!hosting.editingEnabled) throw new CliError("지금은 스토어프론트 소스를 받을 수 없습니다(편집 기능이 꺼져 있습니다)");
1286
1900
  const source = await client.storefront.getSource();
1287
- if (flags.project && flags.project !== source.storeId) throw new CliError(`토큰의 스토어(${source.storeId})와 --project(${flags.project})가 다릅니다. 그 스토어에서 발급한 토큰을 쓰십시오`);
1901
+ if (flags.project && flags.project !== source.storeId) throw new CliError(`받은 상점(${source.storeId})과 --project(${flags.project})가 다릅니다. --project 또는 SAYREN_STORE_ID를 확인하십시오`);
1288
1902
  const store = {
1289
1903
  storeId: source.storeId,
1290
1904
  storeCode: source.storeCode
@@ -1327,7 +1941,7 @@ async function pull(flags, io) {
1327
1941
  if (sha256Hex(tgz) !== source.archive.sha256) throw new CliError("받은 압축 파일이 손상됐습니다. 다시 시도하십시오");
1328
1942
  const files = await unpackArchive(tgz);
1329
1943
  const collisions = caseCollisions(files.keys());
1330
- 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 편집으로 한쪽 이름을 바꾼 뒤 다시 받으십시오`);
1331
1945
  const diff = diffLocal(target, files);
1332
1946
  if (existing.length > 0) {
1333
1947
  const watched = diff.overwrite.filter((path) => path === "package.json" || path === "vite.config.ts");
@@ -1361,18 +1975,15 @@ async function pull(flags, io) {
1361
1975
  SAYREN_STORE_CODE: store.storeCode
1362
1976
  }, ["SAYREN_TOKEN"]);
1363
1977
  const addedIgnore = ensureGitignore(target);
1364
- const written = writeMcpJson(target, {
1365
- token: auth.token,
1366
- storeId: store.storeId
1367
- });
1978
+ const written = writeMcpJson(target, { storeId: store.storeId });
1368
1979
  io.out(`받음 → ${relativeOf(io, target)} (${files.size}개${removed ? `, 원격에 없는 ${removed}개 지움` : ""})`);
1369
- if (written === "tracked") io.out(`알림: ${TRACKED_MCP_JSON_NOTICE}`);
1370
- else io.out(`${MCP_FILE} 작성 — sayren 원격 MCP(${REMOTE_MCP_URL})와 토큰. AI 에이전트가 이 폴더에서 바로 연결합니다. 커밋하지 마십시오`);
1371
- if (written === "written" && auth.apiOrigin !== DEFAULT_API_ORIGIN) io.out(`알림: 원격 MCP는 운영 api(${DEFAULT_API_ORIGIN})에만 연결됩니다. ${auth.apiOrigin}의 토큰으로는 ${MCP_FILE}의 연결이 거부됩니다`);
1372
- io.out(`${ENV_FILE} 작성 — 스토어프론트 API 주소·스토어 코드(개발 서버용)`);
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 주소·상점 코드(개발 서버용)`);
1373
1984
  io.out(`${STATE_FILE} 작성 — 사이트 ${source.siteId}·받은 작업 트리${addedIgnore.length ? ` (.gitignore에 ${addedIgnore.join(" ")} 추가)` : ""}`);
1374
- if (source.editSessionOpen) io.out("알림: 콘솔·AI 편집 환경이 열려 있습니다. 배포 전에 그 편집이 끝났는지 확인하십시오");
1375
- 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 # 빌드 → 버전 → 발행`);
1376
1987
  return 0;
1377
1988
  }
1378
1989
  async function verify(flags, io) {
@@ -1388,7 +1999,8 @@ function printChecks(tree, checks, io) {
1388
1999
  io.out(`로컬 검사 — 파일 ${tree.files.length}개(${formatBytes(tree.totalBytes)}), 규칙 ${report$1.summary.passed}/${report$1.summary.rules} 통과`);
1389
2000
  for (const finding of report$1.findings) {
1390
2001
  const at = finding.line ? `${finding.file}:${finding.line}` : finding.file;
1391
- io.out(` · 위반 [${finding.ruleId}] ${at} — ${finding.fix}`);
2002
+ const label = finding.severity === "warning" ? "권고" : "위반";
2003
+ io.out(` · ${label} [${finding.ruleId}] ${at} — ${finding.fix}`);
1392
2004
  }
1393
2005
  for (const problem of checks.problems) io.out(` · 문제 ${problem}`);
1394
2006
  for (const warning of checks.warnings) io.out(` · 경고 ${warning}`);
@@ -1404,24 +2016,22 @@ async function deploy(flags, io) {
1404
2016
  if (!rc) {
1405
2017
  let storeId = null;
1406
2018
  try {
1407
- storeId = readMcpCredentials(dir).storeId;
2019
+ storeId = readMcpStoreId(dir);
1408
2020
  } catch {
1409
2021
  storeId = null;
1410
2022
  }
1411
2023
  throw new CliError(`${STATE_FILE}가 없습니다. 이 사이트 소스를 clone한 폴더면 npx sayren pull --force --project ${storeId ?? "<storeId>"} --dir ${relativeOf(io, dir)}로 이 폴더를 원격 작업 트리에 맞춘 뒤(로컬 변경은 먼저 커밋·백업하십시오) 다시 배포하십시오. 처음이면 npx sayren pull로 받으십시오`);
1412
2024
  }
1413
- const auth = resolveAuth(flags, dir, rc, io.env);
1414
- await checkAuthSources(auth, io);
1415
- const client = clientOf(auth, io.fetch);
2025
+ const { client } = await apiFor(flags, dir, rc, io);
1416
2026
  const tree = collectTree(dir);
1417
2027
  const checks = runLocalChecks(tree);
1418
2028
  printChecks(tree, checks, io);
1419
2029
  if (checks.blockingRules.length > 0 || checks.problems.length > 0) throw new CliError("로컬 검사를 통과하지 못해 배포하지 않았습니다. 위 항목을 고친 뒤 다시 시도하십시오");
1420
2030
  const site = (await client.storefront.get()).site;
1421
- 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로 다시 받으십시오`);
1422
2032
  if (flags.force && !flags.yes) {
1423
2033
  if (!io.confirm) throw new CliError("--force는 원격 작업 트리를 덮어씁니다. 확인할 수 없는 환경이면 --yes를 함께 주십시오");
1424
- if (!await io.confirm("원격 작업 트리(콘솔·AI 편집 포함)를 이 폴더 내용으로 덮어쓰고, 열린 편집 환경을 끕니다. 계속하시겠습니까?")) {
2034
+ if (!await io.confirm("원격 작업 트리(상점 플랫폼·AI 편집 포함)를 이 폴더 내용으로 덮어쓰고, 열린 편집 환경을 끕니다. 계속하시겠습니까?")) {
1425
2035
  io.out("취소했습니다");
1426
2036
  return 1;
1427
2037
  }
@@ -1433,7 +2043,7 @@ async function deploy(flags, io) {
1433
2043
  if (error instanceof ApprovalPendingError) {
1434
2044
  io.out("작업 트리 바꾸기는 승인이 필요합니다(에이전트 토큰이 package.json·vite.config.ts를 바꿉니다). 승인된 뒤 npx sayren deploy를 다시 돌리면 빌드·발행합니다");
1435
2045
  if (error.approval.approvalUrl) io.out(`승인 요청: ${error.approval.approvalUrl}`);
1436
- else io.out(`승인 요청 id: ${error.approvalId} (셀러 콘솔 › 설정 › 보안 › 승인 요청)`);
2046
+ else io.out(`승인 요청 id: ${error.approvalId} (상점 플랫폼 › 설정 › 보안 › 승인 요청)`);
1437
2047
  return EXIT_APPROVAL_PENDING;
1438
2048
  }
1439
2049
  throw error;
@@ -1451,7 +2061,7 @@ async function deploy(flags, io) {
1451
2061
  });
1452
2062
  } catch (error) {
1453
2063
  const code = codeOf(error);
1454
- 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로 확인한 뒤 다시 배포하십시오");
1455
2065
  if (code === "SOURCE_CHANGED") throw new CliError("올린 직후 다른 편집이 끼어 버전을 만들지 않았습니다. npx sayren pull --force로 받아 합친 뒤 다시 배포하십시오");
1456
2066
  throw error;
1457
2067
  }
@@ -1463,7 +2073,7 @@ async function deploy(flags, io) {
1463
2073
  }
1464
2074
  io.out(`빌드 성공 — v${created.version.number}${build.durationMs ? ` (${Math.round(build.durationMs / 1e3)}초)` : ""}`);
1465
2075
  if (flags.publish === false) {
1466
- io.out(`발행하지 않았습니다(--no-publish). 콘솔 › 스토어프론트 › 버전에서 v${created.version.number}를 발행할 수 있습니다`);
2076
+ io.out(`발행하지 않았습니다(--no-publish). 상점 플랫폼 › 스토어프론트 › 버전에서 v${created.version.number}를 발행할 수 있습니다`);
1467
2077
  return 0;
1468
2078
  }
1469
2079
  try {
@@ -1475,10 +2085,10 @@ async function deploy(flags, io) {
1475
2085
  if (error instanceof ApprovalPendingError) {
1476
2086
  io.out(`발행은 승인이 필요합니다(에이전트 토큰). 셀러가 승인하면 v${created.version.number}가 발행됩니다`);
1477
2087
  if (error.approval.approvalUrl) io.out(`승인 요청: ${error.approval.approvalUrl}`);
1478
- else io.out(`승인 요청 id: ${error.approvalId} (셀러 콘솔 › 설정 › 보안 › 승인 요청)`);
2088
+ else io.out(`승인 요청 id: ${error.approvalId} (상점 플랫폼 › 설정 › 보안 › 승인 요청)`);
1479
2089
  return EXIT_APPROVAL_PENDING;
1480
2090
  }
1481
- 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}는 만들어 뒀습니다 — 상점 플랫폼에서 확인한 뒤 발행하십시오`);
1482
2092
  throw error;
1483
2093
  }
1484
2094
  }
@@ -1529,9 +2139,9 @@ async function replaceSource(client, tree, rc, force, io) {
1529
2139
  dependencyWarnings: []
1530
2140
  };
1531
2141
  }
1532
- 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");
1533
2143
  }
1534
- if (code === "EDIT_SESSION_OPEN") throw new CliError("콘솔·AI 편집 환경이 열려 있어 올리지 않았습니다. 편집을 끝낸 뒤 다시 시도하거나 --force로 편집 환경을 끄고 진행하십시오");
2144
+ if (code === "EDIT_SESSION_OPEN") throw new CliError("상점 플랫폼·AI 편집 환경이 열려 있어 올리지 않았습니다. 편집을 끝낸 뒤 다시 시도하거나 --force로 편집 환경을 끄고 진행하십시오");
1535
2145
  if (code === "DEPENDENCY_REJECTED") throw new CliError(`쓸 수 없는 npm 의존성이 있어 올리지 않았습니다.\n${(Array.isArray(details.rejected) ? details.rejected : []).map((item) => ` · ${findingLine(item)}`).join("\n")}`);
1536
2146
  if (code === "DEPENDENCY_CHECK_UNAVAILABLE") throw new CliError("의존성 확인을 지금 할 수 없습니다. 잠시 뒤 다시 시도하십시오");
1537
2147
  if (code === "SOURCE_TOO_MANY_NEW_FILES") throw new CliError(`한 번에 올릴 수 있는 새 파일은 ${String(details.max ?? 800)}개까지입니다. 파일을 나눠 여러 번 배포하십시오(--no-publish로 중간 배포)`);
@@ -1610,9 +2220,7 @@ async function status(flags, io) {
1610
2220
  const dir = resolve(io.cwd, flags.dir ?? ".");
1611
2221
  migrateLegacy(dir, io.out);
1612
2222
  const rc = readState(dir);
1613
- const auth = resolveAuth(flags, dir, rc, io.env);
1614
- await checkAuthSources(auth, io);
1615
- const client = clientOf(auth, io.fetch);
2223
+ const { client } = await apiFor(flags, dir, rc, io);
1616
2224
  const hosting = await client.storefront.get();
1617
2225
  const site = hosting.site;
1618
2226
  if (!site) {
@@ -1632,48 +2240,116 @@ async function status(flags, io) {
1632
2240
  else io.out(` 이 폴더의 기준 작업 트리 ${rc.baseRef.slice(rc.baseRef.lastIndexOf("/") + 1, -4).slice(0, 12)}`);
1633
2241
  return 0;
1634
2242
  }
2243
+ /**
2244
+ * `sayren login`(#71) — 기본은 Sayren 계정 로그인이다(브라우저 루프백 + PKCE, 안 되면 디바이스 코드). 로그인 토큰은 사용자 설정
2245
+ * 폴더의 `credentials.json`에 둔다. `--token`·`--paste`로 계정 액세스 토큰(PAT)을 붙여 넣을 수 있다. 옛 API 토큰(`sy_pat_`)은
2246
+ * 폐기되어(R1) 거절한다.
2247
+ */
1635
2248
  async function login(flags, io, readToken) {
2249
+ const pasted = flags.token?.trim() || (flags.paste ? (await readToken())?.trim() : void 0);
2250
+ if (flags.paste && !pasted) throw new CliError("토큰을 입력하지 않았습니다");
2251
+ if (pasted) {
2252
+ assertAccountToken(pasted);
2253
+ return pasteAccountToken(flags, pasted, io);
2254
+ }
2255
+ return accountLogin(flags, io);
2256
+ }
2257
+ /** 브라우저(루프백) 또는 디바이스 코드로 로그인한다 */
2258
+ async function accountLogin(flags, io) {
1636
2259
  const dir = resolve(io.cwd, flags.dir ?? ".");
1637
- const token = flags.token?.trim() || (await readToken())?.trim();
1638
- if (!token) throw new CliError("토큰을 입력하지 않았습니다");
1639
- migrateLegacy(dir, io.out);
1640
- const state = readState(dir);
1641
- const client = clientOf(resolveAuth({
1642
- ...flags,
1643
- token
1644
- }, dir, state, io.env), io.fetch);
1645
- const hosting = await client.storefront.get();
1646
- const expected = existsSync(dir) ? state?.storeId ?? readMcpCredentials(dir).storeId : null;
1647
- const tokenStore = await tokenStoreOf(client, hosting, expected !== null);
1648
- if (expected && tokenStore && tokenStore !== expected) throw new CliError(`토큰의 스토어(${tokenStore})가 이 폴더의 스토어(${expected})와 다릅니다. ${expected}에서 발급한 토큰을 쓰십시오(저장하지 않았습니다)`);
1649
- if (expected && !tokenStore) io.err(`경고: 토큰의 스토어를 확인하지 못했습니다. ${MCP_FILE}의 X-Store-Id(${expected})와 다르면 원격 MCP가 연결을 거부합니다`);
1650
- mkdirSync(dir, { recursive: true });
1651
- if (writeMcpJson(dir, {
1652
- token,
1653
- storeId: expected ?? tokenStore
1654
- }) === "tracked") {
1655
- io.err(`알림: ${TRACKED_MCP_JSON_NOTICE}`);
1656
- return 1;
2260
+ const state = existsSync(dir) ? readState(dir) : null;
2261
+ const { apiOrigin } = resolveApiOrigin(flags, dir, state, io.env);
2262
+ const input = {
2263
+ apiOrigin,
2264
+ accountsOrigin: resolveAccountsOrigin(apiOrigin, flags.accountsOrigin, io.env)
2265
+ };
2266
+ let entry = null;
2267
+ if (!flags.device && !headlessEnvironment(io.env)) entry = await browserLogin(io, input);
2268
+ if (!entry) {
2269
+ if (!flags.device) io.out("브라우저를 열 수 없어 기기 코드로 로그인합니다");
2270
+ entry = await deviceLogin(io, input);
1657
2271
  }
1658
- const addedIgnore = isPulledDir(dir) ? ensureGitignore(dir) : [];
1659
- io.out(`토큰을 확인하고(사이트 ${hosting.site?.hostname ?? "없음"}) ${relativeOf(io, join(dir, MCP_FILE))}에 저장했습니다(sayren 원격 MCP 설정)${addedIgnore.length ? ` (.gitignore에 ${addedIgnore.join(" ")} 추가)` : ""}`);
1660
- if (!isPulledDir(dir) && isGitIgnored(dir, MCP_FILE) === false) io.err(`경고: git이 ${MCP_FILE}를 무시하지 않습니다. 토큰이 들어 있으니 .gitignore에 ${MCP_FILE}를 더하십시오`);
2272
+ const path = await withCredentialLock(io.env, io.sleep, async () => saveCredential(io.env, apiOrigin, entry));
2273
+ io.out(`로그인했습니다${entry.email ? `: ${entry.email}` : ""} (${path})`);
2274
+ await reportStores(apiOrigin, entry.accessToken, io);
2275
+ await writeAccountMcpJson(dir, state, io);
1661
2276
  return 0;
1662
2277
  }
1663
- /**
1664
- * 토큰의 스토어 — `GET /v1/store`(스코프 `store:r`)가 먼저다. 없으면(storefront 스코프만 있는 토큰) `need`일 때만 작업 트리 조회에서 읽는다
1665
- * (그 조회는 지금 트리를 스냅샷으로 고정하므로 대조할 값이 있을 때만 부른다). 모르면 null
1666
- */
1667
- async function tokenStoreOf(client, hosting, need) {
1668
- try {
1669
- return (await client.store.get()).storeId;
1670
- } catch {}
1671
- if (!need || !hosting.site || !hosting.editingEnabled) return null;
2278
+ /** 붙여 넣은 계정 액세스 토큰(PAT)을 자격 증명 파일에 둔다 — 갱신이 없고 만료까지 쓴다 */
2279
+ async function pasteAccountToken(flags, token, io) {
2280
+ const dir = resolve(io.cwd, flags.dir ?? ".");
2281
+ const state = existsSync(dir) ? readState(dir) : null;
2282
+ const { apiOrigin } = resolveApiOrigin(flags, dir, state, io.env);
2283
+ const claims = accountTokenClaimsOf(token);
2284
+ const now = io.now?.() ?? Date.now();
2285
+ if (typeof claims?.exp !== "number" || claims.exp * 1e3 <= now) throw new CliError("만료된 토큰입니다. Sayren 계정 › 액세스 토큰에서 새로 만드십시오");
2286
+ const accountsOrigin = typeof claims.iss === "string" ? claims.iss : resolveAccountsOrigin(apiOrigin, flags.accountsOrigin, io.env);
2287
+ const entry = {
2288
+ accessToken: token,
2289
+ expiresAt: claims.exp * 1e3,
2290
+ ...claims.scope ? { scope: claims.scope } : {},
2291
+ accountsOrigin,
2292
+ kind: claims.sayren_kind === "oauth" ? "oauth" : "pat"
2293
+ };
2294
+ const path = await withCredentialLock(io.env, io.sleep, async () => saveCredential(io.env, apiOrigin, entry));
2295
+ io.out(`액세스 토큰${claims.sayren_token_name ? `(${claims.sayren_token_name})` : ""}을 저장했습니다 (${path}, 만료 ${(/* @__PURE__ */ new Date(claims.exp * 1e3)).toISOString().slice(0, 10)})`);
2296
+ await reportStores(apiOrigin, token, io);
2297
+ await writeAccountMcpJson(dir, state, io);
2298
+ return 0;
2299
+ }
2300
+ /** 쓸 수 있는 상점 수 — api가 계정 토큰을 아직 받지 않거나 `stores:read`가 없으면 알리기만 한다 */
2301
+ async function reportStores(apiOrigin, token, io) {
2302
+ const auth = {
2303
+ token,
2304
+ apiOrigin,
2305
+ tokenSource: "credentials",
2306
+ originSource: "default"
2307
+ };
1672
2308
  try {
1673
- return (await client.storefront.getSource()).storeId;
2309
+ const stores = await clientOf(auth, io.fetch).auth.listMyStores();
2310
+ const claims = accountTokenClaimsOf(token);
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}개`);
1674
2313
  } catch {
1675
- return null;
2314
+ io.err("알림: 상점 목록을 불러오지 못했습니다(권한 stores:read 또는 api 연결을 확인하십시오)");
2315
+ }
2316
+ }
2317
+ /** 받은 폴더면 `.mcp.json`을 토큰 없는 항목(주소·상점)으로 맞춘다. 폐기된 옛 토큰 헤더는 지우고 알린다 */
2318
+ async function writeAccountMcpJson(dir, state, io) {
2319
+ if (!state || !isPulledDir(dir)) return;
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}`);
2322
+ }
2323
+ /** `sayren logout` — refresh를 폐기하고(실패는 알리기만) 자격 증명 파일의 항목을 지운다 */
2324
+ async function logout(flags, io) {
2325
+ const dir = resolve(io.cwd, flags.dir ?? ".");
2326
+ const { apiOrigin } = resolveApiOrigin(flags, dir, existsSync(dir) ? readState(dir) : null, io.env);
2327
+ const entry = readCredential(io.env, apiOrigin);
2328
+ if (!entry) {
2329
+ io.out("로그인한 계정이 없습니다");
2330
+ return 0;
1676
2331
  }
2332
+ const revoked = await revokeRefresh(io, entry);
2333
+ await withCredentialLock(io.env, io.sleep, async () => deleteCredential(io.env, apiOrigin));
2334
+ io.out(`로그아웃했습니다${entry.email ? `: ${entry.email}` : ""}`);
2335
+ if (!revoked) io.err("알림: 계정 서버에서 로그인을 폐기하지 못했습니다(토큰은 이 컴퓨터에서 지웠습니다)");
2336
+ return 0;
2337
+ }
2338
+ /** `sayren whoami` — 쓰는 토큰의 종류·계정·상점 범위 */
2339
+ async function whoami(flags, io) {
2340
+ const dir = resolve(io.cwd, flags.dir ?? ".");
2341
+ const auth = resolveAuth(flags, dir, existsSync(dir) ? readState(dir) : null, io.env, credentialTokenReader(io.env));
2342
+ const refresh = await readyAuth(auth, io);
2343
+ const client = clientOf(auth, io.fetch, refresh);
2344
+ const claims = accountTokenClaimsOf(auth.token);
2345
+ const entry = auth.tokenSource === "credentials" ? readCredential(io.env, auth.apiOrigin) : null;
2346
+ const email = (await client.auth.me().catch(() => null))?.email ?? entry?.email ?? "확인 불가";
2347
+ const kind = claims?.sayren_kind === "pat" ? "액세스 토큰" : "로그인";
2348
+ const scope = claims?.sayren_stores === "*" ? "모든 상점" : Array.isArray(claims?.sayren_stores) ? claims.sayren_stores.join(", ") || "없음" : "확인 불가";
2349
+ io.out(`${email} — ${kind}(${auth.tokenSource === "credentials" ? credentialsPathOf(io.env) : auth.tokenSource})`);
2350
+ io.out(` 상점 범위 ${scope}`);
2351
+ if (claims?.scope) io.out(` 권한 ${claims.scope}`);
2352
+ return 0;
1677
2353
  }
1678
2354
  function relativeOf(io, path) {
1679
2355
  return relative(io.cwd, path) || ".";
@@ -1684,31 +2360,42 @@ function relativeOf(io, path) {
1684
2360
  const USAGE = `sayren ${CLI_VERSION} — 호스팅 스토어프론트 CLI
1685
2361
 
1686
2362
  사용법:
1687
- npx sayren login # 토큰을 입력받아 .mcp.json에 저장(셸 기록에 남지 않습니다)
2363
+ npx sayren login [--device] # Sayren 계정으로 로그인(브라우저, 안 되면 기기 코드)
2364
+ npx sayren login --paste # 액세스 토큰을 입력받아 저장(셸 기록에 남지 않습니다)
2365
+ npx sayren logout
2366
+ npx sayren whoami
1688
2367
  npx sayren pull --project <storeId> [폴더] [--force [--yes]]
1689
2368
  npx sayren deploy [--no-publish] [--message <메모>] [--force [--yes]]
1690
2369
  npx sayren status
1691
2370
  npx sayren verify
1692
2371
 
1693
2372
  명령:
1694
- login 토큰을 확인하고 지금 폴더의 .mcp.json(sayren 원격 MCP 설정)에 저장합니다
1695
- pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .mcp.json·.env·.sayren/state.json을 씁니다
2373
+ login Sayren 계정으로 로그인합니다. 로그인 토큰은 사용자 설정 폴더(~/.config/sayren/credentials.json)에 둡니다
2374
+ logout 로그인을 폐기하고 이 컴퓨터의 토큰을 지웁니다
2375
+ whoami 쓰는 계정·토큰 종류·상점 범위
2376
+ pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 상점 코드)로 받고 .mcp.json·.env·.sayren/state.json을 씁니다
1696
2377
  deploy 로컬 검사 → 작업 트리 올리기 → 빌드 → 발행. 원격이 pull 이후 바뀌었으면 덮어쓰지 않습니다
1697
2378
  status 사이트·발행 버전·최근 빌드·편집 환경
1698
2379
  verify 로컬 규칙·경로·크기 검사(서버 빌드와 같은 규칙)
1699
2380
 
1700
- 토큰은 --token → SAYREN_TOKEN 환경 변수 → .mcp.json 순서로 읽습니다(API 토큰 sy_pat_…, 스코프 storefront:rw).
2381
+ 토큰은 Sayren 계정 토큰만 받고 --token → SAYREN_TOKEN 환경 변수 → 로그인(credentials.json) 순서로 읽습니다.
2382
+ 계정 토큰은 계정에 발급됩니다 — 상점은 --store → SAYREN_STORE_ID → 폴더 설정 순서로 고릅니다(하나면 자동).
2383
+ 옛 API 토큰(sy_pat_…)은 더 쓸 수 없습니다. .mcp.json에는 토큰을 쓰지 않습니다(MCP 클라이언트가 스스로 로그인합니다).
1701
2384
  .mcp.json·.env·.sayren/은 .gitignore에 더하고 배포에 올리지 않습니다.
1702
2385
 
1703
2386
  옵션:
1704
- --project <storeId> pull: 토큰의 스토어와 같아야 합니다
1705
- --dir <폴더> 작업 폴더(기본: 지금 폴더, pull은 스토어 코드)
2387
+ --project <storeId> pull: 받을 상점(--store와 같습니다)
2388
+ --dir <폴더> 작업 폴더(기본: 지금 폴더, pull은 상점 코드)
1706
2389
  --force pull: 같은 사이트를 받은 폴더에 원격을 다시 받습니다(지울·덮을 파일을 보여 주고 묻습니다)
1707
2390
  deploy: 원격 변경·열린 편집 환경을 무시하고 덮어씁니다(확인을 묻습니다)
1708
2391
  --yes --force의 확인을 묻지 않습니다(비대화형)
1709
2392
  --no-publish deploy: 버전까지만 만들고 발행하지 않습니다
1710
2393
  --message <메모> deploy: 버전 메모
2394
+ --store <storeId> 다룰 상점(SAYREN_STORE_ID)
2395
+ --device login: 브라우저 대신 기기 코드로 로그인합니다(SSH 등)
2396
+ --paste login: 액세스 토큰을 입력받습니다
1711
2397
  --api-origin <주소> api 주소(기본 https://api.sayren.app, SAYREN_API_ORIGIN). https만, localhost만 http
2398
+ --accounts-origin <주소> 계정 서버 주소(기본: api 주소에 맞춘 값, SAYREN_ACCOUNTS_ORIGIN)
1712
2399
  --token <토큰> 토큰을 직접 줍니다. 셸 기록·프로세스 목록에 남으니 login·SAYREN_TOKEN을 권합니다
1713
2400
 
1714
2401
  종료 코드: 0 성공 · 1 실패 · 3 승인 대기(에이전트 토큰)`;
@@ -1717,14 +2404,18 @@ const VALUE_FLAGS = new Set([
1717
2404
  "project",
1718
2405
  "dir",
1719
2406
  "message",
1720
- "api-origin"
2407
+ "api-origin",
2408
+ "store",
2409
+ "accounts-origin"
1721
2410
  ]);
1722
2411
  const BOOL_FLAGS = new Set([
1723
2412
  "force",
1724
2413
  "yes",
1725
2414
  "no-publish",
1726
2415
  "help",
1727
- "version"
2416
+ "version",
2417
+ "device",
2418
+ "paste"
1728
2419
  ]);
1729
2420
  function parseArgs(argv) {
1730
2421
  const out = {
@@ -1768,7 +2459,14 @@ async function main(argv, io) {
1768
2459
  const common = {
1769
2460
  token: str(parsed.flags.token),
1770
2461
  apiOrigin: str(parsed.flags["api-origin"]),
1771
- dir: str(parsed.flags.dir) ?? parsed.positional[0]
2462
+ dir: str(parsed.flags.dir) ?? parsed.positional[0],
2463
+ store: str(parsed.flags.store)
2464
+ };
2465
+ const loginFlags = {
2466
+ ...common,
2467
+ device: parsed.flags.device === true,
2468
+ paste: parsed.flags.paste === true,
2469
+ accountsOrigin: str(parsed.flags["accounts-origin"])
1772
2470
  };
1773
2471
  switch (parsed.command) {
1774
2472
  case "pull": return pull({
@@ -1786,7 +2484,9 @@ async function main(argv, io) {
1786
2484
  }, io);
1787
2485
  case "status": return status(common, io);
1788
2486
  case "verify": return verify(common, io);
1789
- case "login": return login(common, io, () => ask("API 토큰(sy_pat_…): "));
2487
+ case "login": return login(loginFlags, io, () => ask("액세스 토큰: "));
2488
+ case "logout": return logout(loginFlags, io);
2489
+ case "whoami": return whoami(common, io);
1790
2490
  default: throw new CliError(`알 수 없는 명령: ${parsed.command}\n\n${USAGE}`);
1791
2491
  }
1792
2492
  }
@@ -1812,14 +2512,49 @@ function defaultIo() {
1812
2512
  confirm: stdin.isTTY ? async (question) => /^(y|yes|예|네)$/i.test((await ask(`${question} (y/N) `) ?? "").trim()) : null,
1813
2513
  sleep: (ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)),
1814
2514
  buildTimeoutMs: 20 * 6e4,
1815
- pollMs: 3e3
2515
+ pollMs: 3e3,
2516
+ openBrowser,
2517
+ select: stdin.isTTY ? async (question, options) => {
2518
+ console.log(question);
2519
+ options.forEach((option, index) => {
2520
+ console.log(` ${index + 1}. ${option}`);
2521
+ });
2522
+ const answer = Number((await ask("번호: ") ?? "").trim());
2523
+ return Number.isInteger(answer) ? options[answer - 1] ?? null : null;
2524
+ } : null
1816
2525
  };
1817
2526
  }
2527
+ /** 운영체제 기본 브라우저로 연다. 열기 명령을 못 띄우면 false(로그인은 기기 코드로 넘어간다) */
2528
+ function openBrowser(url) {
2529
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
2530
+ "/c",
2531
+ "start",
2532
+ "",
2533
+ url
2534
+ ]] : ["xdg-open", [url]];
2535
+ return new Promise((resolve$1) => {
2536
+ try {
2537
+ const child = spawn(command, args, {
2538
+ stdio: "ignore",
2539
+ detached: true
2540
+ });
2541
+ child.once("error", () => resolve$1(false));
2542
+ child.once("spawn", () => {
2543
+ child.unref();
2544
+ resolve$1(true);
2545
+ });
2546
+ } catch {
2547
+ resolve$1(false);
2548
+ }
2549
+ });
2550
+ }
1818
2551
  /** 오류 표시 — 토큰·요청 본문은 싣지 않는다 */
1819
2552
  function describeError(error) {
1820
2553
  if (error instanceof CliError) return error.message;
1821
2554
  if (error instanceof ApiError) {
1822
- if (error.status === 401) return "토큰이 올바르지 않거나 만료됐습니다(401). 셀러 콘솔 › 개발자 › API 토큰을 확인하십시오";
2555
+ if (error.status === 401 && error.code === "TOKEN_TYPE_RETIRED") return RETIRED_TOKEN_MESSAGE;
2556
+ if (error.status === 401) return "토큰이 올바르지 않거나 만료됐습니다(401). npx sayren login으로 다시 로그인하거나 새 액세스 토큰을 쓰십시오";
2557
+ if (error.status === 403 && error.code === "STORE_NOT_GRANTED") return "이 토큰의 상점 범위 밖입니다. 그 상점을 포함해 다시 로그인하십시오(npx sayren login)";
1823
2558
  if (error.status === 403 && error.code === "INSUFFICIENT_SCOPE") return "토큰에 storefront:rw 스코프가 없습니다. 스코프를 더한 토큰을 쓰십시오";
1824
2559
  if (error.code === "TOO_MANY_REQUESTS") return "요청이 너무 많습니다. 잠시 뒤 다시 시도하십시오";
1825
2560
  return `${error.message} (${error.code}, HTTP ${error.status})`;