@simplysm/sd-claude 14.0.87 → 14.0.89
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/claude/references/sd-simplysm14/README.md +17 -18
- package/claude/references/sd-simplysm14/apis/angular/README.md +35 -0
- package/claude/references/sd-simplysm14/apis/angular/controls.md +51 -0
- package/claude/references/sd-simplysm14/apis/angular/crud.md +53 -0
- package/claude/references/sd-simplysm14/apis/angular/directives.md +34 -0
- package/claude/references/sd-simplysm14/apis/angular/features.md +40 -0
- package/claude/references/sd-simplysm14/apis/angular/infra.md +74 -0
- package/claude/references/sd-simplysm14/apis/angular/layout.md +27 -0
- package/claude/references/sd-simplysm14/apis/angular/overlay.md +103 -0
- package/claude/references/sd-simplysm14/apis/angular/routing-appstructure.md +69 -0
- package/claude/references/sd-simplysm14/apis/angular/selection-managers.md +28 -0
- package/claude/references/sd-simplysm14/apis/angular/shared-data.md +57 -0
- package/claude/references/sd-simplysm14/apis/angular/sheet.md +73 -0
- package/claude/references/sd-simplysm14/apis/capacitor-plugin-auto-update/README.md +78 -0
- package/claude/references/sd-simplysm14/apis/capacitor-plugin-file-system/README.md +66 -0
- package/claude/references/sd-simplysm14/apis/capacitor-plugin-intent/README.md +71 -0
- package/claude/references/sd-simplysm14/apis/capacitor-plugin-usb-storage/README.md +62 -0
- package/claude/references/sd-simplysm14/apis/core-browser/README.md +70 -0
- package/claude/references/sd-simplysm14/apis/core-browser/dom-element.md +62 -0
- package/claude/references/sd-simplysm14/apis/core-browser/indexed-db.md +80 -0
- package/claude/references/sd-simplysm14/apis/core-common/README.md +262 -0
- package/claude/references/sd-simplysm14/apis/core-common/array-ext.md +121 -0
- package/claude/references/sd-simplysm14/apis/core-common/async-runtime.md +128 -0
- package/claude/references/sd-simplysm14/apis/core-common/datetime.md +129 -0
- package/claude/references/sd-simplysm14/apis/core-common/errors.md +91 -0
- package/claude/references/sd-simplysm14/apis/core-common/json-transfer.md +53 -0
- package/claude/references/sd-simplysm14/apis/core-common/obj.md +117 -0
- package/claude/references/sd-simplysm14/apis/core-node/README.md +17 -0
- package/claude/references/sd-simplysm14/apis/core-node/consola.md +43 -0
- package/claude/references/sd-simplysm14/apis/core-node/cpx.md +50 -0
- package/claude/references/sd-simplysm14/apis/core-node/fs-watcher.md +41 -0
- package/claude/references/sd-simplysm14/apis/core-node/fsx.md +72 -0
- package/claude/references/sd-simplysm14/apis/core-node/pathx.md +39 -0
- package/claude/references/sd-simplysm14/apis/core-node/worker.md +52 -0
- package/claude/references/sd-simplysm14/apis/excel/README.md +43 -0
- package/claude/references/sd-simplysm14/apis/excel/cell.md +54 -0
- package/claude/references/sd-simplysm14/apis/excel/conditional-format.md +51 -0
- package/claude/references/sd-simplysm14/apis/excel/style.md +67 -0
- package/claude/references/sd-simplysm14/apis/excel/utils.md +35 -0
- package/claude/references/sd-simplysm14/apis/excel/workbook-worksheet.md +97 -0
- package/claude/references/sd-simplysm14/apis/excel/wrapper.md +83 -0
- package/claude/references/sd-simplysm14/apis/lint/README.md +49 -0
- package/claude/references/sd-simplysm14/apis/lint/rules.md +130 -0
- package/claude/references/sd-simplysm14/apis/orm-common/README.md +13 -0
- package/claude/references/sd-simplysm14/apis/orm-common/db-context.md +111 -0
- package/claude/references/sd-simplysm14/apis/orm-common/expr.md +128 -0
- package/claude/references/sd-simplysm14/apis/orm-common/queryable.md +145 -0
- package/claude/references/sd-simplysm14/apis/orm-common/schema.md +147 -0
- package/claude/references/sd-simplysm14/apis/orm-common/types.md +62 -0
- package/claude/references/sd-simplysm14/apis/orm-node/README.md +90 -0
- package/claude/references/sd-simplysm14/apis/orm-node/db-conn.md +94 -0
- package/claude/references/sd-simplysm14/apis/sd-cli/README.md +26 -0
- package/claude/references/sd-simplysm14/apis/sd-cli/SdTsCompiler.md +117 -0
- package/claude/references/sd-simplysm14/apis/sd-cli/sd-config-types.md +291 -0
- package/claude/references/sd-simplysm14/apis/service-client/README.md +150 -0
- package/claude/references/sd-simplysm14/apis/service-client/orm.md +48 -0
- package/claude/references/sd-simplysm14/apis/service-client/transport.md +59 -0
- package/claude/references/sd-simplysm14/apis/service-common/README.md +84 -0
- package/claude/references/sd-simplysm14/apis/service-common/app-structure.md +48 -0
- package/claude/references/sd-simplysm14/apis/service-common/protocol.md +72 -0
- package/claude/references/sd-simplysm14/apis/service-server/README.md +118 -0
- package/claude/references/sd-simplysm14/apis/service-server/service-authoring.md +71 -0
- package/claude/references/sd-simplysm14/apis/service-server/transport-internals.md +62 -0
- package/claude/references/sd-simplysm14/apis/service-server/v1-legacy.md +39 -0
- package/claude/references/sd-simplysm14/apis/storage/README.md +120 -0
- package/claude/skills/sd-demo/SKILL.md +6 -0
- package/claude/skills/sd-impl/SKILL.md +4 -7
- package/claude/skills/sd-spec/SKILL.md +31 -858
- package/claude/skills/sd-spec/references/spec-authoring.md +519 -0
- package/claude/workflows/sd-docs.js +84 -0
- package/claude/{skills/sd-docs/references/subagent-prompt.md → workflows/sd-docs.rules.md} +25 -40
- package/package.json +1 -1
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/.specs/inventory/spec.md +0 -99
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/package.json +0 -12
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/index.ts +0 -3
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/screens/inbound/inbound.list.ts +0 -150
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/screens/inventory/inventory-master.list.ts +0 -143
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/screens/outbound/outbound.list.ts +0 -150
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/pnpm-workspace.yaml +0 -2
- package/claude/skills/sd-demo/evals/fixtures/inventory-list/sd.config.ts +0 -12
- package/claude/skills/sd-demo/evals/golden.jsonl +0 -1
- package/claude/skills/sd-dev/evals/fixtures/minimal-ts-pkg/package.json +0 -8
- package/claude/skills/sd-dev/evals/fixtures/minimal-ts-pkg/src/.gitkeep +0 -0
- package/claude/skills/sd-dev/evals/fixtures/minimal-ts-pkg/tests/.gitkeep +0 -0
- package/claude/skills/sd-dev/evals/fixtures/minimal-ts-pkg/tsconfig.json +0 -10
- package/claude/skills/sd-dev/evals/golden.jsonl +0 -1
- package/claude/skills/sd-docs/SKILL.md +0 -58
- package/claude/skills/sd-docs/evals/fixtures/new-write/.claude/references/sd-simplysm14/README.md +0 -7
- package/claude/skills/sd-docs/evals/fixtures/new-write/packages/bar/package.json +0 -5
- package/claude/skills/sd-docs/evals/fixtures/new-write/packages/bar/src/index.ts +0 -3
- package/claude/skills/sd-docs/evals/fixtures/new-write/packages/baz/package.json +0 -6
- package/claude/skills/sd-docs/evals/fixtures/new-write/packages/baz/src/index.ts +0 -1
- package/claude/skills/sd-docs/evals/fixtures/new-write/packages/foo/package.json +0 -5
- package/claude/skills/sd-docs/evals/fixtures/new-write/packages/foo/src/index.ts +0 -8
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/.claude/references/sd-simplysm14/README.md +0 -7
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/.claude/references/sd-simplysm14/apis/foo/README.md +0 -3
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/packages/bar/package.json +0 -5
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/packages/bar/src/index.ts +0 -3
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/packages/baz/package.json +0 -6
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/packages/baz/src/index.ts +0 -1
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/packages/foo/package.json +0 -5
- package/claude/skills/sd-docs/evals/fixtures/update-mixed/packages/foo/src/index.ts +0 -8
- package/claude/skills/sd-docs/evals/golden.jsonl +0 -2
- package/claude/skills/sd-impl/evals/fixtures/case-a-new-screen/.specs/260513120000_warehouse/spec.md +0 -101
- package/claude/skills/sd-impl/evals/fixtures/case-b-update-with-demo/.specs/260513120000_warehouse/spec.md +0 -101
- package/claude/skills/sd-impl/evals/fixtures/case-b-update-with-demo/packages/app/src/screens/box-register/box-register.view.ts +0 -46
- package/claude/skills/sd-impl/evals/fixtures/case-c-new-cross/.specs/260513120000_warehouse/spec.md +0 -89
- package/claude/skills/sd-impl/evals/fixtures/case-d-spec-modify/.specs/260513120000_warehouse/spec.md +0 -101
- package/claude/skills/sd-impl/evals/golden.jsonl +0 -4
- package/claude/skills/sd-manual/evals/fixtures/new-manual/src/notification.ts +0 -25
- package/claude/skills/sd-manual/evals/fixtures/update-manual/.claude/references/sd-simplysm14/manuals/notification.md +0 -14
- package/claude/skills/sd-manual/evals/fixtures/update-manual/src/notification.ts +0 -37
- package/claude/skills/sd-manual/evals/golden.jsonl +0 -2
- package/claude/skills/sd-review/evals/fixtures/code-review/src/foo.ts +0 -7
- package/claude/skills/sd-review/evals/fixtures/doc-review/docs/foo.md +0 -4
- package/claude/skills/sd-review/evals/golden.jsonl +0 -2
- package/claude/skills/sd-skill/evals/fixtures/existing-skill/.claude/skills/todo-format/SKILL.md +0 -14
- package/claude/skills/sd-skill/evals/fixtures/new-skill/.gitkeep +0 -0
- package/claude/skills/sd-skill/evals/golden.jsonl +0 -2
- package/claude/skills/sd-spec/evals/fixtures/case-a-split//355/232/214/354/235/230/353/241/235.md +0 -20
- package/claude/skills/sd-spec/evals/fixtures/case-b-detail/.specs/260513120000_warehouse/spec.md +0 -95
- package/claude/skills/sd-spec/evals/golden.jsonl +0 -2
- package/claude/skills/sd-unpack/evals/fixtures/eml-with-text-attachment/meeting.eml +0 -21
- package/claude/skills/sd-unpack/evals/fixtures/simple-eml/meeting.eml +0 -10
- package/claude/skills/sd-unpack/evals/golden.jsonl +0 -2
- package/claude/skills/sd-use/evals/fixtures/empty/.gitkeep +0 -0
- package/claude/skills/sd-use/evals/golden.jsonl +0 -6
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @simplysm/service-common — app-structure
|
|
2
|
+
|
|
3
|
+
앱의 메뉴·권한 트리 정의 타입과, 사용자의 활성 모듈(`usableModules`) 기준으로 권한을 평탄화/필터링하는 유틸. 트리 노드는 `modules`(OR)·`requiredModules`(AND) 로 가시성을 제어한다. `TModule` 제네릭은 모듈 식별자 타입(앱별 enum/string).
|
|
4
|
+
|
|
5
|
+
## 트리 타입
|
|
6
|
+
|
|
7
|
+
`AppStructureItem<TModule>` = `AppStructureGroupItem<TModule>` | `AppStructureLeafItem<TModule>`. 메뉴 트리의 노드(`children` 유무로 그룹/리프 판별).
|
|
8
|
+
|
|
9
|
+
`AppStructureGroupItem<TModule>` — 하위 노드를 갖는 그룹 노드.
|
|
10
|
+
- `code: string` — 노드 식별 코드. 권한 codeChain 에 누적됨.
|
|
11
|
+
- `title: string` — 표시 제목. titleChain 에 누적됨.
|
|
12
|
+
- `modules?: TModule[]` — 이 중 하나라도 활성이면 통과(OR). 빈 배열/`undefined` 면 제약 없음.
|
|
13
|
+
- `requiredModules?: TModule[]` — 전부 활성이어야 통과(AND).
|
|
14
|
+
- `icon?: string` — 메뉴 아이콘.
|
|
15
|
+
- `children: AppStructureItem<TModule>[]` — 하위 노드 배열(필수, 그룹 판별 키).
|
|
16
|
+
|
|
17
|
+
`AppStructureLeafItem<TModule>` — 실제 화면 노드.
|
|
18
|
+
- `code: string` / `title: string` / `modules?` / `requiredModules?` / `icon?` — 그룹과 동일 의미.
|
|
19
|
+
- `perms?: ("use" | "edit")[]` — 이 화면 직접 권한. `"use"`=조회 권한 / `"edit"`=편집 권한. 각 항목이 평탄 권한 1건이 됨.
|
|
20
|
+
- `subPerms?: AppStructureSubPermission<TModule>[]` — 화면 내 세부 권한 묶음.
|
|
21
|
+
- `url?: string` — 라우팅 경로.
|
|
22
|
+
- `isNotMenu?: boolean` — true 면 메뉴에 노출 안 함(권한만 존재하는 화면), false/미지정이면 메뉴 노출.
|
|
23
|
+
|
|
24
|
+
`AppStructureSubPermission<TModule>` — 화면 하위 세부 권한 묶음.
|
|
25
|
+
- `code: string` / `title: string` / `modules?` / `requiredModules?` — 동일 의미. subPerm 자체의 modules/requiredModules 도 별도 검사됨.
|
|
26
|
+
- `perms: ("use" | "edit")[]` — 이 세부 묶음의 권한 종류(필수). `"use"`=조회 / `"edit"`=편집.
|
|
27
|
+
|
|
28
|
+
`FlatPermission<TModule>` — 평탄화 결과 1건.
|
|
29
|
+
- `titleChain: string[]` — 루트→해당 권한까지 제목 경로.
|
|
30
|
+
- `codeChain: string[]` — code + perm/subPerm 코드 누적 경로(권한 식별자).
|
|
31
|
+
- `modulesChain: TModule[][]` — 경로상 각 레벨 modules 누적.
|
|
32
|
+
|
|
33
|
+
## 유틸 함수
|
|
34
|
+
|
|
35
|
+
- `isUsableModules(modules, requiredModules, usableModules): boolean` — 단일 노드 가시성 판정. `requiredModules` 전부 포함(AND) **그리고** `modules` 중 하나 포함(또는 빈 배열/`undefined` 면 통과, OR). 둘 중 하나만 검사하려면 나머지 인자에 `undefined` 전달.
|
|
36
|
+
- `isUsableModulesChain(modulesChain, requiredModulesChain, usableModules): boolean` — 루트부터 누적된 체인 전체 통과 여부. 각 레벨 modules 는 OR, 각 레벨 requiredModules 는 AND 로 모두 만족해야 true.
|
|
37
|
+
- `getFlatPermissions(items, usableModules): FlatPermission<TModule>[]` — 트리를 BFS 순회하며 `usableModules` 로 필터된 모든 권한을 평탄 목록으로 산출. 모듈 체인을 통과한 노드의 `perms`·`subPerms.perms` 각각을 `FlatPermission` 1건으로 변환. subPerm 은 자체 modules/requiredModules 도 추가 검사.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const flats = getFlatPermissions(appStructure, currentUser.usableModules);
|
|
41
|
+
const codes = flats.map((f) => f.codeChain.join(".")); // 예: "order.list.edit"
|
|
42
|
+
if (isUsableModules(item.modules, item.requiredModules, usableModules)) showMenu(item);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
주의:
|
|
46
|
+
- `usableModules` 가 `undefined` 이면 modules/requiredModules 가 지정된 노드는 통과 못 함(`includes` 가 false).
|
|
47
|
+
- `modules` 가 비었거나 `undefined` 인 노드는 모듈 제약 없이 항상 통과(OR 기본).
|
|
48
|
+
- `codeChain` 마지막 요소는 perm(`"use"`/`"edit"`), 또는 subPerm.code 뒤의 perm 으로 끝남.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @simplysm/service-common — protocol
|
|
2
|
+
|
|
3
|
+
서버·클라이언트 간 서비스 메시지의 바이너리 인코딩/디코딩과 청크 재조립을 담당하는 프로토콜(V2). 헤더 28바이트(UUID 16 + TotalSize 8 + Index 4) + JSON 본문 구조이며, 3MB 초과 시 300KB 청크로 자동 분할, 단일 메시지 최대 100MB.
|
|
4
|
+
|
|
5
|
+
## createServiceProtocol / ServiceProtocol
|
|
6
|
+
|
|
7
|
+
`createServiceProtocol(): ServiceProtocol` — stateful 청크 누적기(`LazyGcMap`)를 내장한 프로토콜 인스턴스 생성. 누적기는 GC 타이머를 가지므로 사용 종료 시 `dispose()` 필수.
|
|
8
|
+
|
|
9
|
+
`ServiceProtocol` 메서드:
|
|
10
|
+
|
|
11
|
+
- `encode(uuid: string, message: ServiceMessage): { chunks: Bytes[]; totalSize: number }` — 메시지를 `[name, body]` JSON→바이트로 직렬화 후 28바이트 헤더 부착. `SPLIT_MESSAGE_SIZE`(3MB) 이하면 단일 청크, 초과면 `CHUNK_SIZE`(300KB) 단위로 분할해 여러 청크. `MAX_TOTAL_SIZE`(100MB) 초과 시 `ArgumentError` throw. `uuid`=메시지 묶음 식별자(재조립 키).
|
|
12
|
+
- `accumulate(bytes: Bytes): ServiceAccumulateResult` — 수신 청크 1개를 uuid별 누적기에 모음(stateful, 재조립 전용). 같은 index 중복 패킷은 무시. JSON 파싱은 하지 않음. 미완성이면 `progress`, 전 청크 도착 시 raw 바이트 담은 `complete` 반환. 헤더 미만(<28B)·크기 초과·무결성 위반(completedSize > totalSize) 시 `ArgumentError` throw.
|
|
13
|
+
- `parseMessage(resultBytes: Bytes): ServiceMessage` — 재조립된 raw 바이트를 메시지 객체로 파싱(stateless). 누적 상태에 비의존이라 worker 등 다른 실행 컨텍스트에 위임 가능. 파싱 실패 시 `ArgumentError` throw.
|
|
14
|
+
- `decode<T extends ServiceMessage>(bytes: Bytes): ServiceMessageDecodeResult<T>` — `accumulate` 후 완료 시 `parseMessage` 까지 수행하는 통합 동작. 가장 일반적인 수신 처리 경로.
|
|
15
|
+
- `dispose(): void` — 내부 누적기 GC 타이머 해제·메모리 반환. 인스턴스 폐기 전 반드시 호출.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const proto = createServiceProtocol();
|
|
19
|
+
try {
|
|
20
|
+
const { chunks } = proto.encode(uuid, { name: "auth", body: token });
|
|
21
|
+
for (const c of chunks) send(c);
|
|
22
|
+
const r = proto.decode(recvBytes);
|
|
23
|
+
if (r.type === "complete") handle(r.message);
|
|
24
|
+
} finally { proto.dispose(); }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`ServiceMessageDecodeResult<TMessage>` (유니언, `type` 판별):
|
|
28
|
+
- `{ type: "complete"; uuid; message: TMessage }` — 전 청크 수신, 메시지 재조립·파싱 완료.
|
|
29
|
+
- `{ type: "progress"; uuid; totalSize; completedSize }` — 일부 청크만 도착. 진행률 표시용.
|
|
30
|
+
|
|
31
|
+
`ServiceAccumulateResult` (유니언, `type` 판별):
|
|
32
|
+
- `{ type: "complete"; uuid; resultBytes: Bytes }` — 재조립 완료, 파싱 전 raw 바이트.
|
|
33
|
+
- `{ type: "progress"; uuid; totalSize; completedSize }` — 진행 중.
|
|
34
|
+
|
|
35
|
+
주의:
|
|
36
|
+
- `dispose()` 누락 시 GC 타이머가 남아 메모리/타이머 누수.
|
|
37
|
+
- `EXPIRE_TIME`(60초) 내 모든 청크가 도착하지 않으면 미완성 누적분이 GC 로 폐기됨.
|
|
38
|
+
- `parseMessage` 입력은 반드시 `accumulate`/`decode` 의 `complete` 가 준 raw 바이트여야 함.
|
|
39
|
+
|
|
40
|
+
## PROTOCOL_CONFIG
|
|
41
|
+
|
|
42
|
+
`as const` 상수. 인코딩 분할·크기 제한·GC 동작 기준값.
|
|
43
|
+
|
|
44
|
+
- `MAX_TOTAL_SIZE: 100MB` — 단일 메시지 허용 최대 크기. 초과 시 `encode`/`accumulate` throw.
|
|
45
|
+
- `SPLIT_MESSAGE_SIZE: 3MB` — 이 값 초과 시 청킹 시작(이하면 단일 청크).
|
|
46
|
+
- `CHUNK_SIZE: 300KB` — 분할 청크 1개의 본문 크기.
|
|
47
|
+
- `GC_INTERVAL: 10초` — 미완성 누적기 정리 주기.
|
|
48
|
+
- `EXPIRE_TIME: 60초` — 미완성 메시지 만료 시간(이후 GC 대상).
|
|
49
|
+
|
|
50
|
+
## 메시지 타입
|
|
51
|
+
|
|
52
|
+
방향별 유니언과 개별 메시지 인터페이스. `name` literal 로 판별하는 discriminated union.
|
|
53
|
+
|
|
54
|
+
분류 유니언:
|
|
55
|
+
- `ServiceMessage` — 전체 메시지 집합.
|
|
56
|
+
- `ServiceClientMessage` — 클라이언트→서버: request/auth/evt:add/evt:remove/evt:gets/evt:emit.
|
|
57
|
+
- `ServiceServerMessage` — 서버→클라이언트: response/error/evt:on.
|
|
58
|
+
- `ServiceServerRawMessage` — `ServiceServerMessage` + progress(청크 수신 진행 알림 포함).
|
|
59
|
+
|
|
60
|
+
개별 메시지(`name` literal → 용도):
|
|
61
|
+
- `ServiceProgressMessage` `"progress"` — 서버가 청크 수신 진행 알림. `body: { totalSize, completedSize }`(바이트).
|
|
62
|
+
- `ServiceErrorMessage` `"error"` — 서버 에러 알림. `body: { name, message, code, stack?, detail?, cause? }`.
|
|
63
|
+
- `ServiceAuthMessage` `"auth"` — 클라이언트 인증. `body: string`(토큰).
|
|
64
|
+
- `ServiceRequestMessage` `` `${string}.${string}` `` — 클라이언트 서비스 메서드 호출(`service.method`). `body: unknown[]`(매개변수 배열).
|
|
65
|
+
- `ServiceResponseMessage` `"response"` — 서버 응답. `body?: unknown`(결과, 없을 수 있어 optional).
|
|
66
|
+
- `ServiceAddEventListenerMessage` `"evt:add"` — 리스너 등록. `body: { key, name, info }` — `key`=리스너 키(uuid, 제거에 필요), `name`=이벤트 이름, `info`=발생 시 필터링용 정보.
|
|
67
|
+
- `ServiceRemoveEventListenerMessage` `"evt:remove"` — 리스너 제거. `body: { key }`(리스너 키).
|
|
68
|
+
- `ServiceGetEventListenerInfosMessage` `"evt:gets"` — 특정 이벤트 리스너 info 목록 요청. `body: { name }`(이벤트 이름).
|
|
69
|
+
- `ServiceEmitEventMessage` `"evt:emit"` — 클라이언트가 이벤트 발생 요청. `body: { keys, data }` — `keys`=대상 리스너 키 목록, `data`=데이터.
|
|
70
|
+
- `ServiceEventMessage` `"evt:on"` — 서버가 구독자에게 이벤트 전달. `body: { keys, data }`(리스너 키 목록·데이터).
|
|
71
|
+
|
|
72
|
+
주의: `name` literal 로 분기해야 타입 좁히기가 동작. body 의 `unknown`/`unknown[]` 은 호출부에서 서비스 시그니처에 맞춰 캐스팅.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @simplysm/service-server
|
|
2
|
+
|
|
3
|
+
Fastify 기반 서비스 서버. WebSocket/HTTP 두 전송 계층으로 RPC 스타일 서비스 메서드를 노출하고, JWT 인증·정적 파일·업로드·이벤트 브로드캐스팅·내장 ORM/자동업데이트 서비스를 제공한다.
|
|
4
|
+
|
|
5
|
+
## 사용 트리거 인덱스
|
|
6
|
+
|
|
7
|
+
- **ServiceServer / createServiceServer / ServiceServerOptions** — 서버 인스턴스를 만들고 listen/close 할 때, 포트·SSL·auth·서비스 목록을 설정할 때. (아래 "서버 인스턴스" 인라인)
|
|
8
|
+
- **이벤트 브로드캐스트 (getEvent / emitEvent / ServerEventProxy)** — 서버에서 WebSocket 클라이언트들에게 이벤트를 푸시할 때. (아래 "이벤트 브로드캐스트" 인라인)
|
|
9
|
+
- **JWT 인증 (signAuthToken/verifyAuthToken, signJwt/verifyJwt/decodeJwt, AuthTokenPayload)** — 로그인 토큰을 발급·검증할 때. (아래 "JWT 인증" 인라인)
|
|
10
|
+
- **서비스 정의 (defineService / auth / ServiceContext / ServiceDefinition / ServiceMethods)** — 서버에 노출할 RPC 서비스를 작성하고 인증·권한을 거는 작업 컨텍스트. 자세히: [service-authoring.md](./service-authoring.md)
|
|
11
|
+
- **내장 서비스 (OrmService / AutoUpdateService)** — DB 원격 실행·앱 자동업데이트를 services 목록에 바로 꽂을 때. (아래 "내장 서비스" 인라인)
|
|
12
|
+
- **전송 계층 내부 (WebSocketHandler / ServiceSocket / HTTP·업로드·정적 핸들러 / 프로토콜 래퍼 / ConfigManager)** — 서버 내부 동작을 이해하거나 커스텀 통합할 때. 자세히: [transport-internals.md](./transport-internals.md)
|
|
13
|
+
- **V1 레거시 자동업데이트 (handleV1Connection 등)** — 구버전(ver≠2) 클라이언트를 지원해야 할 때. 자세히: [v1-legacy.md](./v1-legacy.md)
|
|
14
|
+
|
|
15
|
+
## 서버 인스턴스
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{ ready: void; close: void }>
|
|
19
|
+
function createServiceServer<TAuthInfo = unknown>(options: ServiceServerOptions): ServiceServer<TAuthInfo>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`createServiceServer` 는 `new ServiceServer` 의 얇은 래퍼. `TAuthInfo` 는 인증 토큰 `data` 필드의 타입(`ctx.authInfo` 와 토큰 발급/검증에 전파됨).
|
|
23
|
+
|
|
24
|
+
`ServiceServerOptions`:
|
|
25
|
+
|
|
26
|
+
- `rootPath: string` — 서버 루트 디렉토리. 정적 파일은 `<rootPath>/www`, 업로드는 `<rootPath>/www/uploads`, 설정은 `<rootPath>/.config.json` 및 `<rootPath>/www/<clientName>/.config.json` 에서 읽음.
|
|
27
|
+
- `port: number` — 리슨 포트. host 는 항상 `0.0.0.0`. `0` 이면 OS 가 임의 포트 배정(테스트용).
|
|
28
|
+
- `ssl?: { pfxBytes: Uint8Array; passphrase: string }` — HTTPS 인증서. 지정 시 HTTPS 구동 + HSTS·crossOriginOpenerPolicy 활성, 미지정 시 HTTP 구동 + `upgrade-insecure-requests` CSP 해제. PFX 형식 인증서만 지원.
|
|
29
|
+
- `auth?: { jwtSecret: string } | false` — 인증 모드. `{ jwtSecret }` = JWT 검증 활성, `false` = auth 요구 서비스가 있어도 인증 검사 스킵(의도적 비활성화), 미지정(undefined) = auth 요구 서비스가 하나라도 있으면 `listen()` 시 throw.
|
|
30
|
+
- `services: ServiceDefinition[]` — 노출할 서비스 목록. `defineService` 결과를 나열.
|
|
31
|
+
- `legacyV1Handlers?: V1RequestHandler[]` — V1 레거시 클라이언트용 커스텀 요청 핸들러. 자세히: [v1-legacy.md](./v1-legacy.md).
|
|
32
|
+
|
|
33
|
+
메서드:
|
|
34
|
+
|
|
35
|
+
- `listen(): Promise<void>` — Fastify 플러그인(websocket/helmet/multipart/static/cors) 등록 후 리슨 시작. auth 미설정인데 auth 요구 서비스가 있으면 throw. SIGINT/SIGTERM graceful shutdown 핸들러 등록(10초 내 미종료 시 강제 종료). 완료 시 `isOpen=true` + `ready` 이벤트 발생.
|
|
36
|
+
- `close(): Promise<void>` — 모든 WebSocket 연결 종료 + Fastify 종료. `isOpen=false` + `close` 이벤트 발생.
|
|
37
|
+
- `isOpen: boolean` — 현재 리슨 중 여부.
|
|
38
|
+
- `fastify: FastifyInstance` — 내부 Fastify 인스턴스(예: `fastify.server.address()` 로 실제 포트 조회).
|
|
39
|
+
- `options: ServiceServerOptions` — 생성 시 전달한 옵션(읽기 전용 참조).
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const server = createServiceServer<MyAuthInfo>({
|
|
43
|
+
rootPath: import.meta.dirname,
|
|
44
|
+
port: 50080,
|
|
45
|
+
auth: { jwtSecret: "secret" },
|
|
46
|
+
services: [MyService, OrmService, AutoUpdateService],
|
|
47
|
+
});
|
|
48
|
+
await server.listen();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 이벤트 브로드캐스트
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
interface ServerEventProxy<TEventDef extends ServiceEventDef> {
|
|
55
|
+
emit(infoSelector: (item: TEventDef["$info"]) => boolean, data: TEventDef["$data"]): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
server.getEvent<TEventDef>(eventDef: TEventDef): ServerEventProxy<TEventDef>
|
|
58
|
+
server.emitEvent<TEventDef>(eventDef, infoSelector, data): Promise<void>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`ServiceEventDef` 는 `@simplysm/service-common` 의 이벤트 정의 타입(`eventName`/`$info`/`$data` 보유). 클라이언트는 이벤트 리스너 등록 시 `info` 를 같이 보내고, 서버는 등록된 모든 소켓의 리스너 중 `infoSelector` 가 true 인 대상에게만 `data` 를 푸시한다.
|
|
62
|
+
|
|
63
|
+
- `infoSelector: (item) => boolean` — 수신 대상 필터. 등록된 각 리스너의 `info` 를 받아 전송 여부를 결정. 특정 조건(예: 같은 화면을 보는 클라이언트)에만 보낼 때 사용.
|
|
64
|
+
- `getEvent` 는 `emit` 만 노출하는 프록시를 반환(내부적으로 `emitEvent` 호출) — 같은 eventDef 로 여러 번 emit 할 때 편함.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const evt = server.getEvent(MyDataChangedEvent);
|
|
68
|
+
await evt.emit((info) => info.boardId === 3, { updatedAt: new Date() });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## JWT 인증
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
interface AuthTokenPayload<TAuthInfo = unknown> extends JWTPayload {
|
|
75
|
+
roles: string[];
|
|
76
|
+
data: TAuthInfo;
|
|
77
|
+
}
|
|
78
|
+
server.signAuthToken(payload: AuthTokenPayload<TAuthInfo>): Promise<string>
|
|
79
|
+
server.verifyAuthToken(token: string): Promise<AuthTokenPayload<TAuthInfo>>
|
|
80
|
+
|
|
81
|
+
function signJwt<T>(jwtSecret: string, payload: AuthTokenPayload<T>): Promise<string>
|
|
82
|
+
function verifyJwt<T>(jwtSecret: string, token: string): Promise<AuthTokenPayload<T>>
|
|
83
|
+
function decodeJwt<T>(token: string): AuthTokenPayload<T>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `AuthTokenPayload.roles: string[]` — 보유 역할 목록. `auth(["admin"], ...)` 권한 검사 시 이 배열에 해당 권한이 포함되는지 확인.
|
|
87
|
+
- `AuthTokenPayload.data: TAuthInfo` — 임의 사용자 정보. 서비스 메서드에서 `ctx.authInfo` 로 읽힘.
|
|
88
|
+
- `signAuthToken`/`verifyAuthToken` — 서버 옵션의 `jwtSecret` 을 자동 사용하는 인스턴스 메서드. jwtSecret 미설정 시 throw.
|
|
89
|
+
- `signJwt` — HS256, 발급시각 자동 설정, **만료 12시간 고정**. secret 은 UTF-8 로 인코딩됨.
|
|
90
|
+
- `verifyJwt` — 검증 실패 시 만료면 `"토큰이 만료되었습니다."`, 그 외엔 `"유효하지 않은 토큰입니다."` throw(jose 에러 코드 `ERR_JWT_EXPIRED` 로 만료 여부 구분).
|
|
91
|
+
- `decodeJwt` — **서명 검증 없이** 페이로드만 디코드. 신뢰할 수 없는 토큰 검증 용도로는 쓰지 말 것.
|
|
92
|
+
|
|
93
|
+
## 내장 서비스
|
|
94
|
+
|
|
95
|
+
`defineService` 결과 상수. `services` 목록에 그대로 추가해 사용. 둘 다 이름 별칭(`["Orm","SdOrmService"]`, `["AutoUpdate","SdAutoUpdateService"]`)을 가져 신·구 클라이언트 모두 호출 가능.
|
|
96
|
+
|
|
97
|
+
### OrmService / OrmServiceType
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
export const OrmService: ServiceDefinition
|
|
101
|
+
export type OrmServiceType = ServiceMethods<typeof OrmService>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`auth()` 로 래핑됨(로그인 필요). **WebSocket 전용** — HTTP 호출 시 throw(연결 ID 상태를 소켓에 묶어 관리하기 때문). DB 설정은 `ctx.getConfig("orm")` 의 `<configName>` 키에서 읽음. 소켓 종료 시 해당 소켓의 모든 열린 DB 연결을 자동 정리. 메서드: `getInfo`/`connect`(연결 ID 반환)/`close`/`beginTransaction`(`isolationLevel?`)/`commitTransaction`/`rollbackTransaction`/`executeParametrized`/`executeDefs`/`bulkInsert`. `dialect` 가 `"mssql-azure"` 면 `"mssql"` 로 정규화해 응답.
|
|
105
|
+
|
|
106
|
+
### AutoUpdateService / AutoUpdateServiceType
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
export const AutoUpdateService: ServiceDefinition
|
|
110
|
+
export type AutoUpdateServiceType = ServiceMethods<typeof AutoUpdateService>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
인증 불필요. `getLastVersion(platform: string)` — `<clientPath>/<platform>/updates/` 에서 최신 버전 파일을 semver 로 골라 `{ version, downloadPath }` 반환, 없으면 undefined. `platform === "android"` 면 `.apk`, 그 외엔 `.exe` 파일만 후보(파일명이 버전 숫자 패턴 `^[0-9.]*$` 여야 함). `downloadPath` 는 `/` 로 시작하는 POSIX 경로.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
client.getService<OrmServiceType>("Orm");
|
|
117
|
+
client.getService<AutoUpdateServiceType>("AutoUpdate");
|
|
118
|
+
```
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @simplysm/service-server — service-authoring
|
|
2
|
+
|
|
3
|
+
RPC 서비스(클라이언트가 원격 호출할 메서드 묶음)를 정의하고 인증을 거는 묶음. `defineService`·`auth`·`ServiceContext`·`ServiceMethods` 가 서비스 작성 시 항상 함께 읽힌다. `defineService` 산출물을 `ServiceServerOptions.services` 에 등록한다.
|
|
4
|
+
|
|
5
|
+
## defineService
|
|
6
|
+
|
|
7
|
+
`defineService<TMethods>(name: string | string[], factory: (ctx: ServiceContext) => TMethods): ServiceDefinition<TMethods>` — 서비스 정의 생성.
|
|
8
|
+
|
|
9
|
+
- `name` — 서비스 식별 이름. 문자열 1개 또는 배열(별칭 다중 등록, 첫 원소가 primary). 빈 배열이면 throw. 클라이언트는 `"<name>.<method>"` 형태로 호출.
|
|
10
|
+
- `factory` — 호출마다 `ctx`(요청 컨텍스트)를 받아 메서드 객체를 반환하는 함수. 요청별로 매번 호출되므로 요청 스코프 상태를 여기 둔다. 인스턴스 간 공유 상태는 팩토리 외부에 둘 것(예: `OrmService` 의 `WeakMap`).
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const HealthService = defineService("Health", (ctx) => ({
|
|
14
|
+
check: () => ({ status: "ok" }),
|
|
15
|
+
}));
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
팩토리 전체를 `auth(...)` 로 감싸면 정의의 `authPermissions` 가 채워져 서비스 전 메서드에 인증이 강제된다(`getServiceAuthPermissions` 로 추출).
|
|
19
|
+
|
|
20
|
+
## auth
|
|
21
|
+
|
|
22
|
+
메서드 또는 팩토리를 감싸 인증·권한을 부여하는 래퍼. 호출 동작은 그대로 유지하고 권한 메타데이터만 부착한다.
|
|
23
|
+
|
|
24
|
+
- `auth(fn)` — 권한 배열 없이 감쌈. 로그인만 필요(역할 무관).
|
|
25
|
+
- `auth(permissions: string[], fn)` — 지정 역할 중 하나라도 토큰 `roles` 에 있어야 통과. 빈 배열은 로그인만 요구하는 것과 동일.
|
|
26
|
+
|
|
27
|
+
적용 수준 두 가지(둘 다 같은 함수):
|
|
28
|
+
|
|
29
|
+
- 서비스 수준: `auth((ctx) => ({ ... }))` 또는 `auth(["admin"], (ctx) => ({ ... }))` — 모든 메서드에 적용.
|
|
30
|
+
- 메서드 수준: 객체 안에서 `someMethod: auth(() => result)` 또는 `auth(["admin"], () => result)` — 그 메서드만.
|
|
31
|
+
|
|
32
|
+
권한 해석 우선순위(`executeServiceMethod`): 메서드 수준 권한이 있으면 그것을, 없으면 서비스 수준 권한을 사용. 권한이 있는데 서버 `auth` 가 `undefined` 면 설정 오류로 throw, `false` 면 검사 스킵, 객체면 토큰 검증(미인증 시 `"로그인이 필요합니다."`, 권한 부족 시 `"권한이 부족합니다."` throw).
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const UserService = defineService("User", auth((ctx) => ({
|
|
36
|
+
getProfile: () => ctx.authInfo,
|
|
37
|
+
adminOnly: auth(["admin"], () => "admin"),
|
|
38
|
+
})));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`getServiceAuthPermissions(fn: Function): string[] | undefined` — `auth()` 로 감싼 함수에서 권한 배열을 읽음. 감싸지 않았으면 undefined. 보통 내부에서만 사용.
|
|
42
|
+
|
|
43
|
+
## ServiceContext
|
|
44
|
+
|
|
45
|
+
팩토리가 받는 요청 컨텍스트. `ServiceContext<TAuthInfo>` 멤버:
|
|
46
|
+
|
|
47
|
+
- `server: ServiceServer<TAuthInfo>` — 서버 인스턴스. `server.options` 접근 등.
|
|
48
|
+
- `socket?: ServiceSocket` — WebSocket 요청이면 해당 소켓(HTTP/레거시 요청이면 undefined).
|
|
49
|
+
- `http?: { clientName: string; authTokenPayload? }` — HTTP 요청 메타(WebSocket 요청이면 undefined).
|
|
50
|
+
- `legacy?: { clientName? }` — V1 레거시 요청 메타.
|
|
51
|
+
- `authInfo` (getter) — `TAuthInfo | undefined`. 소켓/HTTP 토큰 페이로드의 `data`. 미인증이면 undefined.
|
|
52
|
+
- `clientName` (getter) — `string | undefined`. 소켓→HTTP→레거시 순으로 클라이언트 이름. `..`·`/`·`\`·빈 문자열 포함 시 보안상 throw.
|
|
53
|
+
- `clientPath` (getter) — `string | undefined`. `<rootPath>/www/<clientName>` 절대경로. clientName 없으면 undefined.
|
|
54
|
+
- `getConfig<T>(section: string): Promise<T>` — 루트 `.config.json` + 클라이언트별 `.config.json` 을 병합(클라이언트가 루트를 덮어씀)한 뒤 `section` 키를 반환. 해당 섹션 없으면 throw.
|
|
55
|
+
|
|
56
|
+
## ServiceMethods
|
|
57
|
+
|
|
58
|
+
`ServiceMethods<TDefinition>` — `ServiceDefinition<M>` 에서 메서드 시그니처 `M` 만 추출하는 타입 유틸. 서버 정의를 클라이언트와 공유해 호출 타입을 맞출 때.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
export type UserServiceType = ServiceMethods<typeof UserService>;
|
|
62
|
+
// 클라이언트: client.getService<UserServiceType>("User");
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## ServiceDefinition
|
|
66
|
+
|
|
67
|
+
`defineService` 의 반환 타입. `{ name: string; names: string[]; factory: (ctx) => TMethods; authPermissions?: string[] }`. `name` 은 primary 이름, `names` 는 모든 별칭, `authPermissions` 는 팩토리가 `auth()` 로 감싸졌을 때만 채워짐. 보통 직접 만들지 않고 `defineService` 결과를 그대로 `services` 에 넣는다.
|
|
68
|
+
|
|
69
|
+
## createServiceContext
|
|
70
|
+
|
|
71
|
+
`createServiceContext<TAuthInfo>(server, socket?, http?, legacy?): ServiceContext<TAuthInfo>` — 위 컨텍스트 객체를 직접 생성. 서버 내부(요청 처리·V1 레거시 fallback)에서 사용하며, 커스텀 호출 경로를 손수 만들 때만 직접 호출.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @simplysm/service-server — transport-internals
|
|
2
|
+
|
|
3
|
+
`ServiceServer.listen()` 이 내부적으로 등록하는 저수준 전송·프로토콜 핸들러와 서비스 실행기. 보통 직접 호출하지 않으며, 커스텀 서버를 손수 조립하거나 동작을 디버깅·확장할 때만 참조한다.
|
|
4
|
+
|
|
5
|
+
## executeServiceMethod
|
|
6
|
+
|
|
7
|
+
`executeServiceMethod(server, def): Promise<unknown>` — 서비스 이름·메서드 이름·params 로 실제 메서드를 찾아 인증 검사 후 실행하는 핵심 디스패처. WebSocket/HTTP 핸들러가 공통으로 이걸 호출한다.
|
|
8
|
+
|
|
9
|
+
- `def.serviceName` / `def.methodName` — `services` 에서 매칭할 이름. 서비스 없으면 `"서비스 [..]를 찾을 수 없습니다."`, 메서드 없으면 `"메서드 [..]를 찾을 수 없습니다."` throw.
|
|
10
|
+
- `def.params: unknown[]` — 메서드 인자.
|
|
11
|
+
- `def.socket?` / `def.http?` — 요청 출처(둘 중 하나). clientName 에 `..`·`/`·`\` 포함 시 보안 throw.
|
|
12
|
+
|
|
13
|
+
인증 검사는 메서드/서비스 권한 + 서버 `auth` 설정 조합으로 수행(service-authoring.md 의 `auth` 항목 참조).
|
|
14
|
+
|
|
15
|
+
## createWebSocketHandler / WebSocketHandler
|
|
16
|
+
|
|
17
|
+
`createWebSocketHandler(runMethod, jwtSecret?): WebSocketHandler` — 여러 WebSocket 연결을 `clientId` 키로 관리하고 메시지를 라우팅·이벤트 브로드캐스트한다. `runMethod` 는 보통 `executeServiceMethod` 바인딩.
|
|
18
|
+
|
|
19
|
+
`WebSocketHandler` 멤버:
|
|
20
|
+
|
|
21
|
+
- `addSocket(socket, clientId, clientName, connReq)` — 새 연결 등록. 같은 `clientId` 기존 연결은 닫고 교체. 연결 처리 중 에러 시 소켓 terminate.
|
|
22
|
+
- `closeAll()` — 모든 연결 종료(서버 close 시).
|
|
23
|
+
- `emit<TEventDef>(eventName, infoSelector, data): Promise<void>` — 등록 리스너 중 `infoSelector(info)` true 인 키에만 `evt:on` 전송.
|
|
24
|
+
|
|
25
|
+
처리하는 클라이언트 메시지 `name`: `"<service>.<method>"`(RPC 실행), `evt:add`/`evt:remove`/`evt:gets`/`evt:emit`(이벤트 리스너 등록·해제·조회·발신), `auth`(토큰 검증 후 소켓에 페이로드 저장; jwtSecret 없으면 throw). 그 외엔 `BAD_MESSAGE`, 실행 중 예외는 `INTERNAL_ERROR` 코드로 에러 응답(`DEV` env 시 stack 포함).
|
|
26
|
+
|
|
27
|
+
## createServiceSocket / ServiceSocket
|
|
28
|
+
|
|
29
|
+
`createServiceSocket(socket: WebSocket, clientId, clientName, connReq): ServiceSocket` — 단일 WebSocket 연결을 감싸 프로토콜 인코딩/디코딩, 5초 주기 ping/pong keep-alive(무응답 시 terminate), 이벤트 리스너 추적을 담당.
|
|
30
|
+
|
|
31
|
+
`ServiceSocket` 멤버:
|
|
32
|
+
|
|
33
|
+
- `connectedAtDateTime: DateTime` / `clientName: string` / `connReq: FastifyRequest` — 연결 메타(읽기 전용).
|
|
34
|
+
- `authTokenPayload?: AuthTokenPayload` — `auth` 메시지 검증 후 저장되는 인증 페이로드(get/set).
|
|
35
|
+
- `close()` — 연결 terminate.
|
|
36
|
+
- `send(uuid, msg): Promise<number>` — 메시지 인코딩 후 전송, 전송 바이트 수 반환(소켓 닫혀 있으면 0).
|
|
37
|
+
- `addListener(key, eventName, info)` / `removeListener(key)` — 이벤트 리스너 등록·제거.
|
|
38
|
+
- `getEventListeners(eventName): Array<{ key, info }>` — 해당 이벤트의 리스너 목록.
|
|
39
|
+
- `filterEventTargetKeys(targetKeys): string[]` — 이 소켓에 실제 등록된 키만 필터.
|
|
40
|
+
- `on(event, handler)` — `"error"`(Error) / `"close"`(code: number) / `"message"`({ uuid, msg }) 핸들러 등록.
|
|
41
|
+
|
|
42
|
+
## handleHttpRequest
|
|
43
|
+
|
|
44
|
+
`handleHttpRequest<TAuthInfo>(req, reply, jwtSecret?, runMethod): Promise<void>` — `/api/:service/:method` 라우트 처리. `x-sd-client-name` 헤더 필수(없으면 throw), `Authorization: Bearer <token>` 있으면 검증(실패 시 401). GET 은 `?json=` 쿼리에서 params 파싱, POST 는 본문 배열(아니면 400), 그 외 메서드는 405. 결과를 그대로 응답.
|
|
45
|
+
|
|
46
|
+
## handleUpload
|
|
47
|
+
|
|
48
|
+
`handleUpload(req, reply, rootPath, jwtSecret?): Promise<void>` — `/upload` multipart 업로드 처리. multipart 아니면 400, 인증 토큰 누락·검증 실패 시 401. 각 파일을 `<rootPath>/www/uploads/<uuid><ext>` 로 저장하고 `ServiceUploadResult[]`(`{ path, filename, size }`) 반환. 크기 제한 초과나 도중 에러 시 이미 저장된 파일을 모두 삭제(원자적 정리)하고 500.
|
|
49
|
+
|
|
50
|
+
## handleStaticFile
|
|
51
|
+
|
|
52
|
+
`handleStaticFile(req, reply, rootPath, urlPath): Promise<void>` — `<rootPath>/www/` 하위 정적 파일 제공. `www` 밖 경로 탐색 시도는 throw. 디렉토리는 끝에 `/` 붙여 리다이렉트 후 `index.html` 제공. `.` 으로 시작하는 숨김 파일은 403, 없는 파일은 404, 그 외 전송 에러는 500(각각 HTML 에러 페이지).
|
|
53
|
+
|
|
54
|
+
## createServerProtocolWrapper / ServerProtocolWrapper
|
|
55
|
+
|
|
56
|
+
`createServerProtocolWrapper(): ServerProtocolWrapper` — 메시지 인코딩/디코딩 래퍼. 무거운 작업(Uint8Array 본문, 30KB 초과 JSON 파싱)은 공유 worker 스레드에 위임하고 가벼운 작업은 메인에서 처리. 청크 재조립(stateful)은 항상 메인 단일 누적기에서 수행한다(분산 시 재조립 불가 회피, #35).
|
|
57
|
+
|
|
58
|
+
`ServerProtocolWrapper` 멤버:
|
|
59
|
+
|
|
60
|
+
- `encode(uuid, message): Promise<{ chunks: Bytes[]; totalSize: number }>` — 인코딩. 본문이 Uint8Array 거나 Uint8Array 요소를 포함한 배열이면 worker 사용.
|
|
61
|
+
- `decode(bytes): Promise<ServiceMessageDecodeResult>` — 누적·디코딩. 진행 중이면 `{ type: "progress", ... }`, 완료 시 `{ type: "complete", uuid, message }`(30KB 초과 시 worker 파싱).
|
|
62
|
+
- `dispose()` — 프로토콜 리소스 해제(소켓 종료 시).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @simplysm/service-server — v1-legacy
|
|
2
|
+
|
|
3
|
+
`ver !== "2"`(구버전) WebSocket 클라이언트를 받기 위한 레거시 핸들러. 주로 구버전 앱의 자동 업데이트(`SdAutoUpdateService.getLastVersion`) 요청을 처리한다. `ServiceServer` 는 ver=2 가 아닌 연결을 자동으로 이 핸들러로 넘긴다(`AutoUpdate` 서비스나 `legacyV1Handlers` 가 있을 때만, 둘 다 없으면 연결 거부). `ServiceServerOptions.legacyV1Handlers` 로 커스텀 핸들러를 끼울 때만 직접 다룬다.
|
|
4
|
+
|
|
5
|
+
## handleV1Connection
|
|
6
|
+
|
|
7
|
+
V1 소켓 연결을 처리한다. 두 가지 시그니처:
|
|
8
|
+
|
|
9
|
+
- `handleV1Connection(socket, autoUpdateMethods: V1AutoUpdateMethods, clientNameSetter?)` — 자동 업데이트 메서드만 넘기는 단축형.
|
|
10
|
+
- `handleV1Connection(socket, options: V1ConnectionOptions)` — 전체 옵션형.
|
|
11
|
+
|
|
12
|
+
연결 즉시 `{ name: "connected" }` 전송. 메시지 수신 시: ① `clientNameSetter` 호출 → ② 사용자 `handlers` 순회(처리되면 그 응답) → ③ 미처리이고 command 가 `"SdAutoUpdateService.getLastVersion"` 이면 자동 업데이트 메서드 실행 → ④ 그래도 미처리면 `{ message: "앱 업그레이드가 필요합니다.", code: "UPGRADE_REQUIRED" }` 에러 응답. 메시지 파싱 에러는 warn 로그.
|
|
13
|
+
|
|
14
|
+
## V1ConnectionOptions
|
|
15
|
+
|
|
16
|
+
- `serviceContext?: ServiceContext` — 핸들러에 넘길 고정 컨텍스트.
|
|
17
|
+
- `serviceContextFactory?: (request: V1Request) => ServiceContext` — 요청별 컨텍스트 생성(고정 컨텍스트보다 우선 적용). `ServiceServer` 는 이걸로 clientName 만 담은 컨텍스트를 만든다.
|
|
18
|
+
- `handlers?: V1RequestHandler[]` — 사용자 정의 처리기 목록. 하나라도 `handled: true` 면 그 응답으로 종료. 핸들러가 있는데 컨텍스트가 없으면 throw.
|
|
19
|
+
- `autoUpdateMethods?: V1AutoUpdateMethods` — getLastVersion fallback 의 고정 구현.
|
|
20
|
+
- `autoUpdateMethodsFactory?: (ctx: V1RequestHandlerContext) => V1AutoUpdateMethods` — 요청별 fallback 생성(있으면 고정 구현보다 우선). 컨텍스트 없으면 throw.
|
|
21
|
+
- `clientNameSetter?: (clientName: string | undefined) => void` — 매 메시지의 `clientName` 을 외부로 전달하는 콜백.
|
|
22
|
+
|
|
23
|
+
## V1RequestHandler
|
|
24
|
+
|
|
25
|
+
`V1RequestHandler` — `(ctx: V1RequestHandlerContext) => V1RequestHandlerResult | Promise<...>`. 동기/비동기 모두 허용.
|
|
26
|
+
|
|
27
|
+
`V1RequestHandlerContext` — `{ request: V1Request; serviceContext: ServiceContext }`.
|
|
28
|
+
|
|
29
|
+
`V1RequestHandlerResult` — `{ handled: true; state?: "success" | "error"; body: unknown }`(이 핸들러가 처리; `state` 미지정 시 `"success"`) 또는 `{ handled: false }`(다음 핸들러/fallback 으로 위임).
|
|
30
|
+
|
|
31
|
+
## V1Request / V1Response
|
|
32
|
+
|
|
33
|
+
`V1Request` — 클라이언트 요청. `{ uuid: string; command: string; params: unknown[]; clientName?: string }`. `command` 는 `"<service>.<method>"` 형태.
|
|
34
|
+
|
|
35
|
+
`V1Response` — 서버 응답. `{ name: "response"; reqUuid: string; state: "success" | "error"; body: unknown }`. `state` 가 `"success"` 면 정상 결과, `"error"` 면 오류 본문.
|
|
36
|
+
|
|
37
|
+
## V1AutoUpdateMethods
|
|
38
|
+
|
|
39
|
+
`V1AutoUpdateMethods` — `{ getLastVersion: (platform: string) => Promise<unknown> | unknown }`. V1 자동 업데이트 fallback 의 최소 인터페이스. `ServiceServer` 는 등록된 `AutoUpdate` 서비스의 `getLastVersion` 을 여기에 어댑트해 넘긴다.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# @simplysm/storage
|
|
2
|
+
|
|
3
|
+
FTP / FTPS / SFTP 원격 스토리지에 연결해 파일·디렉토리를 업로드·다운로드·조회·삭제하는 Node 전용 클라이언트. 프로토콜별 구현을 동일 인터페이스(`StorageClient`)로 통일하고, 팩토리(`StorageFactory.connect`)로 연결/종료를 자동 관리.
|
|
4
|
+
|
|
5
|
+
## 사용 트리거 인덱스
|
|
6
|
+
|
|
7
|
+
- **StorageFactory** — 원격 스토리지에 접속해 파일 작업을 한 뒤 자동으로 연결을 닫고 싶을 때. 권장 진입점.
|
|
8
|
+
- **StorageClient** — `connect` 콜백 안에서 받는 파일 조작 인터페이스. mkdir/list/readFile/put/remove 등 호출 시 참조.
|
|
9
|
+
- **StorageConnConfig** — 접속 호스트/계정/비밀번호를 구성할 때.
|
|
10
|
+
- **StorageProtocol** — 프로토콜 종류(`ftp`/`ftps`/`sftp`)를 지정할 때.
|
|
11
|
+
- **FileInfo** — `list()` 가 돌려주는 항목 구조(이름·파일여부)를 확인할 때.
|
|
12
|
+
- **FtpStorageClient / SftpStorageClient** — 팩토리 없이 클라이언트를 직접 인스턴스화·재연결 제어해야 할 때(비권장).
|
|
13
|
+
|
|
14
|
+
## StorageProtocol
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
type StorageProtocol = "ftp" | "ftps" | "sftp";
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- `"ftp"` — 평문 FTP. 보안 채널 없이 접속(`basic-ftp` `secure: false`). 내부망·테스트 환경에서만 권장.
|
|
21
|
+
- `"ftps"` — TLS 로 암호화된 FTP(`basic-ftp` `secure: true`). 외부망 FTP 접속 시.
|
|
22
|
+
- `"sftp"` — SSH 기반 SFTP. password 미지정 시 `~/.ssh/id_ed25519` 키 + SSH agent 로 인증.
|
|
23
|
+
|
|
24
|
+
## StorageConnConfig
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
interface StorageConnConfig { host: string; port?: number; user?: string; password?: string; }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `host: string` — 접속 대상 서버 호스트명 또는 IP. 필수.
|
|
31
|
+
- `port?: number` — 접속 포트. 미지정 시 각 라이브러리 기본값(FTP 21, SFTP 22) 사용.
|
|
32
|
+
- `user?: string` — 로그인 사용자명. 미지정 시 익명/기본 사용자.
|
|
33
|
+
- `password?: string` — 로그인 비밀번호. **SFTP 에서 이 값이 `null`/미지정이면** password 인증 대신 `~/.ssh/id_ed25519` 개인키와 SSH agent(`SSH_AUTH_SOCK` 환경변수가 설정된 경우 `agent` 옵션) 로 인증 시도하고, 키 파싱 실패(암호화 키 등) 시 agent 단독으로 재시도. FTP/FTPS 에서는 미지정 시 라이브러리 기본(익명) 처리.
|
|
34
|
+
|
|
35
|
+
## StorageFactory
|
|
36
|
+
|
|
37
|
+
스토리지 접속 진입점. 연결 생성 → 콜백 실행 → 자동 종료를 묶어 처리한다. 인스턴스를 직접 만들 필요 없이 정적 `connect` 만 사용.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
class StorageFactory {
|
|
41
|
+
static connect<R>(
|
|
42
|
+
type: StorageProtocol,
|
|
43
|
+
config: StorageConnConfig,
|
|
44
|
+
fn: (storage: StorageClient) => R | Promise<R>,
|
|
45
|
+
): Promise<R>;
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `type: StorageProtocol` — 사용할 프로토콜. `"sftp"` → `SftpStorageClient`, `"ftps"` → `FtpStorageClient(secure=true)`, `"ftp"` → `FtpStorageClient(secure=false)` 를 내부 생성.
|
|
50
|
+
- `config: StorageConnConfig` — 접속 설정.
|
|
51
|
+
- `fn: (storage: StorageClient) => R | Promise<R>` — 연결된 `StorageClient` 를 받아 파일 작업을 수행하는 콜백. 반환값이 그대로 `connect` 의 결과(`Promise<R>`) 가 됨.
|
|
52
|
+
- 동작: `client.connect()` 후 `fn` 실행, `finally` 에서 `client.close()` 호출하며 종료 오류는 무시(이미 종료된 경우 대비). 콜백에서 예외가 나도 연결은 반드시 닫히고 예외는 그대로 전파됨. 직접 클라이언트를 다루는 것보다 권장.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const names = await StorageFactory.connect("sftp", { host: "10.0.0.1", user: "u", password: "p" }, async (s) => {
|
|
56
|
+
await s.mkdir("/upload");
|
|
57
|
+
await s.put(buffer, "/upload/a.txt");
|
|
58
|
+
return (await s.list("/upload")).map((f) => f.name);
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## StorageClient
|
|
63
|
+
|
|
64
|
+
`connect` 콜백 안에서 받는 파일 조작 인터페이스. `FtpStorageClient`·`SftpStorageClient` 가 구현한다. 모든 메서드는 `Promise` 반환.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
interface StorageClient {
|
|
68
|
+
connect(config: StorageConnConfig): Promise<void>;
|
|
69
|
+
mkdir(dirPath: string): Promise<void>;
|
|
70
|
+
rename(fromPath: string, toPath: string): Promise<void>;
|
|
71
|
+
list(dirPath: string): Promise<FileInfo[]>;
|
|
72
|
+
readFile(filePath: string): Promise<Bytes>;
|
|
73
|
+
exists(filePath: string): Promise<boolean>;
|
|
74
|
+
put(localPathOrBuffer: string | Bytes, storageFilePath: string): Promise<void>;
|
|
75
|
+
uploadDir(fromPath: string, toPath: string): Promise<void>;
|
|
76
|
+
remove(filePath: string): Promise<void>;
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- `connect(config)` — 서버에 연결. 이미 연결된 인스턴스에서 재호출하면 `SdError` throw(먼저 `close()` 필요). `StorageFactory.connect` 사용 시 직접 호출 불필요. 연결 도중 실패하면 내부 라이브러리 연결을 닫고 예외를 다시 throw.
|
|
82
|
+
- `mkdir(dirPath)` — 디렉토리 생성. 부모 디렉토리가 없으면 함께 생성(FTP `ensureDir`, SFTP 재귀 `mkdir`).
|
|
83
|
+
- `rename(fromPath, toPath)` — 파일/디렉토리 경로 이동·이름 변경.
|
|
84
|
+
- `list(dirPath)` — 디렉토리 내 항목을 `FileInfo[]` 로 반환.
|
|
85
|
+
- `readFile(filePath)` — 원격 파일 전체를 `Bytes`(Uint8Array) 로 메모리에 다운로드(스트리밍 아님 — 큰 파일은 메모리 부담). 텍스트가 필요하면 호출 측에서 디코딩. SFTP 는 응답이 예상 타입(Buffer/string) 이 아니면 `SdError` throw.
|
|
86
|
+
- `exists(filePath)` — 파일/디렉토리 존재 여부. **모든 예외(부모 없음·권한·네트워크 오류 포함) 에 대해 `false` 반환** — true/false 외 throw 없음. 따라서 `true` 만 "확실히 존재"로 신뢰한다. FTP 는 `size()` 로 파일을 O(1) 확인 후 실패 시 부모 디렉토리 목록으로 디렉토리 확인(슬래시 없는 경로는 루트 `/` 기준이라 항목 많은 디렉토리에서는 느려질 수 있음). SFTP 는 `exists()` 결과가 문자열(`'d'`/`'-'`/`'l'`)이면 존재로 판정.
|
|
87
|
+
- `put(localPathOrBuffer, storageFilePath)` — 단일 파일 업로드. 첫 인자가 `string` 이면 로컬 파일 경로, `Bytes` 면 메모리 바이트를 업로드 대상으로 사용.
|
|
88
|
+
- `uploadDir(fromPath, toPath)` — 로컬 디렉토리 전체를 원격 디렉토리로 재귀 업로드.
|
|
89
|
+
- `remove(filePath)` — 원격 파일 삭제.
|
|
90
|
+
- `close()` — 연결 종료. 이미 종료/미연결 상태에서 호출해도 오류 없음. 종료 후 같은 인스턴스에서 `connect()` 로 재연결 가능. `StorageFactory.connect` 사용 시 직접 호출 불필요.
|
|
91
|
+
|
|
92
|
+
미연결 상태에서 작업 메서드를 호출하면 모든 구현체가 `SdError`("연결되어 있지 않습니다") throw.
|
|
93
|
+
|
|
94
|
+
## FileInfo
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
interface FileInfo { name: string; isFile: boolean; }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `name: string` — 항목 이름(파일명 또는 디렉토리명, 경로 아님).
|
|
101
|
+
- `isFile: boolean` — 파일이면 `true`, 디렉토리면 `false`. 디렉토리 재귀 탐색 시 파일만 골라 처리하는 분기 기준으로 사용. SFTP 는 항목 type 이 `"-"` 인 경우만 `true`(디렉토리·심볼릭 링크는 `false`).
|
|
102
|
+
|
|
103
|
+
## FtpStorageClient / SftpStorageClient (직접 사용, 비권장)
|
|
104
|
+
|
|
105
|
+
`StorageClient` 직접 구현체. 보통은 `StorageFactory.connect` 로 충분하며, 연결 수명을 콜백 밖에서 수동으로 다뤄야 할 때만 직접 생성한다.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
new FtpStorageClient(secure?: boolean) // secure=true → FTPS, 생략/false → 평문 FTP
|
|
109
|
+
new SftpStorageClient()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `FtpStorageClient` 의 `secure` 생성자 인자 — `true` 면 TLS(FTPS), 생략/`false` 면 평문 FTP. (팩토리는 `ftps`→`true`, `ftp`→`false` 로 매핑.)
|
|
113
|
+
- `SftpStorageClient` 는 생성자 인자 없음. password 미지정 시 키/agent 인증 경로를 탄다(StorageConnConfig 의 `password` 풀이 참조).
|
|
114
|
+
- 직접 사용 시 `connect()` → 작업 → `close()` 순으로 호출하고, 예외 발생 시에도 `close()` 가 호출되도록 `try/finally` 로 감쌀 것. 동일 인스턴스에서 `close()` 없이 `connect()` 를 재호출하면 연결 누수로 throw.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const client = new SftpStorageClient();
|
|
118
|
+
await client.connect({ host: "10.0.0.1", user: "u", password: "p" });
|
|
119
|
+
try { await client.put(buf, "/x.txt"); } finally { await client.close(); }
|
|
120
|
+
```
|
|
@@ -30,6 +30,12 @@ spec.md 의 화면 1개를 클라이언트 패키지의 화면 컴포넌트 자
|
|
|
30
30
|
|
|
31
31
|
**충돌 확인**: 대상 화면(또는 동반 모달 화면) 의 파일이 이미 존재하면 묻기 — ① 덮어쓰기 / ② spec 변경분만 보강 / ③ 취소. 호출자 발화에 명시된 분기가 있으면 묻지 않고 그 분기로 진행.
|
|
32
32
|
|
|
33
|
+
**마커 점검**: 대상 화면(또는 동반 모달 화면) 의 헤더 마커 확인.
|
|
34
|
+
|
|
35
|
+
- 헤더 미확정(마커 없음) / `[확정: 날짜]` → 본문에 와이어프레임·항목 초안이 있으면 그대로 진행 (미확정은 미검토 초안이지만 데모가 그 검토를 돕는 시각화이므로 정상).
|
|
36
|
+
- 단 미확정 섹션의 본문이 와이어프레임·항목 없이 분석 방법뿐이면 → 시각화할 내용 없음. 사용자에게 보고하고 중단 (sd-spec 으로 해당 화면을 먼저 채우도록 안내).
|
|
37
|
+
- 본문 인라인 `[OPEN]` 항목 → 4단계의 더미 마커로 placeholder 처리.
|
|
38
|
+
|
|
33
39
|
### 2단계: 대상 화면 항목 분석
|
|
34
40
|
|
|
35
41
|
spec.md 의 대상 화면 항목에서 다음을 추출:
|
|
@@ -33,20 +33,17 @@ spec.md 단위(§4.x 화면 / §5.x 자동 처리 / §6.x 공통·기반 기능)
|
|
|
33
33
|
|
|
34
34
|
작업 대상 §4.x/§5.x/§6.x 와 작업 대상이 참조하는 §5/§6/§7/§8/§9 항목을 점검:
|
|
35
35
|
|
|
36
|
-
- **헤더 `[
|
|
36
|
+
- **헤더 미확정(마커 없음) 섹션**: 사용자 미검토 상태 → 그대로 구현하지 않음. 해당 § 을 사용자에게 제시·검토 요청 → 확정/수정 후 `[확정]` 으로 승격 → 진행. 단 본문이 초안 없이 재분석 방법뿐이면 "spec 수정 절차" 적용.
|
|
37
37
|
- **본문 인라인 `[OPEN]`**: 결정 근거가 있으면 결정. 없으면 1건씩 질문 → 답변 후 "spec 수정 절차" 적용.
|
|
38
38
|
- **사용자 제공 예정 자료**: spec 본문에 사용자 회신 대기로 적힌 자료. 사용자 회신 후 진행.
|
|
39
39
|
- **묵시적 모호**: 명시 마커는 없지만 spec 본문이 분기·정의·정의식·경계 케이스를 다루지 않는 부분. 4단계 분해 중 발견 가능성 높음. 발견 시 본 단계로 회귀.
|
|
40
40
|
|
|
41
41
|
#### spec 수정 절차
|
|
42
42
|
|
|
43
|
-
spec 본문 수정이 필요한 경우(인라인 `[OPEN]`
|
|
43
|
+
spec 본문 수정이 필요한 경우(인라인 `[OPEN]` 해소·미확정 섹션 확정·구조 변경·6단계 (b)(d) 분류 처리 등) 메인 LLM 이 직접 처리:
|
|
44
44
|
|
|
45
|
-
1. **sd-spec SKILL.md Read** — 룰
|
|
46
|
-
2.
|
|
47
|
-
3. **의존 식별** — 수정된 § 항목을 의존으로 가진 §4.x/§5.x/§6.x 탐색 (sd-spec "의존 식별" 룰). 본 작업 단위 자신은 제외 (작업 중).
|
|
48
|
-
4. **마커 제거** — 의존 §4.x/§5.x/§6.x 헤더의 `, 구현: YYYY-MM-DD` 부분만 제거 (이전 구현 무효화).
|
|
49
|
-
- 영향 단위에 번호를 매겨 사용자에게 모아서 "맞나요?" 질문 — 전체 동의 → 일괄 제거 / 일부 유지 지목 → 지목 항목 마커 유지.
|
|
45
|
+
1. **sd-spec SKILL.md Read** — 룰 인지.
|
|
46
|
+
2. **재분석/검토 → 확정 → spec.md Edit** — sd-spec 의 "확정 섹션 수정·구현 무효화" 룰 적용. `[확정]` 산출이므로 합의(행동 규칙) 후 헤더 `[확정: 날짜]` 부착. 수정으로 무효화되는 구현 §4.x/§5.x/§6.x (본 작업 단위 자신은 제외 — 작업 중) 의 `, 구현: …` 마커 제거를 합의 내용에 함께 올림.
|
|
50
47
|
|
|
51
48
|
본 작업 단위(§4.x/§5.x/§6.x 자신)의 헤더 `[확정]` 은 9단계에서 `[확정, 구현]` 으로 확장.
|
|
52
49
|
|