sayren 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +1288 -221
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,27 +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 { createHash } from "node:crypto";
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { createServer } from "node:http";
8
10
 
9
11
  //#region package.json
10
- var version = "0.1.0";
12
+ var version = "0.3.0";
11
13
 
12
14
  //#endregion
13
15
  //#region src/client.ts
14
16
  /**
15
17
  * 관리 API 클라이언트 — `@sayren/store-sdk`(계약의 원천)를 쓴다. User-Agent `sayren-cli/{버전}`은 api가 버전의 만든 경로
16
- * (`createdVia: CLI`)를 적는 표시다(권한과 무관하다. 에이전트 PAT면 행위자는 여전히 에이전트이고 발행은 승인 대기다).
18
+ * (`createdVia: CLI`)를 적는 표시다(권한과 무관하다. 에이전트용 액세스 토큰이면 행위자는 여전히 에이전트이고 발행은 승인 대기다).
17
19
  * 토큰은 헤더에만 싣고 화면·로그에 찍지 않는다.
18
20
  */
19
21
  const CLI_VERSION = version;
20
22
  const CLI_USER_AGENT = `sayren-cli/${CLI_VERSION}`;
21
- 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) {
22
28
  return createStoreClient({
23
29
  baseUrl: `${auth.apiOrigin}/v1`,
24
- auth: { apiToken: auth.token },
30
+ accessToken: () => auth.token,
31
+ storeId: () => auth.storeId ?? null,
32
+ ...refresh ? { refresh } : {},
25
33
  fetch: (input, init) => {
26
34
  const headers = new Headers(init?.headers);
27
35
  headers.set("user-agent", CLI_USER_AGENT);
@@ -40,8 +48,981 @@ function detailsOf(error) {
40
48
  return error instanceof ApiError ? error.details ?? {} : {};
41
49
  }
42
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
+
148
+ //#endregion
149
+ //#region ../mcp/src/mcp-json.ts
150
+ /**
151
+ * 셀러 프로젝트의 `.mcp.json`(Claude Code 형식) — 원격 MCP(`mcp.sayren.app`)에 붙는 `sayren` 항목을 만들고 읽는다.
152
+ *
153
+ * `npx sayren pull`·`login`(packages/cli)과 `sayren-mcp create`가 같은 함수로 쓴다. 파일 입출력은 부르는 쪽이 하고 여기는 문자열만
154
+ * 다룬다 — 게이트웨이(Worker)가 헤더 이름을 이 파일에서 가져가므로 Node 전용 API를 쓰지 않는다.
155
+ *
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`·스토어 헤더만 바꾼다.
161
+ */
162
+ const MCP_JSON_FILE = ".mcp.json";
163
+ const MCP_SERVER_NAME = "sayren";
164
+ /** 원격 MCP 주소. 게이트웨이는 운영 api 하나만 바라본다 — 다른 api 주소를 쓰는 폴더도 이 주소를 쓴다 */
165
+ const REMOTE_MCP_URL = "https://mcp.sayren.app/mcp";
166
+ /** 관리 API의 테넌트 헤더와 같은 이름이다(게이트웨이·api가 토큰의 스토어 범위와 대조한다) */
167
+ const STORE_ID_HEADER = "X-Store-Id";
168
+ var McpJsonError = class extends Error {
169
+ constructor(message) {
170
+ super(message);
171
+ this.name = "McpJsonError";
172
+ }
173
+ };
174
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
175
+ function parse(text) {
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(text);
179
+ } catch {
180
+ throw new McpJsonError(`${MCP_JSON_FILE}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
181
+ }
182
+ if (!isObject(parsed)) throw new McpJsonError(`${MCP_JSON_FILE} 형식이 아닙니다`);
183
+ if (parsed.mcpServers !== void 0 && !isObject(parsed.mcpServers)) throw new McpJsonError(`${MCP_JSON_FILE}의 mcpServers가 객체가 아닙니다`);
184
+ return parsed;
185
+ }
186
+ /** 대소문자를 가리지 않고 헤더 키를 찾는다 */
187
+ function headerKey(headers, name) {
188
+ const lower = name.toLowerCase();
189
+ return Object.keys(headers).find((key) => key.toLowerCase() === lower);
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
+ }
195
+ /**
196
+ * `sayren` 항목을 넣거나 바꾼 `.mcp.json` 본문. `existing`이 없으면 새 파일이다. 폐기된 옛 토큰 헤더는 지운다(`removedRetiredToken`).
197
+ * 형식이 틀린 파일은 덮어쓰지 않고 `McpJsonError`다(셀러가 손으로 쓴 다른 서버 설정을 지우지 않게).
198
+ */
199
+ function mergeMcpJson(existing, entry) {
200
+ const root = existing?.trim() ? parse(existing) : {};
201
+ const servers = isObject(root.mcpServers) ? { ...root.mcpServers } : {};
202
+ const previous = isObject(servers[MCP_SERVER_NAME]) ? servers[MCP_SERVER_NAME] : {};
203
+ const headers = isObject(previous.headers) ? { ...previous.headers } : {};
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];
209
+ const next = {
210
+ ...previous,
211
+ type: "http",
212
+ url: REMOTE_MCP_URL,
213
+ headers: {
214
+ ...entry.storeId ? { [STORE_ID_HEADER]: entry.storeId } : {},
215
+ ...headers
216
+ }
217
+ };
218
+ delete next.command;
219
+ delete next.args;
220
+ delete next.env;
221
+ servers[MCP_SERVER_NAME] = next;
222
+ return {
223
+ text: `${JSON.stringify({
224
+ ...root,
225
+ mcpServers: servers
226
+ }, null, 2)}\n`,
227
+ removedRetiredToken
228
+ };
229
+ }
230
+ /** `sayren` 항목의 스토어(`X-Store-Id`). 항목·헤더가 없으면 null이다. 형식이 틀린 파일은 `McpJsonError` */
231
+ function sayrenStoreIdOf(text) {
232
+ const root = parse(text);
233
+ const entry = (isObject(root.mcpServers) ? root.mcpServers : {})[MCP_SERVER_NAME];
234
+ const headers = isObject(entry) && isObject(entry.headers) ? entry.headers : {};
235
+ const storeKey = headerKey(headers, STORE_ID_HEADER);
236
+ const store = storeKey ? headers[storeKey] : void 0;
237
+ return typeof store === "string" && store.trim() ? store.trim() : null;
238
+ }
239
+
240
+ //#endregion
241
+ //#region ../mcp/src/mcp-json-file.ts
242
+ /**
243
+ * `.mcp.json` 파일 쓰기(Node) — `sayren` CLI(packages/cli)와 `sayren-mcp create`가 같이 쓴다. 형식은 `mcp-json.ts`(Node 없음)다.
244
+ * 항목에 토큰을 쓰지 않는다(#71 R1). 심볼릭 링크는 따라가지 않는다(폴더 밖 파일을 덮지 않게).
245
+ */
246
+ function git(dir, args) {
247
+ if (!existsSync(dir)) return null;
248
+ const result = spawnSync("git", args, {
249
+ cwd: dir,
250
+ stdio: "ignore",
251
+ timeout: 1e4
252
+ });
253
+ if (result.error || result.status === null) return null;
254
+ return result.status;
255
+ }
256
+ /** git이 이 파일을 추적하는가. git이 없거나 저장소가 아니면 false */
257
+ function isGitTracked(dir, name) {
258
+ return git(dir, [
259
+ "ls-files",
260
+ "--error-unmatch",
261
+ "--",
262
+ name
263
+ ]) === 0;
264
+ }
265
+ /** 심볼릭 링크면 쓰지 않는다(링크를 따라 폴더 밖 파일을 덮지 않게). 권한은 600이다 */
266
+ function writePrivateFile(path, text) {
267
+ let link = false;
268
+ try {
269
+ link = lstatSync(path).isSymbolicLink();
270
+ } catch {
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;
950
+ }
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;
961
+ }
962
+ /**
963
+ * 토큰 준비 — 자격 증명 파일 토큰이면 만료 60초 전에 갱신한다. 401 재시도용 갱신 함수를 돌려준다(자격 증명 파일 토큰만).
964
+ */
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;
1000
+ try {
1001
+ stores = (await client.auth.listMyStores()).filter((store) => storeInScope(claims, store.storeId));
1002
+ } catch {
1003
+ throw new CliError("스토어를 고르지 못했습니다. --store <storeId> 또는 SAYREN_STORE_ID로 스토어를 주십시오");
1004
+ }
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 계정으로 로그인합니다`;
1019
+
43
1020
  //#endregion
44
1021
  //#region ../mcp/src/rules.ts
1022
+ /** 규칙의 심각도 — 없으면 `error` */
1023
+ function severityOf(rule) {
1024
+ return rule?.severity ?? "error";
1025
+ }
45
1026
  /** 문서 사이트 — `doc` 경로를 절대 주소로 만들 때 쓴다 */
46
1027
  const DOCS_BASE_URL = "https://docs.sayren.app";
47
1028
  const docUrl = (doc) => `${DOCS_BASE_URL}${doc}`;
@@ -225,6 +1206,16 @@ const RULES = [
225
1206
  example: "// 서버 함수 본문으로 보낸다\nawait addToCart({ data: { productId, optionId, quantity, addons, customInputs } });\n\n// 바로구매 — 값은 HttpOnly 쿠키, 주소에는 표시만\nwriteDirectLine(line); // 서버 함수 안\nnavigate({ to: \"/checkout\", search: { direct: 1, productId } });",
226
1207
  doc: "/guides/product-options",
227
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"
228
1219
  }
229
1220
  ];
230
1221
  /**
@@ -326,6 +1317,7 @@ function verifySources(files) {
326
1317
  if (!matches(/\b(auth|authFor\(\))\.signOut\(|\.signOut\(\s*accessToken\b/)) inProject("buyer-signout", "로그아웃에서 리프레시 토큰을 폐기하는 코드가 없다(`auth.signOut(accessToken)`)");
327
1318
  if (!has("reconsentRequired")) inProject("buyer-reconsent", "재동의가 필요한 구매자를 처리하는 코드가 없다(`reconsentRequired`)");
328
1319
  }
1320
+ if (has("startPayment(") && !has("checkoutAvailable")) inProject("checkout-availability", "결제를 시작하지만 `GET /store`의 `checkoutAvailable`을 읽는 코드가 없다. 주문을 받지 않는 스토어에서도 주문 버튼이 보인다");
329
1321
  if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) inProject("analytics-start", "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다");
330
1322
  const clientQuery = matches(/\buse(Suspense)?Query\(/);
331
1323
  if (clientQuery && !has("ensureQueryData(") && !has("prefetchQuery(")) inFile("loader-first", clientQuery, "TanStack Query로 데이터를 받지만 loader에서 `queryClient.ensureQueryData`로 미리 받는 코드가 없다. 서버 렌더에 데이터가 빠진다", /\buse(Suspense)?Query\(/);
@@ -349,7 +1341,8 @@ function report(finding) {
349
1341
  example: rule?.example ?? "",
350
1342
  doc: rule?.doc ?? "",
351
1343
  docUrl: rule ? docUrl(rule.doc) : "",
352
- ...rule?.templateFile ? { templateFile: rule.templateFile } : {}
1344
+ ...rule?.templateFile ? { templateFile: rule.templateFile } : {},
1345
+ severity: severityOf(rule)
353
1346
  };
354
1347
  }
355
1348
  /** 위반을 고치는 순서 — 에이전트가 무엇부터 할지 묻지 않게 한다 */
@@ -386,12 +1379,28 @@ function reportFromFindings(findings, checkedFiles, target = "local") {
386
1379
  const results = RULES.map((rule) => ({
387
1380
  ruleId: rule.id,
388
1381
  status: counts.has(rule.id) ? "violation" : "pass",
389
- count: counts.get(rule.id) ?? 0
1382
+ count: counts.get(rule.id) ?? 0,
1383
+ severity: severityOf(rule)
390
1384
  }));
391
1385
  const violated = results.filter((result) => result.status === "violation").length;
392
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
+ };
393
1402
  return {
394
- ok: findings.length === 0,
1403
+ ok: blocking.length === 0,
395
1404
  checkedFiles,
396
1405
  summary: {
397
1406
  rules: RULES.length,
@@ -407,7 +1416,7 @@ function reportFromFindings(findings, checkedFiles, target = "local") {
407
1416
  }
408
1417
 
409
1418
  //#endregion
410
- //#region ../hosting-sandbox/src/hash.ts
1419
+ //#region ../../apps/hosting-sandbox/src/hash.ts
411
1420
  const strictUtf8 = new TextDecoder("utf-8", {
412
1421
  fatal: true,
413
1422
  ignoreBOM: true
@@ -422,9 +1431,9 @@ function asUtf8(bytes) {
422
1431
  }
423
1432
 
424
1433
  //#endregion
425
- //#region ../hosting-sandbox/src/verify.ts
1434
+ //#region ../../apps/hosting-sandbox/src/verify.ts
426
1435
  /**
427
- * 규칙 검증. 원천은 `@sayren/mcp`의 규칙(apps/mcp/src/rules.ts)과 보고서(verify-report.ts)다 — stdio `verify_storefront`와
1436
+ * 규칙 검증. 원천은 `@sayren/mcp`의 규칙(packages/mcp/src/rules.ts)과 보고서(verify-report.ts)다 — stdio `verify_storefront`와
428
1437
  * 같은 함수를 Worker가 직접 돌린다(컨테이너가 필요 없다). 대상은 stdio와 같은 소스 확장자다.
429
1438
  */
430
1439
  const SOURCE_RE = /\.(ts|tsx|js|jsx)$/;
@@ -439,7 +1448,7 @@ function verifyTree(files) {
439
1448
  }
440
1449
 
441
1450
  //#endregion
442
- //#region ../hosting-sandbox/src/paths.ts
1451
+ //#region ../../apps/hosting-sandbox/src/paths.ts
443
1452
  const MAX_FILE_BYTES = 1024 * 1024;
444
1453
  const MAX_TREE_BYTES = 20 * 1024 * 1024;
445
1454
  const MAX_TREE_FILES = 2e3;
@@ -501,7 +1510,7 @@ function accessOf(path) {
501
1510
  }
502
1511
 
503
1512
  //#endregion
504
- //#region ../hosting-sandbox/src/tar.ts
1513
+ //#region ../../apps/hosting-sandbox/src/tar.ts
505
1514
  const BLOCK = 512;
506
1515
  const enc = new TextEncoder();
507
1516
  const dec = new TextDecoder();
@@ -602,152 +1611,21 @@ async function gunzip(bytes, maxBytes) {
602
1611
  }
603
1612
 
604
1613
  //#endregion
605
- //#region src/config.ts
1614
+ //#region src/tree.ts
606
1615
  /**
607
- * 설정 — 우선순위는 플래그 > 환경 변수(`SAYREN_TOKEN`·`SAYREN_API_ORIGIN`) > `.env` > `.sayrenrc`다.
608
- *
609
- * - `.sayrenrc`: pull이 만든다. 비밀이 없어 커밋해도 된다(`storeId`·`siteId`·`baseRef`, 기본이 아닌 api 주소).
610
- * **토큰을 두지 않는다** — 읽을 때 `token` 같은 필드가 있으면 무시하고 경고한다.
611
- * - `.env`: 토큰(`SAYREN_TOKEN`)과 스토어프론트 템플릿이 읽는 값(`SAYREN_API_URL`·`SAYREN_STORE_CODE`). `.gitignore` 대상이다.
612
- * 템플릿은 `SAYREN_TOKEN`만 서버 코드 환경에 싣지 않는다(vite.config.ts).
1616
+ * 로컬 작업 트리 — 서버 작업 트리와 **같은 경로 규칙**(편집 Sandbox `paths.ts`의 `accessOf`)으로 고른다. `src/**`·`public/**`·루트 설정
1617
+ * 파일·`package.json`·`pnpm-lock.yaml`만 올리고, `.env*`·`.mcp.json`·`.sayren/`·`node_modules`·`dist`·`package-lock.json` 등은 올리지 않는다.
1618
+ * 비밀·로컬 상태 파일(`isNeverUpload`)은 서버 경로 규칙과 별개로 CLI가 먼저 막는다 — 서버 규칙이 넓어져도 토큰을 올리지 않게.
613
1619
  */
614
- const RC_FILE = ".sayrenrc";
615
- const ENV_FILE = ".env";
616
- const DEFAULT_API_ORIGIN = "https://api.sayren.app";
617
- const RC_KEYS = [
618
- "storeId",
619
- "siteId",
620
- "baseRef",
621
- "apiOrigin"
622
- ];
623
- function readRc(dir) {
624
- const path = join(dir, RC_FILE);
625
- if (!existsSync(path)) return null;
626
- let parsed;
627
- try {
628
- parsed = JSON.parse(readFileSync(path, "utf8"));
629
- } catch {
630
- throw new CliError(`${RC_FILE}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
631
- }
632
- if (!parsed || typeof parsed !== "object") throw new CliError(`${RC_FILE} 형식이 아닙니다`);
633
- const rc = parsed;
634
- for (const key of Object.keys(rc)) if (!RC_KEYS.includes(key)) console.warn(`경고: ${RC_FILE}의 ${key}는 쓰지 않습니다(토큰은 .env에 두십시오)`);
635
- if (typeof rc.storeId !== "string" || typeof rc.siteId !== "string" || typeof rc.baseRef !== "string") throw new CliError(`${RC_FILE}에 storeId·siteId·baseRef가 없습니다. npx sayren pull로 다시 받으십시오`);
636
- return {
637
- storeId: rc.storeId,
638
- siteId: rc.siteId,
639
- baseRef: rc.baseRef,
640
- ...typeof rc.apiOrigin === "string" ? { apiOrigin: rc.apiOrigin } : {}
641
- };
642
- }
643
- function writeRc(dir, rc) {
644
- const out = {
645
- storeId: rc.storeId,
646
- siteId: rc.siteId,
647
- baseRef: rc.baseRef
648
- };
649
- if (rc.apiOrigin && rc.apiOrigin !== DEFAULT_API_ORIGIN) out.apiOrigin = rc.apiOrigin;
650
- writeFileSync(join(dir, RC_FILE), `${JSON.stringify(out, null, 2)}\n`);
651
- }
652
- /** `.env` 읽기 — `KEY=VALUE` 줄만(따옴표는 벗긴다). 없으면 빈 값 */
653
- function readEnvFile(dir) {
654
- const path = join(dir, ENV_FILE);
655
- const out = /* @__PURE__ */ new Map();
656
- if (!existsSync(path)) return out;
657
- for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
658
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
659
- if (!m?.[1]) continue;
660
- let value = m[2] ?? "";
661
- if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
662
- out.set(m[1], value);
663
- }
664
- return out;
665
- }
666
- /** `.env`에 값들을 쓴다 — 다른 줄은 그대로 두고 같은 키만 바꾼다. 파일 권한은 600이다(토큰) */
667
- function updateEnvFile(dir, values) {
668
- const path = join(dir, ENV_FILE);
669
- const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
670
- const pending = new Map(Object.entries(values));
671
- const next = lines.map((line) => {
672
- const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
673
- if (key && pending.has(key)) {
674
- const value = pending.get(key);
675
- pending.delete(key);
676
- return `${key}=${value}`;
677
- }
678
- return line;
679
- });
680
- while (next.length > 0 && next[next.length - 1] === "") next.pop();
681
- for (const [key, value] of pending) next.push(`${key}=${value}`);
682
- writeFileSync(path, `${next.join("\n")}\n`, { mode: 384 });
683
- try {
684
- chmodSync(path, 384);
685
- } catch {}
686
- }
687
- /** `.gitignore`에 `.env`가 없으면 더한다. 더했으면 true */
688
- function ensureGitignore(dir) {
689
- const path = join(dir, ".gitignore");
690
- const text = existsSync(path) ? readFileSync(path, "utf8") : "";
691
- const lines = text.split(/\r?\n/).map((line) => line.trim());
692
- if (lines.includes(".env") || lines.includes(".env*") || lines.includes("/.env")) return false;
693
- writeFileSync(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}.env\n`);
694
- return true;
695
- }
696
- /** 스토어프론트 API 주소(`…/storefront/v1`)에서 api origin을 읽는다 */
697
- function originOfStorefrontUrl(url) {
698
- if (!url) return null;
699
- return /^(https?:\/\/[^/]+)\/storefront\/v1\/?$/.exec(url.trim())?.[1] ?? null;
700
- }
701
- function resolveAuth(flags, dir, rc, env = process.env) {
702
- const dotenv = readEnvFile(dir);
703
- const fromFlag = flags.token?.trim();
704
- const fromEnv = env.SAYREN_TOKEN?.trim();
705
- const fromFile = dotenv.get("SAYREN_TOKEN")?.trim();
706
- const token = fromFlag || fromEnv || fromFile;
707
- if (!token) throw new CliError("토큰이 없습니다. --token으로 주거나 SAYREN_TOKEN 환경 변수·.env에 넣으십시오(셀러 콘솔 › 개발자 › API 토큰, 스코프 storefront:rw)");
708
- const picked = [
709
- ["flag", flags.apiOrigin?.trim()],
710
- ["env", env.SAYREN_API_ORIGIN?.trim()],
711
- [".env", originOfStorefrontUrl(dotenv.get("SAYREN_API_URL"))],
712
- ["rc", rc?.apiOrigin]
713
- ].find(([, value]) => value);
714
- const apiOrigin = (picked?.[1] ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
715
- assertOriginShape(apiOrigin);
716
- return {
717
- token,
718
- apiOrigin,
719
- tokenSource: fromFlag ? "flag" : fromEnv ? "env" : ".env",
720
- originSource: picked && apiOrigin !== DEFAULT_API_ORIGIN ? picked[0] : "default"
721
- };
722
- }
723
- /** 로컬 개발 주소 — http를 받는다 */
724
- const LOOPBACK = new Set([
725
- "localhost",
726
- "127.0.0.1",
727
- "[::1]"
728
- ]);
729
- /** api 주소 형식 — https만 받는다(토큰을 싣고 보낸다). localhost·127.0.0.1만 http 예외다 */
730
- function assertOriginShape(origin) {
731
- let url;
732
- try {
733
- url = new URL(origin);
734
- } catch {
735
- throw new CliError(`api 주소 형식이 아닙니다: ${origin}`);
736
- }
737
- const loopback = LOOPBACK.has(url.hostname);
738
- if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) throw new CliError(`api 주소는 https여야 합니다(localhost만 http 예외): ${origin}. 토큰이 이 주소로 갑니다`);
1620
+ /**
1621
+ * 어떤 경우에도 올리지 않는 파일 — `.env*`(개발 서버 값), `.mcp.json`(토큰), `.sayren/`(CLI 상태), 옛 `.sayrenrc`.
1622
+ * 폴더 깊이와 무관하게 이름으로 본다.
1623
+ */
1624
+ function isNeverUpload(path) {
1625
+ const segments = path.split("/");
1626
+ const name = segments[segments.length - 1] ?? "";
1627
+ return segments.some((segment) => segment.startsWith(".env") || segment === ".sayren") || name === ".mcp.json" || name === ".sayrenrc";
739
1628
  }
740
- /** 사용자에게 보여 줄 오류 — 스택 없이 문장만 찍고 종료 코드 1로 끝난다 */
741
- var CliError = class extends Error {
742
- constructor(message, exitCode = 1) {
743
- super(message);
744
- this.exitCode = exitCode;
745
- this.name = "CliError";
746
- }
747
- };
748
-
749
- //#endregion
750
- //#region src/tree.ts
751
1629
  /** 걸어 들어가지 않는 폴더 — 설치물·산출물·캐시·VCS(경로 규칙이 어차피 막는다. 크기 때문에 읽지도 않는다) */
752
1630
  const SKIP_DIRS = new Set([
753
1631
  "node_modules",
@@ -778,7 +1656,7 @@ function collectTree(root) {
778
1656
  skipped.push(rel);
779
1657
  continue;
780
1658
  }
781
- if (accessOf(rel) === null) {
1659
+ if (isNeverUpload(rel) || accessOf(rel) === null) {
782
1660
  skipped.push(rel);
783
1661
  continue;
784
1662
  }
@@ -888,7 +1766,7 @@ function treeFingerprint(files) {
888
1766
  function runLocalChecks(tree) {
889
1767
  const map = new Map(tree.files.map((file) => [file.path, file.bytes]));
890
1768
  const report$1 = verifyTree(map);
891
- const blockingRules = report$1.results.filter((result) => result.status === "violation");
1769
+ const blockingRules = report$1.results.filter((result) => result.status === "violation" && result.severity !== "warning");
892
1770
  const problems = [...limitProblems(tree)];
893
1771
  const warnings = [];
894
1772
  const text = (path) => {
@@ -980,43 +1858,75 @@ const MAX_BLOB_ROUNDS = 3;
980
1858
  /** 올리기 한 번의 내용 합계 상한 — base64(×4/3)가 api 본문 8MiB 안에 든다 */
981
1859
  const BLOB_BATCH_BYTES = 5 * 1024 * 1024;
982
1860
  /**
983
- * 토큰·api 주소 확인 — `--token`은 셸 기록·프로세스 목록에 남으니 알리고, 파일(`.env`·`.sayrenrc`)에서 읽은 기본이 아닌 api 주소는 토큰이
984
- * 그곳으로 가므로 확인한다(대화형이면 묻고, 아니면 `--api-origin`·`SAYREN_API_ORIGIN`으로 명시해야 한다)
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
+ /**
1881
+ * 토큰·api 주소 확인 — `--token`은 셸 기록·프로세스 목록에 남으니 알리고, 파일(`.env`·`.sayren/state.json`)에서 읽은 기본이 아닌 api
1882
+ * 주소는 토큰이 그곳으로 가므로 확인한다(대화형이면 묻고, 아니면 `--api-origin`·`SAYREN_API_ORIGIN`으로 명시해야 한다)
985
1883
  */
986
1884
  async function checkAuthSources(auth, io) {
987
1885
  if (auth.tokenSource === "flag") io.err("경고: --token은 셸 기록·프로세스 목록에 남습니다. 다음부터는 npx sayren login 또는 SAYREN_TOKEN 환경 변수를 쓰십시오");
988
- if (auth.originSource !== ".env" && auth.originSource !== "rc") return;
989
- const from = auth.originSource === ".env" ? ".env의 SAYREN_API_URL" : RC_FILE;
1886
+ if (auth.originSource !== ".env" && auth.originSource !== "state") return;
1887
+ const from = auth.originSource === ".env" ? ".env의 SAYREN_API_URL" : STATE_FILE;
990
1888
  io.err(`경고: 기본이 아닌 api 주소(${auth.apiOrigin})를 ${from}에서 읽었습니다. 토큰이 이 주소로 갑니다`);
991
1889
  if (!io.confirm) throw new CliError(`파일에서 읽은 api 주소는 확인이 필요합니다. 맞으면 --api-origin ${auth.apiOrigin}으로 명시하십시오`);
992
1890
  if (!await io.confirm(`${auth.apiOrigin}으로 보내겠습니까?`)) throw new CliError("취소했습니다");
993
1891
  }
994
1892
  async function pull(flags, io) {
995
1893
  const probeDir = flags.dir ? resolve(io.cwd, flags.dir) : io.cwd;
996
- const auth = resolveAuth(flags, existsSync(probeDir) ? probeDir : io.cwd, null, io.env);
997
- await checkAuthSources(auth, io);
998
- const client = clientOf(auth, io.fetch);
1894
+ const authDir = existsSync(probeDir) ? probeDir : io.cwd;
1895
+ migrateLegacy(authDir, io.out);
1896
+ const { auth, client } = await apiFor(flags, authDir, null, io, [flags.project]);
999
1897
  const hosting = await client.storefront.get();
1000
1898
  if (!hosting.site) throw new CliError("이 스토어에는 호스팅 사이트가 없습니다. 셀러 콘솔 › 스토어프론트에서 사이트를 만든 뒤 다시 받으십시오");
1001
1899
  if (!hosting.editingEnabled) throw new CliError("지금은 스토어프론트 소스를 받을 수 없습니다(편집 기능이 꺼져 있습니다)");
1002
1900
  const source = await client.storefront.getSource();
1003
- 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를 확인하십시오`);
1004
1902
  const store = {
1005
1903
  storeId: source.storeId,
1006
1904
  storeCode: source.storeCode
1007
1905
  };
1008
1906
  const defaultDir = store.storeCode || source.siteId;
1009
- const target = !flags.dir && existsSync(join(io.cwd, RC_FILE)) ? io.cwd : resolve(io.cwd, flags.dir ?? defaultDir);
1907
+ const target = !flags.dir && isPulledDir(io.cwd) ? io.cwd : resolve(io.cwd, flags.dir ?? defaultDir);
1908
+ if (target !== authDir) migrateLegacy(target, io.out);
1010
1909
  const existing = existsSync(target) ? readdirSync(target) : [];
1011
1910
  if (existing.length > 0) {
1012
1911
  if (!flags.force) throw new CliError(`폴더가 비어 있지 않습니다: ${relativeOf(io, target)}. 같은 사이트를 받은 폴더면 --force로 원격 작업 트리를 다시 받을 수 있습니다`);
1013
- let rc = null;
1912
+ let state = null;
1014
1913
  try {
1015
- rc = readRc(target);
1914
+ state = readState(target);
1016
1915
  } catch {
1017
- rc = null;
1916
+ state = null;
1917
+ }
1918
+ if (state && state.siteId !== source.siteId) throw new CliError(`${relativeOf(io, target)}는 다른 사이트(${state.siteId})를 받은 폴더입니다. 빈 폴더를 지정하십시오`);
1919
+ if (!state) {
1920
+ if (!flags.project) throw new CliError(`${relativeOf(io, target)}는 이 사이트(${source.siteId})를 받은 폴더가 아닙니다(${STATE_FILE} 없음). 이 사이트 소스를 clone한 폴더면 --project ${source.storeId}를 함께 주십시오. 아니면 빈 폴더를 지정하십시오`);
1921
+ io.out(`${relativeOf(io, target)}에 ${STATE_FILE}가 없습니다. 이 폴더를 사이트 ${source.siteId}의 소스 폴더로 받아들이고 원격 작업 트리로 맞춥니다`);
1922
+ if (!flags.yes) {
1923
+ if (!io.confirm) throw new CliError("받은 폴더가 아닌 곳을 원격 작업 트리로 맞춥니다. 확인할 수 없는 환경이면 --yes를 함께 주십시오");
1924
+ if (!await io.confirm("이 폴더를 이 사이트의 소스 폴더로 쓰겠습니까?")) {
1925
+ io.out("취소했습니다");
1926
+ return 1;
1927
+ }
1928
+ }
1018
1929
  }
1019
- if (!rc || rc.siteId !== source.siteId) throw new CliError(`${relativeOf(io, target)}는 이 사이트(${source.siteId})를 받은 폴더가 아닙니다(${RC_FILE} 없음 또는 다른 사이트). 빈 폴더를 지정하십시오`);
1020
1930
  }
1021
1931
  io.out(`작업 트리 받기 — ${source.files}개 파일, ${formatBytes(source.bytes)} (압축 ${formatBytes(source.archive.bytes)})`);
1022
1932
  const chunks = [];
@@ -1054,36 +1964,28 @@ async function pull(flags, io) {
1054
1964
  mkdirSync(target, { recursive: true });
1055
1965
  writeTree(target, files, existing.length > 0 ? diff.remove : []);
1056
1966
  const removed = existing.length > 0 ? diff.remove.length : 0;
1057
- writeRc(target, {
1967
+ writeState(target, {
1058
1968
  storeId: store.storeId,
1059
1969
  siteId: source.siteId,
1060
1970
  baseRef: source.sourceRef,
1061
1971
  ...auth.apiOrigin !== DEFAULT_API_ORIGIN ? { apiOrigin: auth.apiOrigin } : {}
1062
1972
  });
1063
1973
  updateEnvFile(target, {
1064
- SAYREN_TOKEN: auth.token,
1065
1974
  SAYREN_API_URL: `${auth.apiOrigin}/storefront/v1`,
1066
1975
  SAYREN_STORE_CODE: store.storeCode
1067
- });
1976
+ }, ["SAYREN_TOKEN"]);
1068
1977
  const addedIgnore = ensureGitignore(target);
1978
+ const written = writeMcpJson(target, { storeId: store.storeId });
1069
1979
  io.out(`받음 → ${relativeOf(io, target)} (${files.size}개${removed ? `, 원격에 없는 ${removed}개 지움` : ""})`);
1070
- io.out(`${RC_FILE} 작성 — 사이트 ${source.siteId}. 커밋해도 됩니다(비밀 없음)`);
1071
- io.out(`${ENV_FILE} 작성 — 토큰과 스토어프론트 API 주소. 커밋하지 마십시오${addedIgnore ? "(.gitignore에 더했습니다)" : ""}`);
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 주소·스토어 코드(개발 서버용)`);
1984
+ io.out(`${STATE_FILE} 작성 — 사이트 ${source.siteId}·받은 작업 트리${addedIgnore.length ? ` (.gitignore에 ${addedIgnore.join(" ")} 추가)` : ""}`);
1072
1985
  if (source.editSessionOpen) io.out("알림: 콘솔·AI 편집 환경이 열려 있습니다. 배포 전에 그 편집이 끝났는지 확인하십시오");
1073
- warnTokenLoading(target, io);
1074
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 # 빌드 → 버전 → 발행`);
1075
1987
  return 0;
1076
1988
  }
1077
- /**
1078
- * 옛 템플릿의 vite.config.ts는 `.env`의 `SAYREN_*`를 모두 서버 코드 환경에 싣는다 — 토큰(`SAYREN_TOKEN`)도 실린다.
1079
- * 브라우저 번들에는 들어가지 않지만 서버 코드가 읽을 수 있으므로 알린다(새 템플릿은 토큰을 빼고 싣는다).
1080
- */
1081
- function warnTokenLoading(dir, io) {
1082
- const path = join(dir, "vite.config.ts");
1083
- if (!existsSync(path)) return;
1084
- const text = readFileSync(path, "utf8");
1085
- if (text.includes("\"SAYREN_\"") && !text.includes("SAYREN_TOKEN")) io.out("알림: vite.config.ts가 .env의 SAYREN_TOKEN까지 개발 서버 환경에 싣습니다. 서버 코드에서 process.env.SAYREN_TOKEN을 읽지 마십시오(최신 템플릿은 싣지 않습니다)");
1086
- }
1087
1989
  async function verify(flags, io) {
1088
1990
  const dir = resolve(io.cwd, flags.dir ?? ".");
1089
1991
  const tree = collectTree(dir);
@@ -1097,22 +1999,30 @@ function printChecks(tree, checks, io) {
1097
1999
  io.out(`로컬 검사 — 파일 ${tree.files.length}개(${formatBytes(tree.totalBytes)}), 규칙 ${report$1.summary.passed}/${report$1.summary.rules} 통과`);
1098
2000
  for (const finding of report$1.findings) {
1099
2001
  const at = finding.line ? `${finding.file}:${finding.line}` : finding.file;
1100
- io.out(` · 위반 [${finding.ruleId}] ${at} — ${finding.fix}`);
2002
+ const label = finding.severity === "warning" ? "권고" : "위반";
2003
+ io.out(` · ${label} [${finding.ruleId}] ${at} — ${finding.fix}`);
1101
2004
  }
1102
2005
  for (const problem of checks.problems) io.out(` · 문제 ${problem}`);
1103
2006
  for (const warning of checks.warnings) io.out(` · 경고 ${warning}`);
1104
- const secrets = tree.skipped.filter((path) => /(^|\/)\.env|(^|\/)\.sayrenrc$/.test(path));
2007
+ const secrets = tree.skipped.filter(isNeverUpload);
1105
2008
  if (secrets.length) io.out(` · 올리지 않음(비밀): ${secrets.join(", ")}`);
1106
2009
  const others = tree.skipped.filter((path) => !secrets.includes(path));
1107
2010
  if (others.length) io.out(` · 올리지 않음(작업 트리 밖): ${others.slice(0, 8).join(", ")}${others.length > 8 ? ` 외 ${others.length - 8}개` : ""}`);
1108
2011
  }
1109
2012
  async function deploy(flags, io) {
1110
2013
  const dir = resolve(io.cwd, flags.dir ?? ".");
1111
- const rc = readRc(dir);
1112
- if (!rc) throw new CliError(`${RC_FILE}가 없습니다. 먼저 npx sayren pull로 사이트 소스를 받으십시오`);
1113
- const auth = resolveAuth(flags, dir, rc, io.env);
1114
- await checkAuthSources(auth, io);
1115
- const client = clientOf(auth, io.fetch);
2014
+ migrateLegacy(dir, io.out);
2015
+ const rc = readState(dir);
2016
+ if (!rc) {
2017
+ let storeId = null;
2018
+ try {
2019
+ storeId = readMcpStoreId(dir);
2020
+ } catch {
2021
+ storeId = null;
2022
+ }
2023
+ throw new CliError(`${STATE_FILE}가 없습니다. 이 사이트 소스를 clone한 폴더면 npx sayren pull --force --project ${storeId ?? "<storeId>"} --dir ${relativeOf(io, dir)}로 이 폴더를 원격 작업 트리에 맞춘 뒤(로컬 변경은 먼저 커밋·백업하십시오) 다시 배포하십시오. 처음이면 npx sayren pull로 받으십시오`);
2024
+ }
2025
+ const { client } = await apiFor(flags, dir, rc, io);
1116
2026
  const tree = collectTree(dir);
1117
2027
  const checks = runLocalChecks(tree);
1118
2028
  printChecks(tree, checks, io);
@@ -1138,7 +2048,7 @@ async function deploy(flags, io) {
1138
2048
  }
1139
2049
  throw error;
1140
2050
  }
1141
- writeRc(dir, {
2051
+ writeState(dir, {
1142
2052
  ...rc,
1143
2053
  baseRef: replaced.sourceRef
1144
2054
  });
@@ -1245,6 +2155,7 @@ const REASON_LABEL = {
1245
2155
  MALWARE: "악성 패키지",
1246
2156
  INVALID_MANIFEST: "선언 오류",
1247
2157
  INSTALL_SCRIPT: "설치 스크립트",
2158
+ INSTALL_CONFIG: "설치 방식 필드",
1248
2159
  UNSUPPORTED_SOURCE: "지원하지 않는 설치 경로",
1249
2160
  BUNDLED_DEPENDENCIES: "묶인 의존성"
1250
2161
  };
@@ -1307,10 +2218,9 @@ function printBuildFailure(build, io) {
1307
2218
  }
1308
2219
  async function status(flags, io) {
1309
2220
  const dir = resolve(io.cwd, flags.dir ?? ".");
1310
- const rc = readRc(dir);
1311
- const auth = resolveAuth(flags, dir, rc, io.env);
1312
- await checkAuthSources(auth, io);
1313
- const client = clientOf(auth, io.fetch);
2221
+ migrateLegacy(dir, io.out);
2222
+ const rc = readState(dir);
2223
+ const { client } = await apiFor(flags, dir, rc, io);
1314
2224
  const hosting = await client.storefront.get();
1315
2225
  const site = hosting.site;
1316
2226
  if (!site) {
@@ -1330,18 +2240,115 @@ async function status(flags, io) {
1330
2240
  else io.out(` 이 폴더의 기준 작업 트리 ${rc.baseRef.slice(rc.baseRef.lastIndexOf("/") + 1, -4).slice(0, 12)}`);
1331
2241
  return 0;
1332
2242
  }
2243
+ /**
2244
+ * `sayren login`(#71) — 기본은 Sayren 계정 로그인이다(브라우저 루프백 + PKCE, 안 되면 디바이스 코드). 로그인 토큰은 사용자 설정
2245
+ * 폴더의 `credentials.json`에 둔다. `--token`·`--paste`로 계정 액세스 토큰(PAT)을 붙여 넣을 수 있다. 옛 API 토큰(`sy_pat_`)은
2246
+ * 폐기되어(R1) 거절한다.
2247
+ */
1333
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) {
2259
+ const dir = resolve(io.cwd, flags.dir ?? ".");
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);
2271
+ }
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);
2276
+ return 0;
2277
+ }
2278
+ /** 붙여 넣은 계정 액세스 토큰(PAT)을 자격 증명 파일에 둔다 — 갱신이 없고 만료까지 쓴다 */
2279
+ async function pasteAccountToken(flags, token, io) {
1334
2280
  const dir = resolve(io.cwd, flags.dir ?? ".");
1335
- const token = flags.token?.trim() || (await readToken())?.trim();
1336
- if (!token) throw new CliError("토큰을 입력하지 않았습니다");
1337
- const hosting = await clientOf(resolveAuth({
1338
- ...flags,
1339
- token
1340
- }, dir, readRc(dir), io.env), io.fetch).storefront.get();
1341
- mkdirSync(dir, { recursive: true });
1342
- updateEnvFile(dir, { SAYREN_TOKEN: token });
1343
- const addedIgnore = ensureGitignore(dir);
1344
- io.out(`토큰을 확인하고(사이트 ${hosting.site?.hostname ?? "없음"}) ${relativeOf(io, join(dir, ENV_FILE))}에 저장했습니다${addedIgnore ? "(.gitignore에 .env를 더했습니다)" : ""}`);
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
+ };
2308
+ try {
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}개`);
2313
+ } catch {
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;
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}`);
1345
2352
  return 0;
1346
2353
  }
1347
2354
  function relativeOf(io, path) {
@@ -1353,30 +2360,42 @@ function relativeOf(io, path) {
1353
2360
  const USAGE = `sayren ${CLI_VERSION} — 호스팅 스토어프론트 CLI
1354
2361
 
1355
2362
  사용법:
1356
- npx sayren login # 토큰을 입력받아 .env에 저장(셸 기록에 남지 않습니다)
2363
+ npx sayren login [--device] # Sayren 계정으로 로그인(브라우저, 안 되면 기기 코드)
2364
+ npx sayren login --paste # 액세스 토큰을 입력받아 저장(셸 기록에 남지 않습니다)
2365
+ npx sayren logout
2366
+ npx sayren whoami
1357
2367
  npx sayren pull --project <storeId> [폴더] [--force [--yes]]
1358
2368
  npx sayren deploy [--no-publish] [--message <메모>] [--force [--yes]]
1359
2369
  npx sayren status
1360
2370
  npx sayren verify
1361
2371
 
1362
2372
  명령:
1363
- login 토큰을 확인하고 지금 폴더의 .env에 저장합니다
1364
- pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .sayrenrc·.env를 씁니다
2373
+ login Sayren 계정으로 로그인합니다. 로그인 토큰은 사용자 설정 폴더(~/.config/sayren/credentials.json)에 둡니다
2374
+ logout 로그인을 폐기하고 이 컴퓨터의 토큰을 지웁니다
2375
+ whoami 쓰는 계정·토큰 종류·스토어 범위
2376
+ pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .mcp.json·.env·.sayren/state.json을 씁니다
1365
2377
  deploy 로컬 검사 → 작업 트리 올리기 → 빌드 → 발행. 원격이 pull 이후 바뀌었으면 덮어쓰지 않습니다
1366
2378
  status 사이트·발행 버전·최근 빌드·편집 환경
1367
2379
  verify 로컬 규칙·경로·크기 검사(서버 빌드와 같은 규칙)
1368
2380
 
1369
- 토큰은 SAYREN_TOKEN 환경 변수 → .env 순서로 읽습니다(API 토큰 sy_pat_…, 스코프 storefront:rw).
2381
+ 토큰은 Sayren 계정 토큰만 받고 --token → SAYREN_TOKEN 환경 변수 → 로그인(credentials.json) 순서로 읽습니다.
2382
+ 계정 토큰은 계정에 발급됩니다 — 스토어는 --store → SAYREN_STORE_ID → 폴더 설정 순서로 고릅니다(하나면 자동).
2383
+ 옛 API 토큰(sy_pat_…)은 더 쓸 수 없습니다. .mcp.json에는 토큰을 쓰지 않습니다(MCP 클라이언트가 스스로 로그인합니다).
2384
+ .mcp.json·.env·.sayren/은 .gitignore에 더하고 배포에 올리지 않습니다.
1370
2385
 
1371
2386
  옵션:
1372
- --project <storeId> pull: 토큰의 스토어와 같아야 합니다
2387
+ --project <storeId> pull: 받을 스토어(--store와 같습니다)
1373
2388
  --dir <폴더> 작업 폴더(기본: 지금 폴더, pull은 스토어 코드)
1374
2389
  --force pull: 같은 사이트를 받은 폴더에 원격을 다시 받습니다(지울·덮을 파일을 보여 주고 묻습니다)
1375
2390
  deploy: 원격 변경·열린 편집 환경을 무시하고 덮어씁니다(확인을 묻습니다)
1376
2391
  --yes --force의 확인을 묻지 않습니다(비대화형)
1377
2392
  --no-publish deploy: 버전까지만 만들고 발행하지 않습니다
1378
2393
  --message <메모> deploy: 버전 메모
2394
+ --store <storeId> 다룰 스토어(SAYREN_STORE_ID)
2395
+ --device login: 브라우저 대신 기기 코드로 로그인합니다(SSH 등)
2396
+ --paste login: 액세스 토큰을 입력받습니다
1379
2397
  --api-origin <주소> api 주소(기본 https://api.sayren.app, SAYREN_API_ORIGIN). https만, localhost만 http
2398
+ --accounts-origin <주소> 계정 서버 주소(기본: api 주소에 맞춘 값, SAYREN_ACCOUNTS_ORIGIN)
1380
2399
  --token <토큰> 토큰을 직접 줍니다. 셸 기록·프로세스 목록에 남으니 login·SAYREN_TOKEN을 권합니다
1381
2400
 
1382
2401
  종료 코드: 0 성공 · 1 실패 · 3 승인 대기(에이전트 토큰)`;
@@ -1385,14 +2404,18 @@ const VALUE_FLAGS = new Set([
1385
2404
  "project",
1386
2405
  "dir",
1387
2406
  "message",
1388
- "api-origin"
2407
+ "api-origin",
2408
+ "store",
2409
+ "accounts-origin"
1389
2410
  ]);
1390
2411
  const BOOL_FLAGS = new Set([
1391
2412
  "force",
1392
2413
  "yes",
1393
2414
  "no-publish",
1394
2415
  "help",
1395
- "version"
2416
+ "version",
2417
+ "device",
2418
+ "paste"
1396
2419
  ]);
1397
2420
  function parseArgs(argv) {
1398
2421
  const out = {
@@ -1436,7 +2459,14 @@ async function main(argv, io) {
1436
2459
  const common = {
1437
2460
  token: str(parsed.flags.token),
1438
2461
  apiOrigin: str(parsed.flags["api-origin"]),
1439
- 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"])
1440
2470
  };
1441
2471
  switch (parsed.command) {
1442
2472
  case "pull": return pull({
@@ -1454,7 +2484,9 @@ async function main(argv, io) {
1454
2484
  }, io);
1455
2485
  case "status": return status(common, io);
1456
2486
  case "verify": return verify(common, io);
1457
- 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);
1458
2490
  default: throw new CliError(`알 수 없는 명령: ${parsed.command}\n\n${USAGE}`);
1459
2491
  }
1460
2492
  }
@@ -1480,14 +2512,49 @@ function defaultIo() {
1480
2512
  confirm: stdin.isTTY ? async (question) => /^(y|yes|예|네)$/i.test((await ask(`${question} (y/N) `) ?? "").trim()) : null,
1481
2513
  sleep: (ms) => new Promise((resolve$1) => setTimeout(resolve$1, ms)),
1482
2514
  buildTimeoutMs: 20 * 6e4,
1483
- 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
1484
2525
  };
1485
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
+ }
1486
2551
  /** 오류 표시 — 토큰·요청 본문은 싣지 않는다 */
1487
2552
  function describeError(error) {
1488
2553
  if (error instanceof CliError) return error.message;
1489
2554
  if (error instanceof ApiError) {
1490
- 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)";
1491
2558
  if (error.status === 403 && error.code === "INSUFFICIENT_SCOPE") return "토큰에 storefront:rw 스코프가 없습니다. 스코프를 더한 토큰을 쓰십시오";
1492
2559
  if (error.code === "TOO_MANY_REQUESTS") return "요청이 너무 많습니다. 잠시 뒤 다시 시도하십시오";
1493
2560
  return `${error.message} (${error.code}, HTTP ${error.status})`;