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.
- package/dist/index.mjs +431 -99
- 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.
|
|
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
|
|
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
|
|
588
|
+
//#region ../../apps/hosting-sandbox/src/verify.ts
|
|
426
589
|
/**
|
|
427
|
-
* 규칙 검증. 원천은 `@sayren/mcp`의 규칙(
|
|
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
|
|
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
|
|
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
|
-
* 설정
|
|
770
|
+
* 설정 (2026-09-29 사용자 결정: `.sayrenrc`를 없애고 받은 폴더에 원격 MCP `.mcp.json`을 둔다)
|
|
608
771
|
*
|
|
609
|
-
* - `.
|
|
610
|
-
*
|
|
611
|
-
* - `.
|
|
612
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
791
|
+
parsed = JSON.parse(text);
|
|
629
792
|
} catch {
|
|
630
|
-
throw new CliError(`${
|
|
793
|
+
throw new CliError(`${name}를 읽지 못했습니다. JSON 형식을 확인하십시오`);
|
|
631
794
|
}
|
|
632
|
-
if (!parsed || typeof parsed !== "object") throw new CliError(`${
|
|
633
|
-
const
|
|
634
|
-
|
|
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:
|
|
638
|
-
siteId:
|
|
639
|
-
baseRef:
|
|
640
|
-
...typeof
|
|
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
|
|
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:
|
|
646
|
-
siteId:
|
|
647
|
-
baseRef:
|
|
812
|
+
storeId: state.storeId,
|
|
813
|
+
siteId: state.siteId,
|
|
814
|
+
baseRef: state.baseRef
|
|
648
815
|
};
|
|
649
|
-
if (
|
|
650
|
-
|
|
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`에 값들을
|
|
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 =
|
|
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
|
-
|
|
924
|
+
next.push(`${key}=${pending.get(key)}`);
|
|
675
925
|
pending.delete(key);
|
|
676
|
-
|
|
926
|
+
continue;
|
|
677
927
|
}
|
|
678
|
-
|
|
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
|
-
|
|
683
|
-
try {
|
|
684
|
-
chmodSync(path, 384);
|
|
685
|
-
} catch {}
|
|
932
|
+
writePrivate(path, next.length ? `${next.join("\n")}\n` : "");
|
|
686
933
|
}
|
|
687
|
-
/** `.gitignore`에 `.
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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,
|
|
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 =
|
|
973
|
+
const fromFile = fromFlag || fromEnv ? null : readMcpCredentials(dir).token;
|
|
706
974
|
const token = fromFlag || fromEnv || fromFile;
|
|
707
|
-
if (!token) throw new CliError(
|
|
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
|
-
["
|
|
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" : "
|
|
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`·`.
|
|
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 !== "
|
|
989
|
-
const from = auth.originSource === ".env" ? ".env의 SAYREN_API_URL" :
|
|
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
|
|
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 &&
|
|
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
|
|
1298
|
+
let state = null;
|
|
1014
1299
|
try {
|
|
1015
|
-
|
|
1300
|
+
state = readState(target);
|
|
1016
1301
|
} catch {
|
|
1017
|
-
|
|
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
|
-
|
|
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(
|
|
1071
|
-
io.out(`${
|
|
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(
|
|
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
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1639
|
+
migrateLegacy(dir, io.out);
|
|
1640
|
+
const state = readState(dir);
|
|
1641
|
+
const client = clientOf(resolveAuth({
|
|
1338
1642
|
...flags,
|
|
1339
1643
|
token
|
|
1340
|
-
}, dir,
|
|
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
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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 # 토큰을 입력받아 .
|
|
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 토큰을 확인하고 지금 폴더의 .
|
|
1364
|
-
pull 호스팅 중인 사이트의 작업 트리를 폴더(기본: 스토어 코드)로 받고 .
|
|
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 환경 변수 → .
|
|
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.
|
|
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.
|
|
16
|
+
"@sayren/store-sdk": "^0.23.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|