create-entity-server 0.0.15 → 0.0.23

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 (42) hide show
  1. package/bin/create.js +15 -7
  2. package/package.json +1 -1
  3. package/template/.env.example +8 -7
  4. package/template/configs/database.json +173 -10
  5. package/template/entities/Account/account_audit.json +4 -5
  6. package/template/entities/System/system_audit_log.json +14 -8
  7. package/template/samples/README.md +28 -22
  8. package/template/samples/browser/entity-server-client.js +453 -0
  9. package/template/samples/browser/example.html +498 -0
  10. package/template/samples/entities/02_types_and_defaults.json +15 -16
  11. package/template/samples/entities/04_fk_and_composite_unique.json +0 -2
  12. package/template/samples/entities/05_cache.json +9 -8
  13. package/template/samples/entities/06_history_and_hard_delete.json +27 -9
  14. package/template/samples/entities/07_license_scope.json +40 -31
  15. package/template/samples/entities/09_hook_entity.json +0 -6
  16. package/template/samples/entities/10_hook_submit_delete.json +5 -2
  17. package/template/samples/entities/11_hook_webhook.json +9 -7
  18. package/template/samples/entities/12_hook_push.json +3 -3
  19. package/template/samples/entities/13_read_only.json +13 -10
  20. package/template/samples/entities/15_reset_defaults.json +0 -1
  21. package/template/samples/entities/16_isolated_license.json +62 -0
  22. package/template/samples/entities/README.md +36 -39
  23. package/template/samples/flutter/lib/entity_server_client.dart +170 -48
  24. package/template/samples/java/EntityServerClient.java +208 -61
  25. package/template/samples/java/EntityServerExample.java +4 -3
  26. package/template/samples/kotlin/EntityServerClient.kt +175 -45
  27. package/template/samples/node/src/EntityServerClient.js +232 -59
  28. package/template/samples/node/src/example.js +9 -9
  29. package/template/samples/php/ci4/Config/EntityServer.php +0 -1
  30. package/template/samples/php/ci4/Libraries/EntityServer.php +206 -53
  31. package/template/samples/php/laravel/Services/EntityServerService.php +190 -41
  32. package/template/samples/python/entity_server.py +181 -68
  33. package/template/samples/python/example.py +7 -6
  34. package/template/samples/react/src/example.tsx +41 -25
  35. package/template/samples/swift/EntityServerClient.swift +143 -37
  36. package/template/scripts/run.ps1 +12 -3
  37. package/template/scripts/run.sh +12 -8
  38. package/template/scripts/update-server.ps1 +68 -2
  39. package/template/scripts/update-server.sh +59 -2
  40. package/template/samples/entities/order_notification.json +0 -51
  41. package/template/samples/react/src/api/entityServerClient.ts +0 -413
  42. package/template/samples/react/src/hooks/useEntity.ts +0 -173
@@ -15,7 +15,7 @@ es = EntityServerClient()
15
15
 
16
16
  # 목록 조회
17
17
  result = es.list("product", page=1, limit=10)
18
- print(f"List: {len(result.get('data', []))} items")
18
+ print(f"List: {result.get('data', {}).get('total', 0)} items")
19
19
 
20
20
  # 생성
