connectbase-client 3.0.0 → 3.1.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/CHANGELOG.md +37 -0
- package/README.md +92 -0
- package/dist/cli.js +54 -66
- package/dist/connect-base.umd.js +3 -3
- package/dist/index.d.mts +104 -1
- package/dist/index.d.ts +104 -1
- package/dist/index.js +52 -0
- package/dist/index.mjs +51 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,43 @@
|
|
|
3
3
|
본 SDK 의 모든 주요 변경사항을 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 형식으로 기록합니다.
|
|
4
4
|
버전은 [Semantic Versioning](https://semver.org/lang/ko/) 을 따릅니다.
|
|
5
5
|
|
|
6
|
+
## [3.1.0] - 2026-04-29
|
|
7
|
+
|
|
8
|
+
### Added — Endpoint API (로컬 모델 터널 dumb pipe)
|
|
9
|
+
|
|
10
|
+
사용자 PC GPU 모델 (ComfyUI / A1111 / Hunyuan3D / vLLM / 자체 FastAPI 등) 을
|
|
11
|
+
`cb_pk_*` 한 키로 호출하는 새 모듈. ConnectBase 는 모델·API·워크플로우를
|
|
12
|
+
알지 않고, 라벨 → tunnel 매핑만 들고 페이로드/응답을 그대로 통과시킵니다.
|
|
13
|
+
|
|
14
|
+
- **`cb.endpoint.call(label, init)`** — fetch() 시그니처 호환 (path, method,
|
|
15
|
+
headers, body, signal). URL 은 `${baseUrl}/v1/proxy/${label}${path}` 로
|
|
16
|
+
자동 조립, `X-Public-Key` 헤더 자동 주입. SSE / chunked 스트리밍은
|
|
17
|
+
`res.body.getReader()` 로 그대로 읽기.
|
|
18
|
+
- 신규 export: `EndpointAPI`, `EndpointCallInit`.
|
|
19
|
+
|
|
20
|
+
### Added — CLI
|
|
21
|
+
|
|
22
|
+
- **`connectbase tunnel <port> --label <name>`** — tunnel 발급 후 endpoint
|
|
23
|
+
binding 을 자동 등록. SDK 사용자가 즉시 `cb.endpoint.call("<label>", { ... })`
|
|
24
|
+
로 호출 가능. 인증은 User Secret Key (`cb_sk_*`) — dual-auth 라우트
|
|
25
|
+
`POST /v1/apps/:appID/endpoints/cli`.
|
|
26
|
+
- **`--description <text>`** — endpoint binding 의 설명 (`--label` 동반 시만).
|
|
27
|
+
- 이미 등록된 라벨이면 경고 후 진행 (다른 tunnel_id 로 갱신은 콘솔에서 PATCH).
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- JSDoc 안 중첩 블록주석으로 빌드가 깨지던 회귀 수정 (`*/` 가 주석을 조기
|
|
32
|
+
종료시키던 케이스).
|
|
33
|
+
|
|
34
|
+
## [3.0.1] - 2026-04-28
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `npx connectbase docs` 가 인증 없이 곧장 문서를 받도록 수정. 백엔드 `/v1/storages/webs/claude-md`
|
|
39
|
+
는 public 라우트인데 CLI 가 불필요하게 브라우저 인증 → 앱 선택 → Public Key 발급을 강제하던
|
|
40
|
+
흐름을 제거. 캐시된 publicKey 가 있으면 문서에 박아주고, 없으면 백엔드가 placeholder
|
|
41
|
+
(`YOUR_PUBLIC_KEY_HERE`) 로 대체해 그대로 다운로드.
|
|
42
|
+
|
|
6
43
|
## [3.0.0] - 2026-04-28 — BREAKING
|
|
7
44
|
|
|
8
45
|
게임 서버 mechanism-only 재설계. ConnectBase 가 박아두던 게임 룰 (파티/로비/랭킹/매치메이킹/
|
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ const state = await gameClient.createRoom({
|
|
|
79
79
|
- **WebRTC**: Real-time audio/video communication
|
|
80
80
|
- **Payments**: Subscription and one-time payment support
|
|
81
81
|
- **AI Streaming**: Real-time AI text generation via WebSocket (Gemini)
|
|
82
|
+
- **Endpoint**: Call your own GPU models on your own PC through one `cb_pk_*` key — ConnectBase forwards the payload as-is (dumb pipe)
|
|
82
83
|
- **CLI**: Command-line tool for deploying web storage and tunneling local services
|
|
83
84
|
|
|
84
85
|
## CLI
|
|
@@ -127,6 +128,8 @@ npx connectbase deploy ./dist -s <storage-id> -k <public-key>
|
|
|
127
128
|
| `--base-url <url>` | `-u` | Custom server URL |
|
|
128
129
|
| `--timeout <sec>` | `-t` | Tunnel request timeout in seconds (tunnel only) |
|
|
129
130
|
| `--max-body <MB>` | | Tunnel max body size in MB (tunnel only) |
|
|
131
|
+
| `--label <name>` | | Auto-register the issued tunnel as an endpoint binding (tunnel only). Requires Secret Key. SDK callers can then use `cb.endpoint.call(label, …)` |
|
|
132
|
+
| `--description <text>` | | Endpoint binding description (only valid with `--label`) |
|
|
130
133
|
| `--help` | `-h` | Show help |
|
|
131
134
|
| `--version` | `-v` | Show version |
|
|
132
135
|
|
|
@@ -164,6 +167,23 @@ Features:
|
|
|
164
167
|
- Graceful shutdown with Ctrl+C
|
|
165
168
|
- No external dependencies (uses Node.js built-in modules)
|
|
166
169
|
|
|
170
|
+
#### Auto-register an endpoint binding (`--label`)
|
|
171
|
+
|
|
172
|
+
For workflows where you want the SDK to call your local model by a stable name
|
|
173
|
+
(`cb.endpoint.call("comfyui-main", …)`) instead of a random tunnel URL, pass
|
|
174
|
+
`--label <name>`. The CLI registers the issued `tunnel_id` as an endpoint binding
|
|
175
|
+
on the server, so your SDK only needs the Public Key.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# Start ComfyUI on port 8188, expose it as endpoint label "comfyui-main"
|
|
179
|
+
npx connectbase tunnel 8188 --label comfyui-main --description "ComfyUI on my desktop"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Authentication uses your User Secret Key (`cb_sk_*`); the CLI calls the dual-auth
|
|
183
|
+
route `POST /v1/apps/:appID/endpoints/cli`. If the label already exists, the CLI
|
|
184
|
+
warns and keeps the tunnel running — update the binding to the new `tunnel_id`
|
|
185
|
+
from the console if needed.
|
|
186
|
+
|
|
167
187
|
### Configuration File
|
|
168
188
|
|
|
169
189
|
The `init` command creates `.connectbaserc` automatically. You can also create it manually:
|
|
@@ -791,6 +811,78 @@ await session.stop()
|
|
|
791
811
|
| `promptTokens` | `number` | Input prompt tokens |
|
|
792
812
|
| `duration` | `number` | Generation time in ms |
|
|
793
813
|
|
|
814
|
+
### Endpoint (Local Model Tunnel)
|
|
815
|
+
|
|
816
|
+
`cb.endpoint.*` is a dumb pipe to your own GPU/model server running behind a
|
|
817
|
+
ConnectBase tunnel. ConnectBase doesn't know your model, payload, or response
|
|
818
|
+
shape — it routes a `cb_pk_*` call by label to the registered tunnel and forwards
|
|
819
|
+
the body and headers as-is.
|
|
820
|
+
|
|
821
|
+
**Setup**: run `connectbase tunnel <port> --label <name>` once on the machine
|
|
822
|
+
hosting the model (see [Tunnel](#tunnel)) — that registers the binding. Then any
|
|
823
|
+
client with the app's Public Key can call it.
|
|
824
|
+
|
|
825
|
+
#### `cb.endpoint.call(label, init): Promise<Response>`
|
|
826
|
+
|
|
827
|
+
`fetch()`-compatible signature. Returns the raw `Response` — read the body as
|
|
828
|
+
JSON, text, or stream as needed.
|
|
829
|
+
|
|
830
|
+
```typescript
|
|
831
|
+
const cb = new ConnectBase({ publicKey: 'cb_pk_...' })
|
|
832
|
+
|
|
833
|
+
// ComfyUI prompt graph
|
|
834
|
+
const res = await cb.endpoint.call('comfyui-main', {
|
|
835
|
+
method: 'POST',
|
|
836
|
+
path: '/prompt',
|
|
837
|
+
headers: { 'Content-Type': 'application/json' },
|
|
838
|
+
body: JSON.stringify({ prompt: { /* ComfyUI node graph */ } }),
|
|
839
|
+
})
|
|
840
|
+
const data = await res.json()
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
```typescript
|
|
844
|
+
// Streaming response (SSE / chunked) — vLLM chat completions
|
|
845
|
+
const res = await cb.endpoint.call('vllm-local', {
|
|
846
|
+
method: 'POST',
|
|
847
|
+
path: '/v1/chat/completions',
|
|
848
|
+
headers: { 'Content-Type': 'application/json' },
|
|
849
|
+
body: JSON.stringify({ stream: true, messages: [/* { role, content } */] }),
|
|
850
|
+
})
|
|
851
|
+
if (!res.body) throw new Error('no stream')
|
|
852
|
+
const reader = res.body.getReader()
|
|
853
|
+
while (true) {
|
|
854
|
+
const { done, value } = await reader.read()
|
|
855
|
+
if (done) break
|
|
856
|
+
// value is a Uint8Array — decode and process chunk
|
|
857
|
+
}
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
```typescript
|
|
861
|
+
// Cancel an in-flight request
|
|
862
|
+
const ctrl = new AbortController()
|
|
863
|
+
setTimeout(() => ctrl.abort(), 30_000)
|
|
864
|
+
await cb.endpoint.call('hunyuan-laptop', {
|
|
865
|
+
method: 'POST',
|
|
866
|
+
path: '/generate',
|
|
867
|
+
signal: ctrl.signal,
|
|
868
|
+
body: JSON.stringify({ /* model input */ }),
|
|
869
|
+
})
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
**`EndpointCallInit`**
|
|
873
|
+
|
|
874
|
+
| Field | Type | Required | Description |
|
|
875
|
+
|-------|------|----------|-------------|
|
|
876
|
+
| `path` | `string` | yes | Path on your model server, must start with `/` (e.g. `/prompt`, `/v1/chat/completions`) |
|
|
877
|
+
| `method` | `string` | no (`GET`) | HTTP method |
|
|
878
|
+
| `headers` | `HeadersInit` | no | Extra request headers; `X-Public-Key` is auto-injected unless you set it |
|
|
879
|
+
| `body` | `BodyInit \| null` | no | Request body — `string`, `Blob`, `ArrayBuffer`, `FormData`, or `ReadableStream` |
|
|
880
|
+
| `signal` | `AbortSignal` | no | Abort signal for cancellation |
|
|
881
|
+
|
|
882
|
+
The SDK assembles the URL as `${baseUrl}/v1/proxy/${label}${path}` and forwards
|
|
883
|
+
the request. Because the response is the raw `fetch` `Response`, streaming
|
|
884
|
+
formats (SSE, chunked, NDJSON) work out of the box.
|
|
885
|
+
|
|
794
886
|
### Push Notifications
|
|
795
887
|
|
|
796
888
|
```typescript
|
package/dist/cli.js
CHANGED
|
@@ -952,70 +952,6 @@ function detectMonorepo(gitRoot) {
|
|
|
952
952
|
}
|
|
953
953
|
return result;
|
|
954
954
|
}
|
|
955
|
-
async function ensureDocsPublicKey(config) {
|
|
956
|
-
if (config.publicKey) return config.publicKey;
|
|
957
|
-
const rcPath = path2.join(process.cwd(), ".connectbaserc");
|
|
958
|
-
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
959
|
-
const readRc = () => {
|
|
960
|
-
if (!fs2.existsSync(rcPath)) return {};
|
|
961
|
-
try {
|
|
962
|
-
return JSON.parse(fs2.readFileSync(rcPath, "utf-8"));
|
|
963
|
-
} catch {
|
|
964
|
-
return {};
|
|
965
|
-
}
|
|
966
|
-
};
|
|
967
|
-
const writeRc = (data) => {
|
|
968
|
-
fs2.writeFileSync(rcPath, JSON.stringify(data, null, 2) + "\n");
|
|
969
|
-
addToGitignore(".connectbaserc");
|
|
970
|
-
};
|
|
971
|
-
let secretKey = config.secretKey;
|
|
972
|
-
if (!secretKey) {
|
|
973
|
-
info("Public Key \uBC1C\uAE09\uC744 \uC704\uD574 \uBE0C\uB77C\uC6B0\uC800 \uC778\uC99D\uC744 \uC2DC\uC791\uD569\uB2C8\uB2E4...");
|
|
974
|
-
secretKey = await browserAuthFlow();
|
|
975
|
-
const rc2 = readRc();
|
|
976
|
-
rc2.secretKey = secretKey;
|
|
977
|
-
writeRc(rc2);
|
|
978
|
-
config.secretKey = secretKey;
|
|
979
|
-
}
|
|
980
|
-
const savedAppId = readRc().tunnelAppId || "";
|
|
981
|
-
const resolved = await resolveApp(secretKey, baseUrl, savedAppId);
|
|
982
|
-
let publicKey = resolved.publicKey;
|
|
983
|
-
if (!publicKey) {
|
|
984
|
-
info("Public Key \uBC1C\uAE09 \uC911...");
|
|
985
|
-
const res = await makeRequest(
|
|
986
|
-
`${baseUrl}/v1/public/cli/apps/${resolved.appId}/public-keys`,
|
|
987
|
-
"POST",
|
|
988
|
-
{ "X-Public-Key": secretKey },
|
|
989
|
-
JSON.stringify({ name: "CLI Docs Key" })
|
|
990
|
-
);
|
|
991
|
-
if (res.status !== 201) {
|
|
992
|
-
const data2 = res.data;
|
|
993
|
-
const detail = data2?.error || data2?.message || `HTTP ${res.status}`;
|
|
994
|
-
error(`Public Key \uBC1C\uAE09 \uC2E4\uD328: ${detail}`);
|
|
995
|
-
process.exit(1);
|
|
996
|
-
}
|
|
997
|
-
const data = res.data;
|
|
998
|
-
if (!data.key) {
|
|
999
|
-
error("Public Key \uBC1C\uAE09 \uC751\uB2F5\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4");
|
|
1000
|
-
process.exit(1);
|
|
1001
|
-
}
|
|
1002
|
-
publicKey = data.key;
|
|
1003
|
-
success("Public Key \uBC1C\uAE09 \uC644\uB8CC");
|
|
1004
|
-
}
|
|
1005
|
-
const rc = readRc();
|
|
1006
|
-
let changed = false;
|
|
1007
|
-
if (rc.publicKey !== publicKey) {
|
|
1008
|
-
rc.publicKey = publicKey;
|
|
1009
|
-
changed = true;
|
|
1010
|
-
}
|
|
1011
|
-
if (rc.tunnelAppId !== resolved.appId) {
|
|
1012
|
-
rc.tunnelAppId = resolved.appId;
|
|
1013
|
-
changed = true;
|
|
1014
|
-
}
|
|
1015
|
-
if (changed) writeRc(rc);
|
|
1016
|
-
config.publicKey = publicKey;
|
|
1017
|
-
return publicKey;
|
|
1018
|
-
}
|
|
1019
955
|
async function downloadDocs(publicKey, templates, baseDir) {
|
|
1020
956
|
if (!baseDir) {
|
|
1021
957
|
baseDir = getProjectRoot();
|
|
@@ -1629,6 +1565,41 @@ ${colors.blue}?${colors.reset} \uC571 \uC120\uD0DD (\uBC88\uD638): `);
|
|
|
1629
1565
|
success(`\uC571 \uC0DD\uC131 \uC644\uB8CC: ${createData.app_name}`);
|
|
1630
1566
|
return { appId: createData.app_id, publicKey: createData.public_key };
|
|
1631
1567
|
}
|
|
1568
|
+
async function registerEndpointBinding(baseUrl, appId, secretKey, tunnelUrl, label, description) {
|
|
1569
|
+
try {
|
|
1570
|
+
const u = new URL(tunnelUrl);
|
|
1571
|
+
const host = u.hostname;
|
|
1572
|
+
const tunnelId = host.replace(/\.tunnel\.connectbase\.world$/, "");
|
|
1573
|
+
if (!tunnelId || tunnelId === host) {
|
|
1574
|
+
log(`${colors.yellow}\u26A0 tunnel_id \uCD94\uCD9C \uC2E4\uD328 (${tunnelUrl}) \u2014 endpoint \uC790\uB3D9 \uB4F1\uB85D skip${colors.reset}`);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const apiBase = baseUrl.replace(/\/+$/, "");
|
|
1578
|
+
const res = await fetch(`${apiBase}/v1/apps/${encodeURIComponent(appId)}/endpoints/cli`, {
|
|
1579
|
+
method: "POST",
|
|
1580
|
+
headers: {
|
|
1581
|
+
"Content-Type": "application/json",
|
|
1582
|
+
"Authorization": `Bearer ${secretKey}`
|
|
1583
|
+
},
|
|
1584
|
+
body: JSON.stringify({
|
|
1585
|
+
label,
|
|
1586
|
+
tunnel_id: tunnelId,
|
|
1587
|
+
description: description ?? `CLI tunnel start (${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)})`
|
|
1588
|
+
})
|
|
1589
|
+
});
|
|
1590
|
+
if (res.status === 201) {
|
|
1591
|
+
success(`Endpoint "${label}" \uC790\uB3D9 \uB4F1\uB85D \uC644\uB8CC`);
|
|
1592
|
+
log(`${colors.green}\u2192${colors.reset} SDK: ${colors.cyan}cb.endpoint.call("${label}", { path: "/...", method: "POST", body: ... })${colors.reset}`);
|
|
1593
|
+
} else if (res.status === 409) {
|
|
1594
|
+
log(`${colors.yellow}\u26A0 "${label}" \uB77C\uBCA8\uC774 \uC774\uBBF8 \uB4F1\uB85D\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.${colors.reset} \uC0C8 tunnel_id \uB85C \uAC31\uC2E0\uD558\uB824\uBA74 \uCF58\uC194\uC5D0\uC11C \uC218\uB3D9 PATCH.`);
|
|
1595
|
+
} else {
|
|
1596
|
+
const text = await res.text().catch(() => "");
|
|
1597
|
+
log(`${colors.yellow}\u26A0 endpoint \uB4F1\uB85D \uC2E4\uD328 (status ${res.status}): ${text}${colors.reset}`);
|
|
1598
|
+
}
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
log(`${colors.yellow}\u26A0 endpoint \uB4F1\uB85D \uC911 \uC5D0\uB7EC: ${err instanceof Error ? err.message : err}${colors.reset}`);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1632
1603
|
function acquireTunnelLock2(appID, port, force) {
|
|
1633
1604
|
const result = acquireTunnelLock(appID, port, force, VERSION);
|
|
1634
1605
|
if (result && result.startsWith("LOCKED:")) {
|
|
@@ -1860,6 +1831,16 @@ ${colors.dim}\uC678\uBD80 \uC9C1\uC811 \uD638\uCD9C \uC2DC \uB2E4\uC74C \uD5E4\u
|
|
|
1860
1831
|
log(`${colors.dim} $ curl -H "X-Proxy-Token: ${proxyToken}" ${tunnelUrl}/${colors.reset}`);
|
|
1861
1832
|
log(`${colors.dim} $ curl "${tunnelUrl}/?proxy_token=${proxyToken}"${colors.reset}`);
|
|
1862
1833
|
}
|
|
1834
|
+
if (tunnelOpts?.label && tunnelUrl) {
|
|
1835
|
+
void registerEndpointBinding(
|
|
1836
|
+
config.baseUrl,
|
|
1837
|
+
appId,
|
|
1838
|
+
tunnelKey,
|
|
1839
|
+
tunnelUrl,
|
|
1840
|
+
tunnelOpts.label,
|
|
1841
|
+
tunnelOpts.description
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1863
1844
|
log(`
|
|
1864
1845
|
${colors.dim}Ctrl+C\uB85C \uC885\uB8CC${colors.reset}
|
|
1865
1846
|
`);
|
|
@@ -2032,6 +2013,8 @@ ${colors.yellow}\uC635\uC158:${colors.reset}
|
|
|
2032
2013
|
--force \uD130\uB110 lockfile \uBB34\uC2DC (\uC911\uBCF5 \uC2E4\uD589 \uAC15\uC81C, tunnel \uC804\uC6A9)
|
|
2033
2014
|
--public proxy_token \uAC80\uC99D \uBE44\uD65C\uC131\uD654 \u2014 \uC6F9\uD6C5/\uC678\uBD80 \uC9C1\uC811 \uD638\uCD9C\uC6A9 (tunnel \uC804\uC6A9)
|
|
2034
2015
|
--show-token proxy_token \uACFC curl \uC608\uC2DC\uB97C \uCD9C\uB825 (--public \uC544\uB2CC \uACBD\uC6B0)
|
|
2016
|
+
--label <name> Endpoint binding \uC790\uB3D9 \uB4F1\uB85D (tunnel \uC804\uC6A9) \u2014 SDK \uC758 cb.endpoint.call(label) \uD638\uCD9C \uAC00\uB2A5
|
|
2017
|
+
--description <text> Endpoint binding \uC124\uBA85 (--label \uB3D9\uBC18 \uC2DC\uB9CC)
|
|
2035
2018
|
-d, --dev Dev \uD658\uACBD\uC5D0 \uBC30\uD3EC (deploy \uC804\uC6A9)
|
|
2036
2019
|
--check \uBC84\uC804\uB9CC \uD655\uC778 (update \uC804\uC6A9)
|
|
2037
2020
|
--skip-docs \uBB38\uC11C \uC5C5\uB370\uC774\uD2B8 \uAC74\uB108\uB6F0\uAE30 (update \uC804\uC6A9)
|
|
@@ -2165,8 +2148,7 @@ async function main() {
|
|
|
2165
2148
|
setupRoot: parsed.options.setupRoot === "true"
|
|
2166
2149
|
});
|
|
2167
2150
|
} else if (parsed.command === "docs") {
|
|
2168
|
-
|
|
2169
|
-
await downloadDocs(docsPublicKey);
|
|
2151
|
+
await downloadDocs(config.publicKey || "");
|
|
2170
2152
|
} else if (parsed.command === "mcp") {
|
|
2171
2153
|
await setupMcp();
|
|
2172
2154
|
} else if (parsed.command === "deploy") {
|
|
@@ -2213,6 +2195,12 @@ async function main() {
|
|
|
2213
2195
|
if (parsed.options.showToken === "true") {
|
|
2214
2196
|
tunnelOpts.showToken = true;
|
|
2215
2197
|
}
|
|
2198
|
+
if (parsed.options.label) {
|
|
2199
|
+
tunnelOpts.label = parsed.options.label;
|
|
2200
|
+
}
|
|
2201
|
+
if (parsed.options.description) {
|
|
2202
|
+
tunnelOpts.description = parsed.options.description;
|
|
2203
|
+
}
|
|
2216
2204
|
await startTunnel(port, config, tunnelOpts);
|
|
2217
2205
|
} else {
|
|
2218
2206
|
error(`\uC54C \uC218 \uC5C6\uB294 \uBA85\uB839\uC5B4: ${parsed.command}`);
|