sayren 0.1.0 → 0.2.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 +431 -99
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -4,10 +4,11 @@ import { createInterface } from "node:readline/promises";
4
4
  import { ApiError, ApprovalPendingError, STOREFRONT_BUILD_TERMINAL_STATUSES, STOREFRONT_SOURCE_BLOB_BATCH_MAX, createStoreClient, normalizeStorefrontPath } from "@sayren/store-sdk";
5
5
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, relative, resolve, sep } from "node:path";
7
+ import { spawnSync } from "node:child_process";
7
8
  import { createHash } from "node:crypto";
8
9
 
9
10
  //#region package.json
10
- var version = "0.1.0";
11
+ var version = "0.2.0";
11
12
 
12
13
  //#endregion
13
14
  //#region src/client.ts
@@ -40,6 +41,168 @@ function detailsOf(error) {
40
41
  return error instanceof ApiError ? error.details ?? {} : {};
41
42
  }
42
43
 
44
+ //#endregion
45
+ //#region ../mcp/src/mcp-json.ts
46
+ /**
47
+ * 셀러 프로젝트의 `.mcp.json`(Claude Code 형식) — 원격 MCP(`mcp.sayren.app`)에 붙는 `sayren` 항목을 만들고 읽는다.
48
+ *
49
+ * `npx sayren pull`·`login`(packages/cli)과 `sayren-mcp create`가 같은 함수로 쓴다. 파일 입출력은 부르는 쪽이 하고 여기는 문자열만
50
+ * 다룬다 — 게이트웨이(Worker)가 헤더 이름을 이 파일에서 가져가므로 Node 전용 API를 쓰지 않는다.
51
+ *
52
+ * - 토큰 값을 파일에 그대로 쓴다(2026-09-29 사용자 결정). 그래서 `.mcp.json`은 `.gitignore` 대상이고 배포에 올리지 않는다.
53
+ * - 다른 서버 항목과 `sayren` 항목의 다른 헤더는 그대로 둔다. `sayren` 항목의 `type`·`url`·토큰·스토어 헤더만 바꾼다.
54
+ * - 스토어 헤더(`X-Store-Id`)는 게이트웨이가 토큰의 스토어와 대조한다(다르면 403). 토큰을 다른 스토어 것으로 바꿔 넣는 실수를 막는다.
55
+ */
56
+ const MCP_JSON_FILE = ".mcp.json";
57
+ const MCP_SERVER_NAME = "sayren";
58
+ /** 원격 MCP 주소. 게이트웨이는 운영 api 하나만 바라본다 — 다른 api 주소를 쓰는 폴더도 이 주소를 쓴다 */
59
+ const REMOTE_MCP_URL = "https://mcp.sayren.app/mcp";
60
+ /** 관리 API의 테넌트 헤더와 같은 이름이다(api `StoreTokenGuard`가 토큰의 스토어와 대조한다) */
61
+ const STORE_ID_HEADER = "X-Store-Id";
62
+ var McpJsonError = class extends Error {
63
+ constructor(message) {
64
+ super(message);
65
+ this.name = "McpJsonError";
66
+ }
67
+ };
68
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
69
+ function parse(text) {
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(text);
73
+ } catch {
74
+ throw new McpJsonError(`${MCP_JSON_FILE}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
75
+ }
76
+ if (!isObject(parsed)) throw new McpJsonError(`${MCP_JSON_FILE} 형식이 아닙니다`);
77
+ if (parsed.mcpServers !== void 0 && !isObject(parsed.mcpServers)) throw new McpJsonError(`${MCP_JSON_FILE}의 mcpServers가 객체가 아닙니다`);
78
+ return parsed;
79
+ }
80
+ /** 대소문자를 가리지 않고 헤더 키를 찾는다 */
81
+ function headerKey(headers, name) {
82
+ const lower = name.toLowerCase();
83
+ return Object.keys(headers).find((key) => key.toLowerCase() === lower);
84
+ }
85
+ /**
86
+ * `sayren` 항목을 넣거나 바꾼 `.mcp.json` 본문. `existing`이 없으면 새 파일이다.
87
+ * 형식이 틀린 파일은 덮어쓰지 않고 `McpJsonError`다(셀러가 손으로 쓴 다른 서버 설정을 지우지 않게).
88
+ */
89
+ function mergeMcpJson(existing, credentials) {
90
+ const root = existing?.trim() ? parse(existing) : {};
91
+ const servers = isObject(root.mcpServers) ? { ...root.mcpServers } : {};
92
+ const previous = isObject(servers[MCP_SERVER_NAME]) ? servers[MCP_SERVER_NAME] : {};
93
+ 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
+ }
98
+ const next = {
99
+ ...previous,
100
+ type: "http",
101
+ url: REMOTE_MCP_URL,
102
+ headers: {
103
+ Authorization: `Bearer ${credentials.token}`,
104
+ ...credentials.storeId ? { [STORE_ID_HEADER]: credentials.storeId } : {},
105
+ ...headers
106
+ }
107
+ };
108
+ delete next.command;
109
+ delete next.args;
110
+ delete next.env;
111
+ servers[MCP_SERVER_NAME] = next;
112
+ return `${JSON.stringify({
113
+ ...root,
114
+ mcpServers: servers
115
+ }, null, 2)}\n`;
116
+ }
117
+ /** `sayren` 항목의 토큰·스토어. 항목·헤더가 없으면 null 값이다. 형식이 틀린 파일은 `McpJsonError` */
118
+ function sayrenCredentialsOf(text) {
119
+ const root = parse(text);
120
+ const entry = (isObject(root.mcpServers) ? root.mcpServers : {})[MCP_SERVER_NAME];
121
+ const headers = isObject(entry) && isObject(entry.headers) ? entry.headers : {};
122
+ const authKey = headerKey(headers, "Authorization");
123
+ 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
+ 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
+ };
131
+ }
132
+
133
+ //#endregion
134
+ //#region ../mcp/src/mcp-json-file.ts
135
+ /**
136
+ * `.mcp.json` 파일 쓰기(Node) — `sayren` CLI(packages/cli)와 `sayren-mcp create`가 같이 쓴다. 형식은 `mcp-json.ts`(Node 없음)다.
137
+ *
138
+ * 토큰을 파일에 그대로 쓰므로 두 경우에는 쓰지 않는다.
139
+ * - git이 추적 중인 `.mcp.json`(팀이 커밋해 둔 설정): `.gitignore`는 추적 중인 파일을 막지 못해 다음 커밋에 토큰이 실린다.
140
+ * - 심볼릭 링크: 링크를 따라 폴더 밖 파일에 토큰을 쓰지 않는다.
141
+ */
142
+ /** 추적 중인 `.mcp.json`에 쓰지 않았을 때의 안내 */
143
+ const TRACKED_MCP_JSON_NOTICE = `${MCP_JSON_FILE}가 git에 커밋돼 있어 토큰을 쓰지 않았습니다. 토큰을 파일에 두지 않으려면 SAYREN_TOKEN 환경 변수로 주십시오. 파일에 두려면 git rm --cached ${MCP_JSON_FILE}로 추적을 해제하고 .gitignore에 더한 뒤 다시 실행하십시오`;
144
+ function git(dir, args) {
145
+ if (!existsSync(dir)) return null;
146
+ const result = spawnSync("git", args, {
147
+ cwd: dir,
148
+ stdio: "ignore",
149
+ timeout: 1e4
150
+ });
151
+ if (result.error || result.status === null) return null;
152
+ return result.status;
153
+ }
154
+ /** git이 이 파일을 추적하는가. git이 없거나 저장소가 아니면 false */
155
+ function isGitTracked(dir, name) {
156
+ return git(dir, [
157
+ "ls-files",
158
+ "--error-unmatch",
159
+ "--",
160
+ name
161
+ ]) === 0;
162
+ }
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
+ /** 심볼릭 링크면 쓰지 않는다(링크를 따라 폴더 밖 파일을 덮지 않게). 권한은 600이다 */
176
+ function writePrivateFile(path, text) {
177
+ let link = false;
178
+ try {
179
+ link = lstatSync(path).isSymbolicLink();
180
+ } catch {
181
+ link = false;
182
+ }
183
+ if (link) throw new McpJsonError(`${path}가 심볼릭 링크라 쓰지 않았습니다. 링크를 지운 뒤 다시 실행하십시오`);
184
+ writeFileSync(path, text, { mode: 384 });
185
+ try {
186
+ chmodSync(path, 384);
187
+ } catch {}
188
+ }
189
+ /**
190
+ * `sayren` 항목을 넣는다(다른 서버는 둔다). git이 추적 중인 파일이면 쓰지 않고 `"tracked"`다.
191
+ * 형식이 틀린 파일·심볼릭 링크는 `McpJsonError`다.
192
+ */
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;
197
+ try {
198
+ if (!lstatSync(path).isSymbolicLink()) existing = readFileSync(path, "utf8");
199
+ } catch {
200
+ existing = null;
201
+ }
202
+ writePrivateFile(path, mergeMcpJson(existing, credentials));
203
+ return "written";
204
+ }
205
+
43
206
  //#endregion
44
207
  //#region ../mcp/src/rules.ts
45
208
  /** 문서 사이트 — `doc` 경로를 절대 주소로 만들 때 쓴다 */
@@ -407,7 +570,7 @@ function reportFromFindings(findings, checkedFiles, target = "local") {
407
570
  }
408
571
 
409
572
  //#endregion
410
- //#region ../hosting-sandbox/src/hash.ts
573
+ //#region ../../apps/hosting-sandbox/src/hash.ts
411
574
  const strictUtf8 = new TextDecoder("utf-8", {
412
575
  fatal: true,
413
576
  ignoreBOM: true
@@ -422,9 +585,9 @@ function asUtf8(bytes) {
422
585
  }
423
586
 
424
587
  //#endregion
425
- //#region ../hosting-sandbox/src/verify.ts
588
+ //#region ../../apps/hosting-sandbox/src/verify.ts
426
589
  /**
427
- * 규칙 검증. 원천은 `@sayren/mcp`의 규칙(apps/mcp/src/rules.ts)과 보고서(verify-report.ts)다 — stdio `verify_storefront`와
590
+ * 규칙 검증. 원천은 `@sayren/mcp`의 규칙(packages/mcp/src/rules.ts)과 보고서(verify-report.ts)다 — stdio `verify_storefront`와
428
591
  * 같은 함수를 Worker가 직접 돌린다(컨테이너가 필요 없다). 대상은 stdio와 같은 소스 확장자다.
429
592
  */
430
593
  const SOURCE_RE = /\.(ts|tsx|js|jsx)$/;
@@ -439,7 +602,7 @@ function verifyTree(files) {
439
602
  }
440
603
 
441
604
  //#endregion
442
- //#region ../hosting-sandbox/src/paths.ts
605
+ //#region ../../apps/hosting-sandbox/src/paths.ts
443
606
  const MAX_FILE_BYTES = 1024 * 1024;
444
607
  const MAX_TREE_BYTES = 20 * 1024 * 1024;
445
608
  const MAX_TREE_FILES = 2e3;
@@ -501,7 +664,7 @@ function accessOf(path) {
501
664
  }
502
665
 
503
666
  //#endregion
504
- //#region ../hosting-sandbox/src/tar.ts
667
+ //#region ../../apps/hosting-sandbox/src/tar.ts
505
668
  const BLOCK = 512;
506
669
  const enc = new TextEncoder();
507
670
  const dec = new TextDecoder();
@@ -604,50 +767,135 @@ async function gunzip(bytes, maxBytes) {
604
767
  //#endregion
605
768
  //#region src/config.ts
606
769
  /**
607
- * 설정 — 우선순위는 플래그 > 환경 변수(`SAYREN_TOKEN`·`SAYREN_API_ORIGIN`) > `.env` > `.sayrenrc`다.
770
+ * 설정 (2026-09-29 사용자 결정: `.sayrenrc`를 없애고 받은 폴더에 원격 MCP `.mcp.json`을 둔다)
608
771
  *
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).
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`).
613
780
  */
614
- const RC_FILE = ".sayrenrc";
781
+ const STATE_DIR = ".sayren";
782
+ const STATE_FILE = `${STATE_DIR}/state.json`;
783
+ /** 옛 상태 파일(0.1.x) — 읽어서 옮기고 지운다 */
784
+ const LEGACY_RC_FILE = ".sayrenrc";
615
785
  const ENV_FILE = ".env";
786
+ const MCP_FILE = MCP_JSON_FILE;
616
787
  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;
788
+ function parseState(text, name) {
626
789
  let parsed;
627
790
  try {
628
- parsed = JSON.parse(readFileSync(path, "utf8"));
791
+ parsed = JSON.parse(text);
629
792
  } catch {
630
- throw new CliError(`${RC_FILE}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
793
+ throw new CliError(`${name}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
631
794
  }
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로 다시 받으십시오`);
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로 다시 받으십시오`);
636
798
  return {
637
- storeId: rc.storeId,
638
- siteId: rc.siteId,
639
- baseRef: rc.baseRef,
640
- ...typeof rc.apiOrigin === "string" ? { apiOrigin: rc.apiOrigin } : {}
799
+ storeId: raw.storeId,
800
+ siteId: raw.siteId,
801
+ baseRef: raw.baseRef,
802
+ ...typeof raw.apiOrigin === "string" ? { apiOrigin: raw.apiOrigin } : {}
641
803
  };
642
804
  }
643
- function writeRc(dir, rc) {
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) {
644
811
  const out = {
645
- storeId: rc.storeId,
646
- siteId: rc.siteId,
647
- baseRef: rc.baseRef
812
+ storeId: state.storeId,
813
+ siteId: state.siteId,
814
+ baseRef: state.baseRef
648
815
  };
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`);
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(" ")} 추가)` : ""}`);
898
+ }
651
899
  }
652
900
  /** `.env` 읽기 — `KEY=VALUE` 줄만(따옴표는 벗긴다). 없으면 빈 값 */
653
901
  function readEnvFile(dir) {
@@ -663,60 +911,80 @@ function readEnvFile(dir) {
663
911
  }
664
912
  return out;
665
913
  }
666
- /** `.env`에 값들을 쓴다 — 다른 줄은 그대로 두고 같은 키만 바꾼다. 파일 권한은 600이다(토큰) */
667
- function updateEnvFile(dir, values) {
914
+ /** `.env`에 값들을 쓴다. 다른 줄은 그대로 두고 같은 키만 바꾸거나(`values`) 지운다(`remove`). 파일 권한은 600이다 */
915
+ function updateEnvFile(dir, values, remove = []) {
668
916
  const path = join(dir, ENV_FILE);
669
917
  const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : [];
670
918
  const pending = new Map(Object.entries(values));
671
- const next = lines.map((line) => {
919
+ const next = [];
920
+ for (const line of lines) {
672
921
  const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
922
+ if (key && remove.includes(key)) continue;
673
923
  if (key && pending.has(key)) {
674
- const value = pending.get(key);
924
+ next.push(`${key}=${pending.get(key)}`);
675
925
  pending.delete(key);
676
- return `${key}=${value}`;
926
+ continue;
677
927
  }
678
- return line;
679
- });
928
+ next.push(line);
929
+ }
680
930
  while (next.length > 0 && next[next.length - 1] === "") next.pop();
681
931
  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 {}
932
+ writePrivate(path, next.length ? `${next.join("\n")}\n` : "");
686
933
  }
687
- /** `.gitignore`에 `.env`가 없으면 더한다. 더했으면 true */
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/`)을 더한다. 더한 줄을 돌려준다 */
688
955
  function ensureGitignore(dir) {
689
956
  const path = join(dir, ".gitignore");
690
957
  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;
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;
695
963
  }