21
21
  created = es.submit("product", {
@@ -34,16 +34,17 @@ print(f"Get: {item['data']['name']}")
34
34
  es.submit("product", {"seq": seq, "price": 39000})
35
35
  print("Updated")
36
36
 
37
- # 필터 검색
37
+ # 커스텀 SQL 검색
38
38
  results = es.query("product",
39
- filter=[{"field": "category", "op": "eq", "value": "peripherals"}],
40
- page=1, limit=5,
39
+ sql="SELECT seq, name, category FROM product WHERE category = ?",
40
+ params=["peripherals"],
41
+ limit=5,
41
42
  )
42
- print(f"Query: {len(results.get('data', []))} results")
43
+ print(f"Query: {len(results.get('data', {}).get('items', []))} results")
43
44
 
44
45
  # 이력 조회
45
46
  hist = es.history("product", seq)
46
- print(f"History: {len(hist.get('data', []))} entries")
47
+ print(f"History: {hist.get('data', {}).get('total', 0)} entries")
47
48
 
48
49
  # 삭제
49
50
  es.delete("product", seq)
@@ -4,16 +4,15 @@
4
4
  * 설정:
5
5
  * 1. .env 에 VITE_ENTITY_SERVER_URL=http://localhost:47200 추가
6
6
  * 2. 로그인 시 entityServer.login(email, password) 호출
7
- * 3. 이후 훅으로 데이터 조회/수정
7
+ * 3. 이후 client 메서드로 데이터 조회/수정
8
8
  */
9
9
 
10
- import { useState } from "react";
11
- import {
12
- useEntityDelete,
13
- useEntityGet,
14
- useEntityList,
15
- useEntitySubmit,
16
- } from "./hooks/useEntity";
10
+ // @ts-ignore
11
+ import { useEffect, useState } from "react";
12
+ // @ts-ignore
13
+ import { useEntityServer } from "entity-server-client/react";
14
+ // @ts-ignore
15
+ import type { EntityListResult } from "entity-server-client";
17
16
 
18
17
  interface Product {
19
18
  seq: number;
@@ -25,27 +24,35 @@ interface Product {
25
24
  // ─── 목록 컴포넌트 ─────────────────────────────────────────────────────────
26
25
 
27
26
  export function ProductList() {
27
+ const { client, isPending, error, del } = useEntityServer();
28
28
  const [page, setPage] = useState(1);
29
- const { data, isLoading, error } = useEntityList<Product>("product", {
30
- page,
31
- limit: 20,
32
- });
33
- const deleteMut = useEntityDelete("product");
29
+ const [result, setResult] = useState<EntityListResult<Product> | null>(
30
+ null,
31
+ );
32
+ const [loading, setLoading] = useState(false);
33
+
34
+ useEffect(() => {
35
+ setLoading(true);
36
+ client
37
+ .list<Product>("product", { page, limit: 20 })
38
+ .then((r) => setResult(r.data))
39
+ .finally(() => setLoading(false));
40
+ }, [client, page]);
34
41
 
35
- if (isLoading) return <p>로딩 중...</p>;
36
- if (error) return <p>에러: {(error as Error).message}</p>;
42
+ if (loading) return <p>로딩 중...</p>;
43
+ if (error) return <p>에러: {error.message}</p>;
37
44
 
38
45
  return (
39
46
  <div>
40
- <h2>상품 목록 ({data?.total ?? 0}건)</h2>
47
+ <h2>상품 목록 ({result?.total ?? 0}건)</h2>
41
48
  <ul>
42
- {data?.data.map((item) => (
49
+ {result?.items.map((item) => (
43
50
  <li key={item.seq}>
44
51
  [{item.seq}] {item.name} — {item.price.toLocaleString()}
45
52
 
46
53
  <button
47
- onClick={() => deleteMut.mutate(item.seq)}
48
- disabled={deleteMut.isPending}
54
+ onClick={() => del("product", item.seq)}
55
+ disabled={isPending}
49
56
  >
50
57
  삭제
51
58
  </button>
@@ -67,10 +74,19 @@ export function ProductList() {
67
74
  // ─── 단건 조회 컴포넌트 ─────────────────────────────────────────────────────
68
75
 
69
76
  export function ProductDetail({ seq }: { seq: number }) {
70
- const { data, isLoading } = useEntityGet<Product>("product", seq);
77
+ const { client } = useEntityServer();
78
+ const [item, setItem] = useState<Product | null>(null);
79
+ const [loading, setLoading] = useState(true);
80
+
81
+ useEffect(() => {
82
+ client
83
+ .get<Product>("product", seq)
84
+ .then((r) => setItem(r.data))
85
+ .catch(() => setItem(null))
86
+ .finally(() => setLoading(false));
87
+ }, [client, seq]);
71
88
 
72
- if (isLoading) return <p>로딩 중...</p>;
73
- const item = data?.data;
89
+ if (loading) return <p>로딩 중...</p>;
74
90
  if (!item) return <p>상품을 찾을 수 없습니다.</p>;
75
91
 
76
92
  return (
@@ -85,13 +101,13 @@ export function ProductDetail({ seq }: { seq: number }) {
85
101
  // ─── 생성/수정 폼 컴포넌트 ──────────────────────────────────────────────────
86
102
 
87
103
  export function ProductForm({ seq }: { seq?: number }) {
88
- const submitMut = useEntitySubmit("product");
104
+ const { submit, isPending } = useEntityServer();
89
105
  const [form, setForm] = useState({ name: "", price: 0, category: "" });
90
106
 
91
107
  const handleSubmit = async (e: React.FormEvent) => {
92
108
  e.preventDefault();
93
109
  // seq 있으면 수정, 없으면 생성
94
- await submitMut.mutateAsync(seq ? { ...form, seq } : form);
110
+ await submit("product", seq ? { ...form, seq } : form);
95
111
  alert(seq ? "수정 완료" : "등록 완료");
96
112
  };
97
113
 
@@ -119,7 +135,7 @@ export function ProductForm({ seq }: { seq?: number }) {
119
135
  setForm((f) => ({ ...f, category: e.target.value }))
120
136
  }
121
137
  />
122
- <button type="submit" disabled={submitMut.isPending}>
138
+ <button type="submit" disabled={isPending}>
123
139
  {seq ? "수정" : "등록"}
124
140
  </button>
125
141
  </form>
@@ -8,8 +8,7 @@
8
8
  * let client = EntityServerClient(
9
9
  * baseUrl: "http://your-server:47200",
10
10
  * apiKey: "your-api-key",
11
- * hmacSecret: "your-hmac-secret",
12
- * magicLen: 4 // 서버 packet_magic_len 과 동일
11
+ * hmacSecret: "your-hmac-secret"
13
12
  * )
14
13
  * let result = try await client.list("product")
15
14
  *
@@ -33,7 +32,8 @@ public final class EntityServerClient {
33
32
  private let baseURL: URL
34
33
  private let apiKey: String
35
34
  private let hmacSecret: String
36
- private let magicLen: Int
35
+ private var token: String
36
+ private let encryptRequests: Bool
37
37
  private let session: URLSession
38
38
  private var activeTxId: String? = nil
39
39
 
@@ -41,33 +41,77 @@ public final class EntityServerClient {
41
41
  baseUrl: String = "http://localhost:47200",
42
42
  apiKey: String = "",
43
43
  hmacSecret: String = "",
44
- magicLen: Int = 4,
44
+ token: String = "",
45
+ /// true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다.
46
+ encryptRequests: Bool = false,
45
47
  session: URLSession = .shared
46
48
  ) {
47
49
  self.baseURL = URL(string: baseUrl.removingSuffix("/"))!
48
50
  self.apiKey = apiKey
49
51
  self.hmacSecret = hmacSecret
50
- self.magicLen = magicLen
52
+ self.token = token
53
+ self.encryptRequests = encryptRequests
51
54
  self.session = session
52
55
  }
53
56
 
57
+ /** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
58
+ public func setToken(_ newToken: String) { token = newToken }
59
+
54
60
  // ─── CRUD ─────────────────────────────────────────────────────────
55
61
 
56
- public func get(entity: String, seq: Int64) async throws -> [String: Any] {
57
- try await request(method: "GET", path: "/v1/entity/\(entity)/\(seq)")
62
+ public func get(entity: String, seq: Int64, skipHooks: Bool = false) async throws -> [String: Any] {
63
+ let q = skipHooks ? "?skipHooks=true" : ""
64
+ return try await request(method: "GET", path: "/v1/entity/\(entity)/\(seq)\(q)")
65
+ }
66
+
67
+ /// 조건으로 단건 조회 (POST + conditions body)
68
+ ///
69
+ /// `conditions` 는 index/hash/unique 필드에만 사용 가능합니다.
70
+ /// 조건에 맞는 행이 없으면 404 오류가 발생합니다.
71
+ public func find(entity: String, conditions: [String: Any], skipHooks: Bool = false) async throws -> [String: Any] {
72
+ let q = skipHooks ? "?skipHooks=true" : ""
73
+ let body = try JSONSerialization.data(withJSONObject: conditions)
74
+ return try await request(method: "POST", path: "/v1/entity/\(entity)/find\(q)", body: body)
58
75
  }
59
76
 
60
- public func list(entity: String, page: Int = 1, limit: Int = 20) async throws -> [String: Any] {
61
- try await request(method: "GET", path: "/v1/entity/\(entity)/list?page=\(page)&limit=\(limit)")
77
+ /// 목록 조회 (POST + conditions body)
78
+ ///
79
+ /// `fields`를 미지정하면 기본적으로 인덱스 필드만 반환합니다 (가장 빠름).
80
+ /// 전체 필드 반환이 필요하면 `fields: ["*"]` 를 지정하세요.
81
+ /// `conditions` 는 index/hash/unique 필드에만 사용 가능합니다.
82
+ public func list(
83
+ entity: String,
84
+ page: Int = 1,
85
+ limit: Int = 20,
86
+ orderBy: String? = nil,
87
+ fields: [String]? = nil,
88
+ conditions: [String: Any]? = nil
89
+ ) async throws -> [String: Any] {
90
+ var qParts = ["page=\(page)", "limit=\(limit)"]
91
+ if let orderBy = orderBy { qParts.append("order_by=\(orderBy)") }
92
+ if let fields = fields, !fields.isEmpty { qParts.append("fields=\(fields.joined(separator: ","))") }
93
+ let body = try JSONSerialization.data(withJSONObject: conditions ?? [:])
94
+ return try await request(method: "POST", path: "/v1/entity/\(entity)/list?\(qParts.joined(separator: "&"))", body: body)
62
95
  }
63
96
 
64
- public func count(entity: String) async throws -> [String: Any] {
65
- try await request(method: "GET", path: "/v1/entity/\(entity)/count")
97
+ /// 건수 조회
98
+ public func count(entity: String, conditions: [String: Any]? = nil) async throws -> [String: Any] {
99
+ let body = try JSONSerialization.data(withJSONObject: conditions ?? [:])
100
+ return try await request(method: "POST", path: "/v1/entity/\(entity)/count", body: body)
66
101
  }
67
102
 
68
- public func query(entity: String, filter: [[String: Any]], page: Int = 1, limit: Int = 20) async throws -> [String: Any] {
69
- let body = try JSONSerialization.data(withJSONObject: filter)
70
- return try await request(method: "POST", path: "/v1/entity/\(entity)/query?page=\(page)&limit=\(limit)", body: body)
103
+ /// 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
104
+ /// 사용자 입력은 반드시 `params` 로 바인딩하세요 (SQL Injection 방지).
105
+ public func query(
106
+ entity: String,
107
+ sql: String,
108
+ params: [Any]? = nil,
109
+ limit: Int? = nil
110
+ ) async throws -> [String: Any] {
111
+ var bodyDict: [String: Any] = ["sql": sql, "params": params ?? []]
112
+ if let limit = limit { bodyDict["limit"] = limit }
113
+ let body = try JSONSerialization.data(withJSONObject: bodyDict)
114
+ return try await request(method: "POST", path: "/v1/entity/\(entity)/query", body: body)
71
115
  }
72
116
 
73
117
  /// 트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
@@ -104,18 +148,23 @@ public final class EntityServerClient {
104
148
  return try await request(method: "POST", path: "/v1/transaction/commit/\(txId)")
105
149
  }
106
150
 
107
- public func submit(entity: String, data: [String: Any], transactionId: String? = nil) async throws -> [String: Any] {
151
+ public func submit(entity: String, data: [String: Any], transactionId: String? = nil, skipHooks: Bool = false) async throws -> [String: Any] {
108
152
  let body = try JSONSerialization.data(withJSONObject: data)
109
153
  var extra: [String: String] = [:]
110
154
  if let txId = transactionId ?? activeTxId { extra["X-Transaction-ID"] = txId }
111
- return try await request(method: "POST", path: "/v1/entity/\(entity)/submit", body: body, extraHeaders: extra)
155
+ let q = skipHooks ? "?skipHooks=true" : ""
156
+ return try await request(method: "POST", path: "/v1/entity/\(entity)/submit\(q)", body: body, extraHeaders: extra)
112
157
  }
113
158
 
114
- public func delete(entity: String, seq: Int64, transactionId: String? = nil, hard: Bool = false) async throws -> [String: Any] {
115
- let q = hard ? "?hard=true" : ""
159
+ /// 삭제. 서버는 POST /delete/:seq 로만 처리합니다.
160
+ public func delete(entity: String, seq: Int64, transactionId: String? = nil, hard: Bool = false, skipHooks: Bool = false) async throws -> [String: Any] {
161
+ var qParts: [String] = []
162
+ if hard { qParts.append("hard=true") }
163
+ if skipHooks { qParts.append("skipHooks=true") }
164
+ let q = qParts.isEmpty ? "" : "?" + qParts.joined(separator: "&")
116
165
  var extra: [String: String] = [:]
117
166
  if let txId = transactionId ?? activeTxId { extra["X-Transaction-ID"] = txId }
118
- return try await request(method: "DELETE", path: "/v1/entity/\(entity)/delete/\(seq)\(q)", extraHeaders: extra)
167
+ return try await request(method: "POST", path: "/v1/entity/\(entity)/delete/\(seq)\(q)", extraHeaders: extra)
119
168
  }
120
169
 
121
170
  public func history(entity: String, seq: Int64, page: Int = 1, limit: Int = 50) async throws -> [String: Any] {
@@ -238,25 +287,38 @@ public final class EntityServerClient {
238
287
  var req = URLRequest(url: url)
239
288
  req.httpMethod = method
240
289
 
241
- let bodyStr = body.map { String(data: $0, encoding: .utf8) ?? "" } ?? ""
242
- let timestamp = String(Int(Date().timeIntervalSince1970))
243
- let nonce = UUID().uuidString
244
- let signature = try sign(method: method, path: path, timestamp: timestamp, nonce: nonce, body: bodyStr)
290
+ // 요청 바디 결정: encryptRequests POST 바디를 암호화합니다.
291
+ var requestBody = body
292
+ var requestContentType = "application/json"
293
+ if let body = body, encryptRequests || packetEncryption {
294
+ requestBody = try encryptPacket(body)
295
+ requestContentType = "application/octet-stream"
296
+ }
245
297
 
246
- req.setValue("application/json", forHTTPHeaderField: "Content-Type")
247
- req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
248
- req.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
249
- req.setValue(nonce, forHTTPHeaderField: "X-Nonce")
250
- req.setValue(signature, forHTTPHeaderField: "X-Signature")
298
+ let bodyForSign = requestBody ?? Data()
299
+ let isHmacMode = !apiKey.isEmpty && !hmacSecret.isEmpty
300
+
301
+ req.setValue(requestContentType, forHTTPHeaderField: "Content-Type")
302
+ if isHmacMode {
303
+ let timestamp = String(Int(Date().timeIntervalSince1970))
304
+ let nonce = UUID().uuidString
305
+ let signature = try sign(method: method, path: path, timestamp: timestamp, nonce: nonce, bodyData: bodyForSign)
306
+ req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
307
+ req.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
308
+ req.setValue(nonce, forHTTPHeaderField: "X-Nonce")
309
+ req.setValue(signature, forHTTPHeaderField: "X-Signature")
310
+ } else if !token.isEmpty {
311
+ req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
312
+ }
251
313
  extraHeaders.forEach { req.setValue($0.value, forHTTPHeaderField: $0.key) }
252
- req.httpBody = body
314
+ req.httpBody = requestBody
253
315
 
254
316
  let (data, response) = try await session.data(for: req)
255
317
  let http = response as! HTTPURLResponse
256
- let contentType = http.value(forHTTPHeaderField: "Content-Type") ?? ""
318
+ let respContentType = http.value(forHTTPHeaderField: "Content-Type") ?? ""
257
319
 
258
320
  // 패킷 암호화 응답: application/octet-stream → 복호화
259
- if contentType.contains("application/octet-stream") {
321
+ if respContentType.contains("application/octet-stream") {
260
322
  return try decryptPacket(data)
261
323
  }
262
324
 
@@ -266,14 +328,50 @@ public final class EntityServerClient {
266
328
  return json
267
329
  }
268
330
 
331
+ /**
332
+ * 패킷 암호화 키를 유도합니다.
333
+ * - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
334
+ * - JWT 모드: SHA256(token)
335
+ */
336
+ private func derivePacketKey() throws -> [UInt8] {
337
+ if !token.isEmpty && hmacSecret.isEmpty {
338
+ return Digest.sha256(Array(token.utf8))
339
+ }
340
+ return try HKDF(
341
+ password: Array(hmacSecret.utf8),
342
+ salt: Array("entity-server:hkdf:v1".utf8),
343
+ info: Array("entity-server:packet-encryption".utf8),
344
+ keyLength: 32,
345
+ variant: .sha256
346
+ ).calculate()
347
+ }
348
+
349
+ /**
350
+ * XChaCha20-Poly1305 패킷 암호화
351
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
352
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
353
+ * magicLen: 2 + key[31] % 14
354
+ */
355
+ private func encryptPacket(_ data: Data) throws -> Data {
356
+ let key = try derivePacketKey()
357
+ var generator = SystemRandomNumberGenerator()
358
+ let magicLen = 2 + Int(key[31]) % 14
359
+ let magic = (0..<magicLen).map { _ in generator.next() as UInt8 }
360
+ let nonce = (0..<24).map { _ in generator.next() as UInt8 }
361
+ let xchacha = XChaCha20Poly1305(key: key, iv: nonce, aad: [])
362
+ let ciphertext = try xchacha.encrypt(Array(data))
363
+ return Data(magic + nonce + ciphertext)
364
+ }
365
+
269
366
  /**
270
367
  * XChaCha20-Poly1305 패킷 복호화
271
368
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
272
- * 키: sha256(hmac_secret)
369
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
273
370
  */
274
371
  private func decryptPacket(_ data: Data) throws -> [String: Any] {
275
- let key = Array(SHA2(variant: .sha256).calculate(for: Array(hmacSecret.utf8)))
372
+ let key = try derivePacketKey()
276
373
  let bytes = Array(data)
374
+ let magicLen = 2 + Int(key[31]) % 14
277
375
  let nonce = Array(bytes[magicLen..<(magicLen + 24)])
278
376
  let ciphertext = Array(bytes[(magicLen + 24)...])
279
377
 
@@ -287,10 +385,11 @@ public final class EntityServerClient {
287
385
  return json
288
386
  }
289
387
 
290
- /** HMAC-SHA256 서명 */
291
- private func sign(method: String, path: String, timestamp: String, nonce: String, body: String) throws -> String {
292
- let payload = [method, path, timestamp, nonce, body].joined(separator: "|")
293
- let mac = try HMAC(key: Array(hmacSecret.utf8), variant: .sha256).authenticate(Array(payload.utf8))
388
+ /** HMAC-SHA256 서명. bodyData 는 JSON 또는 암호화된 바이너리 모두 지원합니다. */
389
+ private func sign(method: String, path: String, timestamp: String, nonce: String, bodyData: Data) throws -> String {
390
+ var payload = "\(method)|\(path)|\(timestamp)|\(nonce)|".data(using: .utf8)!
391
+ payload.append(bodyData)
392
+ let mac = try HMAC(key: Array(hmacSecret.utf8), variant: .sha256).authenticate(Array(payload))
294
393
  return mac.map { String(format: "%02x", $0) }.joined()
295
394
  }
296
395
  }
@@ -301,6 +400,13 @@ private struct XChaCha20Poly1305 {
301
400
  let iv: [UInt8]
302
401
  let aad: [UInt8]
303
402
 
403
+ func encrypt(_ plaintext: [UInt8]) throws -> [UInt8] {
404
+ let chacha = try ChaCha20(key: key, iv: iv)
405
+ let ct = try chacha.encrypt(plaintext)
406
+ let poly = try Poly1305(key: chacha.keystream().prefix(32)).authenticate(ct + aad)
407
+ return ct + poly
408
+ }
409
+
304
410
  func decrypt(_ ciphertext: [UInt8]) throws -> [UInt8] {
305
411
  // CryptoSwift ChaCha20.Poly1305 AEAD - tag는 마지막 16바이트
306
412
  let tag = Array(ciphertext.suffix(16))
@@ -83,6 +83,10 @@ function Stop-Server {
83
83
 
84
84
  function Show-Status {
85
85
  $ServerBin = Join-Path $ProjectRoot "bin\entity-server.exe"
86
+ if (-not (Test-Path $ServerBin)) {
87
+ $LegacyBin = Join-Path $ProjectRoot "entity-server.exe"
88
+ if (Test-Path $LegacyBin) { $ServerBin = $LegacyBin }
89
+ }
86
90
  if (Is-Running) {
87
91
  & $ServerBin banner-status RUNNING
88
92
  if ($Language -eq "en") { Write-Host "Stop: .\run.ps1 stop" }
@@ -149,9 +153,14 @@ if (-not (Test-Path $DatabaseConfig)) {
149
153
  }
150
154
  $ServerBin = Join-Path $ProjectRoot "bin\entity-server.exe"
151
155
  if (-not (Test-Path $ServerBin)) {
152
- if ($Language -eq "en") { Write-Host "X bin/entity-server.exe not found" }
153
- else { Write-Host "X bin/entity-server.exe 파일이 없습니다" }
154
- exit 1
156
+ $LegacyBin = Join-Path $ProjectRoot "entity-server.exe"
157
+ if (Test-Path $LegacyBin) {
158
+ $ServerBin = $LegacyBin
159
+ } else {
160
+ if ($Language -eq "en") { Write-Host "X entity-server.exe not found (bin/entity-server.exe or .\entity-server.exe)" }
161
+ else { Write-Host "X entity-server.exe 파일이 없습니다 (bin/entity-server.exe 또는 .\entity-server.exe)" }
162
+ exit 1
163
+ }
155
164
  }
156
165
 
157
166
  function Update-JsonField {
@@ -11,6 +11,10 @@ DATABASE_CONFIG="$PROJECT_ROOT/configs/database.json"
11
11
  RUN_DIR="$PROJECT_ROOT/.run"
12
12
  PID_FILE="$RUN_DIR/entity-server.pid"
13
13
  STDOUT_LOG="$PROJECT_ROOT/logs/server.out.log"
14
+ SERVER_BIN="$PROJECT_ROOT/bin/entity-server"
15
+ if [ ! -f "$SERVER_BIN" ] && [ -f "$PROJECT_ROOT/entity-server" ]; then
16
+ SERVER_BIN="$PROJECT_ROOT/entity-server"
17
+ fi
14
18
 
15
19
  mkdir -p "$RUN_DIR" "$PROJECT_ROOT/logs"
16
20
 
@@ -203,7 +207,7 @@ show_status() {
203
207
  if is_running; then
204
208
  local status_pid
205
209
  status_pid=$(find_pid_by_port)
206
- ./bin/entity-server banner-status RUNNING
210
+ "$SERVER_BIN" banner-status RUNNING
207
211
  if [ "$LANGUAGE" = "en" ]; then
208
212
  if [ -n "$status_pid" ]; then
209
213
  echo "PID: $status_pid (detected by port)"
@@ -216,7 +220,7 @@ show_status() {
216
220
  echo "중지: ./run.sh stop"
217
221
  fi
218
222
  else
219
- ./bin/entity-server banner-status STOPPED
223
+ "$SERVER_BIN" banner-status STOPPED
220
224
  if [ "$LANGUAGE" = "en" ]; then
221
225
  echo "Start: ./run.sh start"
222
226
  else
@@ -289,11 +293,11 @@ if [ ! -f "$DATABASE_CONFIG" ]; then
289
293
  exit 1
290
294
  fi
291
295
 
292
- if [ ! -f bin/entity-server ]; then
296
+ if [ ! -f "$SERVER_BIN" ]; then
293
297
  if [ "$LANGUAGE" = "en" ]; then
294
- echo "❌ bin/entity-server not found"
298
+ echo "❌ entity-server binary not found (bin/entity-server or ./entity-server)"
295
299
  else
296
- echo "❌ bin/entity-server 파일이 없습니다"
300
+ echo "❌ entity-server 바이너리 파일이 없습니다 (bin/entity-server 또는 ./entity-server)"
297
301
  fi
298
302
  exit 1
299
303
  fi
@@ -320,7 +324,7 @@ case "$MODE" in
320
324
 
321
325
  sed -E -i 's/("environment"[[:space:]]*:[[:space:]]*")[^"]+(")/\1development\2/' "$SERVER_CONFIG"
322
326
  sed -E -i 's/("default"[[:space:]]*:[[:space:]]*")[^"]+(")/\1development\2/' "$DATABASE_CONFIG"
323
- ./bin/entity-server
327
+ "$SERVER_BIN"
324
328
  ;;
325
329
 
326
330
  start)
@@ -345,8 +349,8 @@ case "$MODE" in
345
349
  sed -E -i 's/("environment"[[:space:]]*:[[:space:]]*")[^"]+(")/\1production\2/' "$SERVER_CONFIG"
346
350
  sed -E -i 's/("default"[[:space:]]*:[[:space:]]*")[^"]+(")/\1production\2/' "$DATABASE_CONFIG"
347
351
 
348
- ./bin/entity-server banner
349
- nohup ./bin/entity-server >> "$STDOUT_LOG" 2>&1 &
352
+ "$SERVER_BIN" banner
353
+ nohup "$SERVER_BIN" >> "$STDOUT_LOG" 2>&1 &
350
354
  SERVER_PID=$!
351
355
  echo "$SERVER_PID" > "$PID_FILE"
352
356
 
@@ -21,10 +21,70 @@ $PLATFORM = "windows"
21
21
  $ARCH_TAG = "x64"
22
22
  $ProjectRoot = Split-Path -Parent $PSScriptRoot
23
23
 
24
+ function Get-RunningServerPid {
25
+ $PidFile = Join-Path $ProjectRoot ".run\entity-server.pid"
26
+ if (Test-Path $PidFile) {
27
+ $pidValue = (Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()
28
+ if ($pidValue -match '^\d+$') {
29
+ try {
30
+ $p = Get-Process -Id ([int]$pidValue) -ErrorAction Stop
31
+ if ($p) { return [int]$pidValue }
32
+ } catch {}
33
+ }
34
+ }
35
+
36
+ $proc = Get-Process -Name "entity-server" -ErrorAction SilentlyContinue | Select-Object -First 1
37
+ if ($proc) { return [int]$proc.Id }
38
+ return $null
39
+ }
40
+
41
+ function Ensure-ServerStopped {
42
+ $pidValue = Get-RunningServerPid
43
+ if (-not $pidValue) { return }
44
+
45
+ Write-Host ""
46
+ Write-Host "⚠️ 현재 Entity Server가 실행 중입니다."
47
+ try {
48
+ $p = Get-Process -Id $pidValue -ErrorAction Stop
49
+ Write-Host ("PID: {0} Name: {1} Start: {2}" -f $p.Id, $p.ProcessName, $p.StartTime)
50
+ } catch {}
51
+ Write-Host ""
52
+
53
+ $answer = Read-Host "업데이트를 위해 서버를 중지할까요? [y/N]"
54
+ if ($answer -notmatch '^[Yy](es)?$') {
55
+ Write-Host "❌ 업데이트를 취소했습니다."
56
+ exit 1
57
+ }
58
+
59
+ $RunScript = Join-Path $ProjectRoot "scripts\run.ps1"
60
+ if (Test-Path $RunScript) {
61
+ try {
62
+ & $RunScript stop
63
+ } catch {}
64
+ } else {
65
+ try {
66
+ Stop-Process -Id $pidValue -Force -ErrorAction Stop
67
+ } catch {}
68
+ }
69
+
70
+ Start-Sleep -Milliseconds 200
71
+ $still = Get-RunningServerPid
72
+ if ($still) {
73
+ Write-Host "❌ 서버 중지에 실패했습니다. 업데이트를 중단합니다."
74
+ exit 1
75
+ }
76
+
77
+ Write-Host "✅ 서버 중지 완료"
78
+ }
79
+
24
80
  # ── 현재 버전 확인 ────────────────────────────────────────────────────────────
25
81
 
26
82
  function Get-CurrentVer {
27
- $BinPath = Join-Path $ProjectRoot "entity-server.exe"
83
+ $BinPath = Join-Path $ProjectRoot "bin\entity-server.exe"
84
+ if (-not (Test-Path $BinPath)) {
85
+ $LegacyBin = Join-Path $ProjectRoot "entity-server.exe"
86
+ if (Test-Path $LegacyBin) { $BinPath = $LegacyBin }
87
+ }
28
88
  if (Test-Path $BinPath) {
29
89
  try {
30
90
  $out = & $BinPath --version 2>$null
@@ -52,6 +112,8 @@ function Install-Version([string]$TargetVer) {
52
112
  $TargetVer = $TargetVer -replace '^v', ''
53
113
  $CurrentVer = Get-CurrentVer
54
114
 
115
+ Ensure-ServerStopped
116
+
55
117
  Write-Host ""
56
118
  Write-Host "📦 entity-server v$TargetVer 다운로드 중... ($PLATFORM-$ARCH_TAG)"
57
119
  Write-Host ""
@@ -59,7 +121,11 @@ function Install-Version([string]$TargetVer) {
59
121
  foreach ($Bin in $BINARIES) {
60
122
  $FileName = "$Bin-$PLATFORM-$ARCH_TAG.exe"
61
123
  $Url = "https://github.com/$REPO/releases/download/v$TargetVer/$FileName"
62
- $Dest = Join-Path $ProjectRoot "$Bin.exe"
124
+ $BinDir = Join-Path $ProjectRoot "bin"
125
+ if (-not (Test-Path $BinDir)) {
126
+ New-Item -ItemType Directory -Path $BinDir | Out-Null
127
+ }
128
+ $Dest = Join-Path $BinDir "$Bin.exe"
63
129
  $Tmp = "$Dest.tmp"
64
130
 
65
131
  Write-Host (" ↓ {0,-35}" -f $FileName) -NoNewline