696
964
  /** 스토어프론트 API 주소(`…/storefront/v1`)에서 api origin을 읽는다 */
697
965
  function originOfStorefrontUrl(url) {
698
966
  if (!url) return null;
699
967
  return /^(https?:\/\/[^/]+)\/storefront\/v1\/?$/.exec(url.trim())?.[1] ?? null;
700
968
  }
701
- function resolveAuth(flags, dir, rc, env = process.env) {
969
+ function resolveAuth(flags, dir, state, env = process.env) {
702
970
  const dotenv = readEnvFile(dir);
703
971
  const fromFlag = flags.token?.trim();
704
972
  const fromEnv = env.SAYREN_TOKEN?.trim();
705
- const fromFile = dotenv.get("SAYREN_TOKEN")?.trim();
973
+ const fromFile = fromFlag || fromEnv ? null : readMcpCredentials(dir).token;
706
974
  const token = fromFlag || fromEnv || fromFile;
707
- if (!token) throw new CliError("토큰이 없습니다. --token으로 주거나 SAYREN_TOKEN 환경 변수·.env에 넣으십시오(셀러 콘솔 › 개발자 › API 토큰, 스코프 storefront:rw)");
975
+ if (!token) throw new CliError(`토큰이 없습니다. npx sayren login으로 ${MCP_FILE}에 저장하거나 SAYREN_TOKEN 환경 변수·--token으로 주십시오(셀러 콘솔 › 개발자 › API 토큰, 스코프 storefront:rw)`);
708
976
  const picked = [
709
977
  ["flag", flags.apiOrigin?.trim()],
710
978
  ["env", env.SAYREN_API_ORIGIN?.trim()],
711
979
  [".env", originOfStorefrontUrl(dotenv.get("SAYREN_API_URL"))],
712
- ["rc", rc?.apiOrigin]
980
+ ["state", state?.apiOrigin]
713
981
  ].find(([, value]) => value);
714
982
  const apiOrigin = (picked?.[1] ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
715
983
  assertOriginShape(apiOrigin);
716
984
  return {
717
985
  token,
718
986
  apiOrigin,
719
- tokenSource: fromFlag ? "flag" : fromEnv ? "env" : ".env",
987
+ tokenSource: fromFlag ? "flag" : fromEnv ? "env" : "mcp",
720
988
  originSource: picked && apiOrigin !== DEFAULT_API_ORIGIN ? picked[0] : "default"
721
989
  };
722
990
  }
@@ -748,6 +1016,20 @@ var CliError = class extends Error {
748
1016
 
749
1017
  //#endregion
750
1018
  //#region src/tree.ts
1019
+ /**
1020
+ * 로컬 작업 트리 — 서버 작업 트리와 **같은 경로 규칙**(편집 Sandbox `paths.ts`의 `accessOf`)으로 고른다. `src/**`·`public/**`·루트 설정
1021
+ * 파일·`package.json`·`pnpm-lock.yaml`만 올리고, `.env*`·`.mcp.json`·`.sayren/`·`node_modules`·`dist`·`package-lock.json` 등은 올리지 않는다.
1022
+ * 비밀·로컬 상태 파일(`isNeverUpload`)은 서버 경로 규칙과 별개로 CLI가 먼저 막는다 — 서버 규칙이 넓어져도 토큰을 올리지 않게.
1023
+ */
1024
+ /**
1025
+ * 어떤 경우에도 올리지 않는 파일 — `.env*`(개발 서버 값), `.mcp.json`(토큰), `.sayren/`(CLI 상태), 옛 `.sayrenrc`.
1026
+ * 폴더 깊이와 무관하게 이름으로 본다.
1027
+ */
1028
+ function isNeverUpload(path) {
1029
+ const segments = path.split("/");
1030
+ const name = segments[segments.length - 1] ?? "";
1031
+ return segments.some((segment) => segment.startsWith(".env") || segment === ".sayren") || name === ".mcp.json" || name === ".sayrenrc";
1032
+ }
751
1033
  /** 걸어 들어가지 않는 폴더 — 설치물·산출물·캐시·VCS(경로 규칙이 어차피 막는다. 크기 때문에 읽지도 않는다) */
752
1034
  const SKIP_DIRS = new Set([
753
1035
  "node_modules",
@@ -778,7 +1060,7 @@ function collectTree(root) {
778
1060
  skipped.push(rel);
779
1061
  continue;
780
1062
  }
781
- if (accessOf(rel) === null) {
1063
+ if (isNeverUpload(rel) || accessOf(rel) === null) {
782
1064
  skipped.push(rel);
783
1065
  continue;
784
1066
  }
@@ -980,20 +1262,22 @@ const MAX_BLOB_ROUNDS = 3;
980
1262
  /** 올리기 한 번의 내용 합계 상한 — base64(×4/3)가 api 본문 8MiB 안에 든다 */
981
1263
  const BLOB_BATCH_BYTES = 5 * 1024 * 1024;
982
1264
  /**
983
- * 토큰·api 주소 확인 — `--token`은 셸 기록·프로세스 목록에 남으니 알리고, 파일(`.env`·`.sayrenrc`)에서 읽은 기본이 아닌 api 주소는 토큰이
984
- * 그곳으로 가므로 확인한다(대화형이면 묻고, 아니면 `--api-origin`·`SAYREN_API_ORIGIN`으로 명시해야 한다)
1265
+ * 토큰·api 주소 확인 — `--token`은 셸 기록·프로세스 목록에 남으니 알리고, 파일(`.env`·`.sayren/state.json`)에서 읽은 기본이 아닌 api
1266
+ * 주소는 토큰이 그곳으로 가므로 확인한다(대화형이면 묻고, 아니면 `--api-origin`·`SAYREN_API_ORIGIN`으로 명시해야 한다)
985
1267
  */
986
1268
  async function checkAuthSources(auth, io) {
987
1269
  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;
1270
+ if (auth.originSource !== ".env" && auth.originSource !== "state") return;
1271
+ const from = auth.originSource === ".env" ? ".env의 SAYREN_API_URL" : STATE_FILE;
990
1272
  io.err(`경고: 기본이 아닌 api 주소(${auth.apiOrigin})를 ${from}에서 읽었습니다. 토큰이 이 주소로 갑니다`);
991
1273
  if (!io.confirm) throw new CliError(`파일에서 읽은 api 주소는 확인이 필요합니다. 맞으면 --api-origin ${auth.apiOrigin}으로 명시하십시오`);
992
1274
  if (!await io.confirm(`${auth.apiOrigin}으로 보내겠습니까?`)) throw new CliError("취소했습니다");
993
1275
  }
994
1276
  async function pull(flags, io) {
995
1277
  const probeDir = flags.dir ? resolve(io.cwd, flags.dir) : io.cwd;
996
- const auth = resolveAuth(flags, existsSync(probeDir) ? probeDir : io.cwd, null, io.env);
1278
+ const authDir = existsSync(probeDir) ? probeDir : io.cwd;
1279
+ migrateLegacy(authDir, io.out);
1280
+ const auth = resolveAuth(flags, authDir, null, io.env);
997
1281
  await checkAuthSources(auth, io);
998
1282
  const client = clientOf(auth, io.fetch);
999
1283
  const hosting = await client.storefront.get();
@@ -1006,17 +1290,29 @@ async function pull(flags, io) {
1006
1290
  storeCode: source.storeCode
1007
1291
  };
1008
1292
  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);
1293
+ const target = !flags.dir && isPulledDir(io.cwd) ? io.cwd : resolve(io.cwd, flags.dir ?? defaultDir);
1294
+ if (target !== authDir) migrateLegacy(target, io.out);
1010
1295
  const existing = existsSync(target) ? readdirSync(target) : [];
1011
1296
  if (existing.length > 0) {
1012
1297
  if (!flags.force) throw new CliError(`폴더가 비어 있지 않습니다: ${relativeOf(io, target)}. 같은 사이트를 받은 폴더면 --force로 원격 작업 트리를 다시 받을 수 있습니다`);
1013
- let rc = null;
1298
+ let state = null;
1014
1299
  try {
1015
- rc = readRc(target);
1300
+ state = readState(target);
1016
1301
  } catch {
1017
- rc = null;
1302
+ state = null;
1303
+ }
1304
+ if (state && state.siteId !== source.siteId) throw new CliError(`${relativeOf(io, target)}는 다른 사이트(${state.siteId})를 받은 폴더입니다. 빈 폴더를 지정하십시오`);
1305
+ if (!state) {
1306
+ if (!flags.project) throw new CliError(`${relativeOf(io, target)}는 이 사이트(${source.siteId})를 받은 폴더가 아닙니다(${STATE_FILE} 없음). 이 사이트 소스를 clone한 폴더면 --project ${source.storeId}를 함께 주십시오. 아니면 빈 폴더를 지정하십시오`);
1307
+ io.out(`${relativeOf(io, target)}에 ${STATE_FILE}가 없습니다. 이 폴더를 사이트 ${source.siteId}의 소스 폴더로 받아들이고 원격 작업 트리로 맞춥니다`);
1308
+ if (!flags.yes) {
1309
+ if (!io.confirm) throw new CliError("받은 폴더가 아닌 곳을 원격 작업 트리로 맞춥니다. 확인할 수 없는 환경이면 --yes를 함께 주십시오");
1310
+ if (!await io.confirm("이 폴더를 이 사이트의 소스 폴더로 쓰겠습니까?")) {
1311
+ io.out("취소했습니다");
1312
+ return 1;
1313
+ }
1314
+ }
1018
1315
  }
1019
- if (!rc || rc.siteId !== source.siteId) throw new CliError(`${relativeOf(io, target)}는 이 사이트(${source.siteId})를 받은 폴더가 아닙니다(${RC_FILE} 없음 또는 다른 사이트). 빈 폴더를 지정하십시오`);
1020
1316
  }
1021
1317
  io.out(`작업 트리 받기 — ${source.files}개 파일, ${formatBytes(source.bytes)} (압축 ${formatBytes(source.archive.bytes)})`);
1022
1318
  const chunks = [];
@@ -1054,36 +1350,31 @@ async function pull(flags, io) {
1054
1350
  mkdirSync(target, { recursive: true });
1055
1351
  writeTree(target, files, existing.length > 0 ? diff.remove : []);
1056
1352
  const removed = existing.length > 0 ? diff.remove.length : 0;
1057
- writeRc(target, {
1353
+ writeState(target, {
1058
1354
  storeId: store.storeId,
1059
1355
  siteId: source.siteId,
1060
1356
  baseRef: source.sourceRef,
1061
1357
  ...auth.apiOrigin !== DEFAULT_API_ORIGIN ? { apiOrigin: auth.apiOrigin } : {}
1062
1358
  });
1063
1359
  updateEnvFile(target, {
1064
- SAYREN_TOKEN: auth.token,
1065
1360
  SAYREN_API_URL: `${auth.apiOrigin}/storefront/v1`,
1066
1361
  SAYREN_STORE_CODE: store.storeCode
1067
- });
1362
+ }, ["SAYREN_TOKEN"]);
1068
1363
  const addedIgnore = ensureGitignore(target);
1364
+ const written = writeMcpJson(target, {
1365
+ token: auth.token,
1366
+ storeId: store.storeId
1367
+ });
1069
1368
  io.out(`받음 → ${relativeOf(io, target)} (${files.size}개${removed ? `, 원격에 없는 ${removed}개 지움` : ""})`);
1070
- io.out(`${RC_FILE} 작성 — 사이트 ${source.siteId}. 커밋해도 됩니다(비밀 없음)`);
1071
- io.out(`${ENV_FILE} 작성 — 토큰과 스토어프론트 API 주소. 커밋하지 마십시오${addedIgnore ? "(.gitignore에 더했습니다)" : ""}`);
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 주소·스토어 코드(개발 서버용)`);
1373
+ io.out(`${STATE_FILE} 작성 — 사이트 ${source.siteId}·받은 작업 트리${addedIgnore.length ? ` (.gitignore에 ${addedIgnore.join(" ")} 추가)` : ""}`);
1072
1374
  if (source.editSessionOpen) io.out("알림: 콘솔·AI 편집 환경이 열려 있습니다. 배포 전에 그 편집이 끝났는지 확인하십시오");
1073
- warnTokenLoading(target, io);
1074
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 # 빌드 → 버전 → 발행`);
1075
1376
  return 0;
1076
1377
  }
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
1378
  async function verify(flags, io) {
1088
1379
  const dir = resolve(io.cwd, flags.dir ?? ".");
1089
1380
  const tree = collectTree(dir);
@@ -1101,15 +1392,24 @@ function printChecks(tree, checks, io) {
1101
1392
  }
1102
1393
  for (const problem of checks.problems) io.out(` · 문제 ${problem}`);
1103
1394
  for (const warning of checks.warnings) io.out(` · 경고 ${warning}`);
1104
- const secrets = tree.skipped.filter((path) => /(^|\/)\.env|(^|\/)\.sayrenrc$/.test(path));
1395
+ const secrets = tree.skipped.filter(isNeverUpload);
1105
1396
  if (secrets.length) io.out(` · 올리지 않음(비밀): ${secrets.join(", ")}`);
1106
1397
  const others = tree.skipped.filter((path) => !secrets.includes(path));
1107
1398
  if (others.length) io.out(` · 올리지 않음(작업 트리 밖): ${others.slice(0, 8).join(", ")}${others.length > 8 ? ` 외 ${others.length - 8}개` : ""}`);
1108
1399
  }
1109
1400
  async function deploy(flags, io) {
1110
1401
  const dir = resolve(io.cwd, flags.dir ?? ".");
1111
- const rc = readRc(dir);
1112
- if (!rc) throw new CliError(`${RC_FILE}가 없습니다. 먼저 npx sayren pull로 사이트 소스를 받으십시오`);
1402
+ migrateLegacy(dir, io.out);
1403
+ const rc = readState(dir);
1404
+ if (!rc) {
1405
+ let storeId = null;
1406
+ try {
1407
+ storeId = readMcpCredentials(dir).storeId;
1408
+ } catch {
1409
+ storeId = null;
1410
+ }
1411
+ throw new CliError(`${STATE_FILE}가 없습니다. 이 사이트 소스를 clone한 폴더면 npx sayren pull --force --project ${storeId ?? "<storeId>"} --dir ${relativeOf(io, dir)}로 이 폴더를 원격 작업 트리에 맞춘 뒤(로컬 변경은 먼저 커밋·백업하십시오) 다시 배포하십시오. 처음이면 npx sayren pull로 받으십시오`);
1412
+ }
1113
1413
  const auth = resolveAuth(flags, dir, rc, io.env);
1114
1414
  await checkAuthSources(auth, io);
1115
1415
  const client = clientOf(auth, io.fetch);
@@ -1138,7 +1438,7 @@ async function deploy(flags, io) {
1138
1438
  }
1139
1439
  throw error;
1140
1440
  }
1141
- writeRc(dir, {
1441
+ writeState(dir, {
1142
1442
  ...rc,
1143
1443
  baseRef: replaced.sourceRef
1144
1444
  });
@@ -1245,6 +1545,7 @@ const REASON_LABEL = {
1245
1545
  MALWARE: "악성 패키지",
1246
1546
  INVALID_MANIFEST: "선언 오류",
1247
1547
  INSTALL_SCRIPT: "설치 스크립트",
1548
+ INSTALL_CONFIG: "설치 방식 필드",
1248
1549
  UNSUPPORTED_SOURCE: "지원하지 않는 설치 경로",
1249
1550
  BUNDLED_DEPENDENCIES: "묶인 의존성"
1250
1551
  };
@@ -1307,7 +1608,8 @@ function printBuildFailure(build, io) {
1307
1608
  }
1308
1609
  async function status(flags, io) {
1309
1610
  const dir = resolve(io.cwd, flags.dir ?? ".");
1310
- const rc = readRc(dir);
1611
+ migrateLegacy(dir, io.out);
1612
+ const rc = readState(dir);
1311
1613
  const auth = resolveAuth(flags, dir, rc, io.env);
1312
1614
  await checkAuthSources(auth, io);
1313
1615
  const client = clientOf(auth, io.fetch);
@@ -1334,16 +1636,45 @@ async function login(flags, io, readToken) {
1334
1636
  const dir = resolve(io.cwd, flags.dir ?? ".");
1335
1637
  const token = flags.token?.trim() || (await readToken())?.trim();
1336
1638
  if (!token) throw new CliError("토큰을 입력하지 않았습니다");
1337
- const hosting = await clientOf(resolveAuth({
1639
+ migrateLegacy(dir, io.out);
1640
+ const state = readState(dir);
1641
+ const client = clientOf(resolveAuth({
1338
1642
  ...flags,
1339
1643
  token
1340
- }, dir, readRc(dir), io.env), io.fetch).storefront.get();
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가 연결을 거부합니다`);
1341
1650
  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를 더했습니다)" : ""}`);
1651
+ if (writeMcpJson(dir, {
1652
+ token,
1653
+ storeId: expected ?? tokenStore
1654
+ }) === "tracked") {
1655
+ io.err(`알림: ${TRACKED_MCP_JSON_NOTICE}`);
1656
+ return 1;
1657
+ }
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}를 더하십시오`);
1345
1661
  return 0;
1346
1662
  }
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;
1672
+ try {
1673
+ return (await client.storefront.getSource()).storeId;
1674
+ } catch {
1675
+ return null;
1676
+ }
1677
+ }
1347
1678
  function relativeOf(io, path) {
1348
1679
  return relative(io.cwd, path) || ".";
1349
1680
  }
@@ -1353,20 +1684,21 @@ function relativeOf(io, path) {
1353
1684
  const USAGE = `sayren ${CLI_VERSION} — 호스팅 스토어프론트 CLI
1354
1685
 
1355
1686
  사용법:
1356
- npx sayren login # 토큰을 입력받아 .env에 저장(셸 기록에 남지 않습니다)
1687
+ npx sayren login # 토큰을 입력받아 .mcp.json에 저장(셸 기록에 남지 않습니다)
1357
1688
  npx sayren pull --project <storeId> [폴더] [--force [--yes]]
1358
1689
  npx sayren deploy [--no-publish] [--message <메모>] [--force [--yes]]
1359
1690
  npx sayren status
1360
1691
  npx sayren verify
1361
1692
 
1362
1693
  명령:
1363
- login 토큰을 확인하고 지금 폴더의 .env에 저장합니다
1364
- pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .sayrenrc·.env를 씁니다
1694
+ login 토큰을 확인하고 지금 폴더의 .mcp.json(sayren 원격 MCP 설정)에 저장합니다
1695
+ pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .mcp.json·.env·.sayren/state.json을 씁니다
1365
1696
  deploy 로컬 검사 → 작업 트리 올리기 → 빌드 → 발행. 원격이 pull 이후 바뀌었으면 덮어쓰지 않습니다
1366
1697
  status 사이트·발행 버전·최근 빌드·편집 환경
1367
1698
  verify 로컬 규칙·경로·크기 검사(서버 빌드와 같은 규칙)
1368
1699
 
1369
- 토큰은 SAYREN_TOKEN 환경 변수 → .env 순서로 읽습니다(API 토큰 sy_pat_…, 스코프 storefront:rw).
1700
+ 토큰은 --token → SAYREN_TOKEN 환경 변수 → .mcp.json 순서로 읽습니다(API 토큰 sy_pat_…, 스코프 storefront:rw).
1701
+ .mcp.json·.env·.sayren/은 .gitignore에 더하고 배포에 올리지 않습니다.
1370
1702
 
1371
1703
  옵션:
1372
1704
  --project <storeId> pull: 토큰의 스토어와 같아야 합니다
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sayren",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "sayren 스토어프론트 CLI — 호스팅 중인 사이트의 소스를 내려받고(pull) 로컬에서 고친 뒤 배포(deploy)합니다",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "node": ">=20.10"
14
14
  },
15
15
  "dependencies": {
16
- "@sayren/store-sdk": "^0.22.0"
16
+ "@sayren/store-sdk": "^0.23.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",