opensoma 0.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.
Files changed (146) hide show
  1. package/.claude-plugin/README.md +145 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.github/workflows/release.yml +86 -0
  4. package/.oxfmtrc.json +9 -0
  5. package/.oxlintrc.json +4 -0
  6. package/AGENTS.md +78 -0
  7. package/README.md +249 -0
  8. package/bun.lock +297 -0
  9. package/bunfig.toml +2 -0
  10. package/dist/package.json +56 -0
  11. package/dist/src/cli.d.ts +5 -0
  12. package/dist/src/cli.d.ts.map +1 -0
  13. package/dist/src/cli.js +39 -0
  14. package/dist/src/cli.js.map +1 -0
  15. package/dist/src/client.d.ts +98 -0
  16. package/dist/src/client.d.ts.map +1 -0
  17. package/dist/src/client.js +141 -0
  18. package/dist/src/client.js.map +1 -0
  19. package/dist/src/commands/auth.d.ts +3 -0
  20. package/dist/src/commands/auth.d.ts.map +1 -0
  21. package/dist/src/commands/auth.js +125 -0
  22. package/dist/src/commands/auth.js.map +1 -0
  23. package/dist/src/commands/dashboard.d.ts +3 -0
  24. package/dist/src/commands/dashboard.d.ts.map +1 -0
  25. package/dist/src/commands/dashboard.js +33 -0
  26. package/dist/src/commands/dashboard.js.map +1 -0
  27. package/dist/src/commands/event.d.ts +3 -0
  28. package/dist/src/commands/event.d.ts.map +1 -0
  29. package/dist/src/commands/event.js +58 -0
  30. package/dist/src/commands/event.js.map +1 -0
  31. package/dist/src/commands/helpers.d.ts +3 -0
  32. package/dist/src/commands/helpers.d.ts.map +1 -0
  33. package/dist/src/commands/helpers.js +12 -0
  34. package/dist/src/commands/helpers.js.map +1 -0
  35. package/dist/src/commands/index.d.ts +9 -0
  36. package/dist/src/commands/index.d.ts.map +1 -0
  37. package/dist/src/commands/index.js +9 -0
  38. package/dist/src/commands/index.js.map +1 -0
  39. package/dist/src/commands/member.d.ts +3 -0
  40. package/dist/src/commands/member.d.ts.map +1 -0
  41. package/dist/src/commands/member.js +23 -0
  42. package/dist/src/commands/member.js.map +1 -0
  43. package/dist/src/commands/mentoring.d.ts +3 -0
  44. package/dist/src/commands/mentoring.d.ts.map +1 -0
  45. package/dist/src/commands/mentoring.js +154 -0
  46. package/dist/src/commands/mentoring.js.map +1 -0
  47. package/dist/src/commands/notice.d.ts +3 -0
  48. package/dist/src/commands/notice.d.ts.map +1 -0
  49. package/dist/src/commands/notice.js +42 -0
  50. package/dist/src/commands/notice.js.map +1 -0
  51. package/dist/src/commands/room.d.ts +3 -0
  52. package/dist/src/commands/room.d.ts.map +1 -0
  53. package/dist/src/commands/room.js +79 -0
  54. package/dist/src/commands/room.js.map +1 -0
  55. package/dist/src/commands/team.d.ts +3 -0
  56. package/dist/src/commands/team.d.ts.map +1 -0
  57. package/dist/src/commands/team.js +20 -0
  58. package/dist/src/commands/team.js.map +1 -0
  59. package/dist/src/constants.d.ts +43 -0
  60. package/dist/src/constants.d.ts.map +1 -0
  61. package/dist/src/constants.js +62 -0
  62. package/dist/src/constants.js.map +1 -0
  63. package/dist/src/credential-manager.d.ts +15 -0
  64. package/dist/src/credential-manager.d.ts.map +1 -0
  65. package/dist/src/credential-manager.js +40 -0
  66. package/dist/src/credential-manager.js.map +1 -0
  67. package/dist/src/formatters.d.ts +15 -0
  68. package/dist/src/formatters.d.ts.map +1 -0
  69. package/dist/src/formatters.js +382 -0
  70. package/dist/src/formatters.js.map +1 -0
  71. package/dist/src/http.d.ts +32 -0
  72. package/dist/src/http.d.ts.map +1 -0
  73. package/dist/src/http.js +143 -0
  74. package/dist/src/http.js.map +1 -0
  75. package/dist/src/index.d.ts +7 -0
  76. package/dist/src/index.d.ts.map +1 -0
  77. package/dist/src/index.js +6 -0
  78. package/dist/src/index.js.map +1 -0
  79. package/dist/src/shared/utils/error-handler.d.ts +2 -0
  80. package/dist/src/shared/utils/error-handler.d.ts.map +1 -0
  81. package/dist/src/shared/utils/error-handler.js +7 -0
  82. package/dist/src/shared/utils/error-handler.js.map +1 -0
  83. package/dist/src/shared/utils/mentoring-params.d.ts +15 -0
  84. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -0
  85. package/dist/src/shared/utils/mentoring-params.js +39 -0
  86. package/dist/src/shared/utils/mentoring-params.js.map +1 -0
  87. package/dist/src/shared/utils/output.d.ts +2 -0
  88. package/dist/src/shared/utils/output.d.ts.map +1 -0
  89. package/dist/src/shared/utils/output.js +4 -0
  90. package/dist/src/shared/utils/output.js.map +1 -0
  91. package/dist/src/shared/utils/stderr.d.ts +5 -0
  92. package/dist/src/shared/utils/stderr.d.ts.map +1 -0
  93. package/dist/src/shared/utils/stderr.js +19 -0
  94. package/dist/src/shared/utils/stderr.js.map +1 -0
  95. package/dist/src/shared/utils/swmaestro.d.ts +33 -0
  96. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -0
  97. package/dist/src/shared/utils/swmaestro.js +164 -0
  98. package/dist/src/shared/utils/swmaestro.js.map +1 -0
  99. package/dist/src/token-extractor.d.ts +23 -0
  100. package/dist/src/token-extractor.d.ts.map +1 -0
  101. package/dist/src/token-extractor.js +163 -0
  102. package/dist/src/token-extractor.js.map +1 -0
  103. package/dist/src/types.d.ts +176 -0
  104. package/dist/src/types.d.ts.map +1 -0
  105. package/dist/src/types.js +110 -0
  106. package/dist/src/types.js.map +1 -0
  107. package/e2e/.gitkeep +0 -0
  108. package/package.json +56 -0
  109. package/scripts/postbuild.ts +11 -0
  110. package/scripts/prepublish.ts +9 -0
  111. package/scripts/test.ts +82 -0
  112. package/skills/opensoma/SKILL.md +345 -0
  113. package/skills/opensoma/references/common-patterns.md +182 -0
  114. package/skills/opensoma/references/output-format.md +130 -0
  115. package/src/cli.ts +57 -0
  116. package/src/client.test.ts +210 -0
  117. package/src/client.ts +264 -0
  118. package/src/commands/auth.ts +153 -0
  119. package/src/commands/dashboard.ts +39 -0
  120. package/src/commands/event.ts +74 -0
  121. package/src/commands/helpers.ts +12 -0
  122. package/src/commands/index.ts +8 -0
  123. package/src/commands/member.ts +29 -0
  124. package/src/commands/mentoring.ts +209 -0
  125. package/src/commands/notice.ts +56 -0
  126. package/src/commands/room.ts +102 -0
  127. package/src/commands/team.ts +26 -0
  128. package/src/constants.ts +70 -0
  129. package/src/credential-manager.test.ts +66 -0
  130. package/src/credential-manager.ts +52 -0
  131. package/src/formatters.test.ts +382 -0
  132. package/src/formatters.ts +489 -0
  133. package/src/http.test.ts +152 -0
  134. package/src/http.ts +196 -0
  135. package/src/index.ts +6 -0
  136. package/src/shared/utils/error-handler.ts +7 -0
  137. package/src/shared/utils/mentoring-params.test.ts +112 -0
  138. package/src/shared/utils/mentoring-params.ts +57 -0
  139. package/src/shared/utils/output.ts +3 -0
  140. package/src/shared/utils/stderr.ts +23 -0
  141. package/src/shared/utils/swmaestro.ts +218 -0
  142. package/src/token-extractor.test.ts +119 -0
  143. package/src/token-extractor.ts +205 -0
  144. package/src/types.test.ts +172 -0
  145. package/src/types.ts +134 -0
  146. package/tsconfig.json +38 -0
package/src/cli.ts ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from 'commander'
4
+
5
+ import pkg from '../package.json' with { type: 'json' }
6
+ import {
7
+ authCommand,
8
+ dashboardCommand,
9
+ eventCommand,
10
+ memberCommand,
11
+ mentoringCommand,
12
+ noticeCommand,
13
+ roomCommand,
14
+ teamCommand,
15
+ } from './commands/index'
16
+
17
+ function isAuthCommand(command: Command): boolean {
18
+ let current: Command | null = command
19
+ while (current) {
20
+ if (current.name() === 'auth') {
21
+ return true
22
+ }
23
+ current = current.parent
24
+ }
25
+ return false
26
+ }
27
+
28
+ const program = new Command()
29
+
30
+ program.name('opensoma').description('SWMaestro MyPage CLI for AI agents').version(pkg.version)
31
+
32
+ program.hook('preAction', async (_thisCommand, actionCommand) => {
33
+ if (isAuthCommand(actionCommand)) {
34
+ return
35
+ }
36
+
37
+ const { CredentialManager } = await import('./credential-manager')
38
+ const manager = new CredentialManager()
39
+ const creds = await manager.getCredentials()
40
+ if (!creds) {
41
+ console.error(JSON.stringify({ error: 'Not logged in. Run: opensoma auth login' }))
42
+ process.exit(1)
43
+ }
44
+ })
45
+
46
+ program.addCommand(authCommand)
47
+ program.addCommand(mentoringCommand)
48
+ program.addCommand(roomCommand)
49
+ program.addCommand(dashboardCommand)
50
+ program.addCommand(noticeCommand)
51
+ program.addCommand(teamCommand)
52
+ program.addCommand(memberCommand)
53
+ program.addCommand(eventCommand)
54
+
55
+ program.parse(process.argv)
56
+
57
+ export { program }
@@ -0,0 +1,210 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ import { SomaClient } from '@/client'
4
+ import { MENU_NO } from '@/constants'
5
+ import { SomaHttp } from '@/http'
6
+
7
+ afterEach(() => {
8
+ mock.restore()
9
+ })
10
+
11
+ describe('SomaClient', () => {
12
+ test('constructor initializes SomaHttp with provided session state', () => {
13
+ const client = new SomaClient({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
14
+ const http = Reflect.get(client, 'http') as SomaHttp
15
+
16
+ expect(http.getSessionCookie()).toBe('session-1')
17
+ expect(http.getCsrfToken()).toBe('csrf-1')
18
+ })
19
+
20
+ test('mentoring list calls GET and parses list plus pagination', async () => {
21
+ const client = new SomaClient()
22
+ const calls: Array<{ method: string; path: string; data: Record<string, string> | undefined }> = []
23
+ Reflect.set(client, 'http', {
24
+ get: async (path: string, data?: Record<string, string>) => {
25
+ calls.push({ method: 'get', path, data })
26
+ return '<table><tbody><tr><td>1</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=123">[자유 멘토링] 제목 [접수중]</a></td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03(목) 10:00 ~ 11:00</td><td>1 /4</td><td>OK</td><td>[접수중]</td><td>작성자</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
27
+ },
28
+ })
29
+
30
+ const result = await client.mentoring.list({ status: 'open', type: 'public', page: 2 })
31
+
32
+ expect(calls).toEqual([
33
+ {
34
+ method: 'get',
35
+ path: '/mypage/mentoLec/list.do',
36
+ data: { menuNo: MENU_NO.MENTORING, searchStatMentolec: 'A', searchGubunMentolec: 'MRC010', pageIndex: '2' },
37
+ },
38
+ ])
39
+ expect(result.items[0]?.title).toBe('제목')
40
+ expect(result.pagination).toEqual({ total: 1, currentPage: 1, totalPages: 1 })
41
+ })
42
+
43
+ test('mentoring get calls detail endpoint and parser', async () => {
44
+ const client = new SomaClient()
45
+ let captured: { path: string; data: Record<string, string> | undefined } | undefined
46
+ Reflect.set(client, 'http', {
47
+ get: async (path: string, data?: Record<string, string>) => {
48
+ captured = { path, data }
49
+ return '<input type="hidden" name="qustnrSn" value="99"><table><tr><th>모집 명</th><td>상세</td><th>상태</th><td>접수중</td></tr><tr><th>접수 기간</th><td>2026-04-01 ~ 2026-04-02</td><th>강의날짜</th><td>2026-04-03(목) 10:00 ~ 11:00</td></tr><tr><th>장소</th><td>온라인(Webex)</td><th>모집인원</th><td>4명</td></tr><tr><th>작성자</th><td>작성자</td><th>등록일</th><td>2026-04-01</td></tr></table><div data-content><p>본문</p></div>'
50
+ },
51
+ })
52
+
53
+ const result = await client.mentoring.get(99)
54
+
55
+ expect(captured).toEqual({ path: '/mypage/mentoLec/view.do', data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' } })
56
+ expect(result).toMatchObject({ id: 99, title: '상세', venue: '온라인(Webex)' })
57
+ })
58
+
59
+ test('mutating operations post expected payloads', async () => {
60
+ const client = new SomaClient()
61
+ const calls: Array<{ path: string; data: Record<string, string> }> = []
62
+ Reflect.set(client, 'http', {
63
+ post: async (path: string, data: Record<string, string>) => {
64
+ calls.push({ path, data })
65
+ return ''
66
+ },
67
+ })
68
+
69
+ await client.mentoring.create({
70
+ title: '새 멘토링',
71
+ type: 'lecture',
72
+ date: '2026-04-01',
73
+ startTime: '10:00',
74
+ endTime: '11:00',
75
+ venue: '온라인(Webex)',
76
+ maxAttendees: 10,
77
+ })
78
+ await client.mentoring.delete(7)
79
+ await client.mentoring.apply(8)
80
+ await client.mentoring.cancel({ applySn: 9, qustnrSn: 10 })
81
+ await client.room.reserve({ roomId: 17, date: '2026-04-01', slots: ['10:00', '10:30'], title: '회의' })
82
+ await client.event.apply(11)
83
+
84
+ expect(calls.map((call) => call.path)).toEqual([
85
+ '/mypage/mentoLec/insert.do',
86
+ '/mypage/mentoLec/delete.do',
87
+ '/application/application/application.do',
88
+ '/mypage/userAnswer/cancel.do',
89
+ '/mypage/itemRent/insert.do',
90
+ '/application/application/application.do',
91
+ ])
92
+ expect(calls[0]?.data).toMatchObject({ menuNo: MENU_NO.MENTORING, reportCd: 'MRC020', qustnrSj: '새 멘토링' })
93
+ expect(calls[1]?.data).toEqual({ menuNo: MENU_NO.MENTORING, qustnrSn: '7', pageQueryString: '' })
94
+ expect(calls[2]?.data).toEqual({ menuNo: MENU_NO.EVENT, qustnrSn: '8', applyGb: 'C', stepHeader: '0' })
95
+ expect(calls[3]?.data).toEqual({ menuNo: MENU_NO.APPLICATION_HISTORY, applySn: '9', qustnrSn: '10' })
96
+ expect(calls[4]?.data).toMatchObject({
97
+ menuNo: MENU_NO.ROOM,
98
+ itemId: '17',
99
+ title: '회의',
100
+ 'time[0]': '10:00',
101
+ 'time[1]': '10:30',
102
+ })
103
+ expect(calls[5]?.data).toEqual({ menuNo: MENU_NO.EVENT, qustnrSn: '11', applyGb: 'C', stepHeader: '0' })
104
+ })
105
+
106
+ test('room, dashboard, notice, team, member, event, and history routes use expected endpoints', async () => {
107
+ const client = new SomaClient()
108
+ const calls: Array<{ method: string; path: string; data: Record<string, string> | undefined }> = []
109
+ Reflect.set(client, 'http', {
110
+ checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
111
+ get: async (path: string, data?: Record<string, string>) => {
112
+ calls.push({ method: 'get', path, data })
113
+ if (path === '/mypage/myMain/dashboard.do') {
114
+ return '<ul class="dash-top"><li class="dash-card"><div class="dash-etc"><span>소속 :<br> Indent</span><span>직책 :<br> </span></div><div class="dash-state"><div class="top"><span class="bg-orange label"><span>멘토</span></span><div class="welcome"><strong>전수열</strong>님 안녕하세요.</div></div></div></li></ul><ul class="bbs-dash_w"><li>멘토링 · 멘토특강<li><a href="/sw/mypage/mentoLec/view.do?qustnrSn=9582">게임 개발 AI 활용법 접수중</a></li></li></ul>'
115
+ }
116
+ if (path === '/mypage/mentoLec/list.do') {
117
+ return '<table><tbody><tr><td>1</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=100">[멘토 특강] 내 멘토링 [접수중]</a></td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03(목) 10:00 ~ 11:00</td><td>1 /4</td><td>OK</td><td>[접수중]</td><td>전수열</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
118
+ }
119
+ if (path === '/mypage/myNotice/list.do') {
120
+ return '<table><tbody><tr><td></td><td><a href="/sw/mypage/myNotice/view.do?nttId=1">공지</a></td><td>관리자</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
121
+ }
122
+ if (path === '/mypage/myNotice/view.do') {
123
+ return '<input name="nttId" value="1"><table><tr><th>제목</th><td>공지</td><th>작성자</th><td>관리자</td></tr><tr><th>등록일</th><td>2026-04-01</td><th>번호</th><td>1</td></tr></table><div class="board-view-content"><p>상세</p></div>'
124
+ }
125
+ if (path === '/mypage/myTeam/team.do') {
126
+ return '<ul class="bbs-team"><li><div class="top"><strong class="t"><a href="javascript:void(0);">오픈소마</a></strong></div><p>팀원 3명</p><button type="button">참여중</button></li></ul><p class="ico-team">현재 참여중인 방은 <strong class="color-blue">1</strong>/100팀 입니다</p>'
127
+ }
128
+ if (path === '/mypage/myInfo/forUpdateMy.do') {
129
+ return '<dl><dt><span class="point">아이디</span></dt><dd>neo@example.com</dd></dl><dl><dt><span class="point">이름</span></dt><dd>전수열</dd></dl><dl><dt><span class="point">성별</span></dt><dd>남자</dd></dl><dl><dt><span class="point">생년월일</span></dt><dd>1995-01-14</dd></dl><dl><dt><span class="point">연락처</span></dt><dd>01012345678</dd></dl><dl><dt><span class="point">소속</span></dt><dd>Indent</dd></dl><dl><dt><span class="point">직책</span></dt><dd></dd></dl>'
130
+ }
131
+ if (path === '/mypage/applicants/list.do') {
132
+ return '<table><tbody><tr><td>1</td><td>행사</td><td>행사</td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03 ~ 2026-04-03</td><td>[접수중]</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
133
+ }
134
+ if (path === '/mypage/applicants/view.do') {
135
+ return '<input name="bbsId" value="1"><table><tr><th>제목</th><td>행사 상세</td></tr></table><div data-content><p>본문</p></div>'
136
+ }
137
+ return '<table><tbody><tr><td>1</td><td>멘토 특강</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=1">접수내역</a></td><td>전수열</td><td>2026.04.11</td><td>2026-04-01</td><td>[신청완료]</td><td>[OK]</td><td>승인대기</td><td>-</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
138
+ },
139
+ post: async (path: string, data: Record<string, string>) => {
140
+ calls.push({ method: 'post', path, data })
141
+ if (path === '/mypage/officeMng/rentTime.do') {
142
+ return '<div class="time-grid"><span>09:00</span></div>'
143
+ }
144
+ return '<ul class="bbs-reserve"><li class="item"><a href="javascript:void(0);" onclick="location.href=\'/sw/mypage/officeMng/view.do?itemId=17\';"><div class="cont"><h4 class="tit">스페이스 A1</h4><ul class="txt bul-dot grey"><li>이용기간 : 2026-04-01 ~ 2026-12-31</li><li><p>설명 : 4인</p></li><li class="time-list"><div class="time-grid"><span>09:00</span></div></li></ul></div></a></li></ul>'
145
+ },
146
+ })
147
+
148
+ const roomList = await client.room.list({ date: '2026-04-01', room: 'A1' })
149
+ const roomSlots = await client.room.available(17, '2026-04-01')
150
+ const dashboard = await client.dashboard.get()
151
+ const noticeList = await client.notice.list({ page: 2 })
152
+ const noticeDetail = await client.notice.get(1)
153
+ const team = await client.team.show()
154
+ const member = await client.member.show()
155
+ const events = await client.event.list({ page: 3 })
156
+ const eventDetail = await client.event.get(1)
157
+ const history = await client.mentoring.history({ page: 4 })
158
+
159
+ expect(roomList[0]?.itemId).toBe(17)
160
+ expect(roomSlots).toEqual([{ time: '09:00', available: true }])
161
+ expect(dashboard.name).toBe('전수열')
162
+ expect(dashboard.mentoringSessions).toEqual([
163
+ { title: '내 멘토링', url: '/mypage/mentoLec/view.do?qustnrSn=100', status: '접수중' },
164
+ ])
165
+ expect(noticeList.items[0]?.title).toBe('공지')
166
+ expect(noticeDetail).toMatchObject({ id: 1, title: '공지' })
167
+ expect(team.teams[0]?.name).toBe('오픈소마')
168
+ expect(member.email).toBe('neo@example.com')
169
+ expect(events.items[0]?.title).toBe('행사')
170
+ expect(eventDetail).toMatchObject({ id: 1, title: '행사 상세' })
171
+ expect(history.items[0]).toEqual({
172
+ id: 1,
173
+ category: '멘토 특강',
174
+ title: '접수내역',
175
+ author: '전수열',
176
+ sessionDate: '2026-04-11',
177
+ appliedAt: '2026-04-01',
178
+ applicationStatus: '신청완료',
179
+ approvalStatus: 'OK',
180
+ applicationDetail: '승인대기',
181
+ note: '-',
182
+ })
183
+
184
+ const dashboardCallIndex = calls.findIndex((c) => c.path === '/mypage/myMain/dashboard.do')
185
+ expect(dashboardCallIndex).toBeGreaterThanOrEqual(0)
186
+ const mentoringListCall = calls.find((c) => c.path === '/mypage/mentoLec/list.do')
187
+ expect(mentoringListCall?.data).toEqual({
188
+ menuNo: MENU_NO.MENTORING,
189
+ searchCnd: '2',
190
+ searchId: 'neo@example.com',
191
+ searchWrd: '전수열',
192
+ })
193
+ })
194
+
195
+ test('login and isLoggedIn delegate to SomaHttp', async () => {
196
+ const client = new SomaClient({ username: 'neo@example.com', password: 'secret' })
197
+ const calls: string[] = []
198
+ Reflect.set(client, 'http', {
199
+ login: async (username: string, password: string) => {
200
+ calls.push(`${username}:${password}`)
201
+ },
202
+ checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
203
+ })
204
+
205
+ await client.login()
206
+
207
+ expect(calls).toEqual(['neo@example.com:secret'])
208
+ await expect(client.isLoggedIn()).resolves.toBe(true)
209
+ })
210
+ })
package/src/client.ts ADDED
@@ -0,0 +1,264 @@
1
+ import { MENU_NO } from '@/constants'
2
+ import { CredentialManager } from '@/credential-manager'
3
+ import * as formatters from '@/formatters'
4
+ import { type UserIdentity, SomaHttp } from '@/http'
5
+ import { type MentoringSearchQuery, buildMentoringListParams } from '@/shared/utils/mentoring-params'
6
+ import {
7
+ buildApplicationPayload,
8
+ buildCancelApplicationPayload,
9
+ buildDeleteMentoringPayload,
10
+ buildMentoringPayload,
11
+ buildRoomReservationPayload,
12
+ parseEventDetail,
13
+ resolveRoomId,
14
+ } from '@/shared/utils/swmaestro'
15
+ import type {
16
+ ApplicationHistoryItem,
17
+ Dashboard,
18
+ EventListItem,
19
+ MemberInfo,
20
+ MentoringDetail,
21
+ MentoringListItem,
22
+ NoticeDetail,
23
+ NoticeListItem,
24
+ Pagination,
25
+ RoomCard,
26
+ TeamInfo,
27
+ } from '@/types'
28
+
29
+ export interface SomaClientOptions {
30
+ sessionCookie?: string
31
+ csrfToken?: string
32
+ username?: string
33
+ password?: string
34
+ }
35
+
36
+ export class SomaClient {
37
+ private readonly http: SomaHttp
38
+ private readonly options: SomaClientOptions
39
+
40
+ readonly mentoring: {
41
+ list(options?: {
42
+ status?: string
43
+ type?: string
44
+ search?: MentoringSearchQuery
45
+ page?: number
46
+ }): Promise<{ items: MentoringListItem[]; pagination: Pagination }>
47
+ get(id: number): Promise<MentoringDetail>
48
+ create(params: {
49
+ title: string
50
+ type: 'public' | 'lecture'
51
+ date: string
52
+ startTime: string
53
+ endTime: string
54
+ venue: string
55
+ maxAttendees?: number
56
+ regStart?: string
57
+ regEnd?: string
58
+ content?: string
59
+ }): Promise<void>
60
+ delete(id: number): Promise<void>
61
+ apply(id: number): Promise<void>
62
+ cancel(params: { applySn: number; qustnrSn: number }): Promise<void>
63
+ history(options?: { page?: number }): Promise<{ items: ApplicationHistoryItem[]; pagination: Pagination }>
64
+ }
65
+
66
+ readonly room: {
67
+ list(options?: { date?: string; room?: string }): Promise<RoomCard[]>
68
+ available(roomId: number, date: string): Promise<RoomCard['timeSlots']>
69
+ reserve(params: {
70
+ roomId: number
71
+ date: string
72
+ slots: string[]
73
+ title: string
74
+ attendees?: number
75
+ notes?: string
76
+ }): Promise<void>
77
+ }
78
+
79
+ readonly dashboard: {
80
+ get(): Promise<Dashboard>
81
+ }
82
+
83
+ readonly notice: {
84
+ list(options?: { page?: number }): Promise<{ items: NoticeListItem[]; pagination: Pagination }>
85
+ get(id: number): Promise<NoticeDetail>
86
+ }
87
+
88
+ readonly team: {
89
+ show(): Promise<TeamInfo>
90
+ }
91
+
92
+ readonly member: {
93
+ show(): Promise<MemberInfo>
94
+ }
95
+
96
+ readonly event: {
97
+ list(options?: { page?: number }): Promise<{ items: EventListItem[]; pagination: Pagination }>
98
+ get(id: number): Promise<unknown>
99
+ apply(id: number): Promise<void>
100
+ }
101
+
102
+ constructor(options: SomaClientOptions = {}) {
103
+ this.options = options
104
+ this.http = new SomaHttp({ sessionCookie: options.sessionCookie, csrfToken: options.csrfToken })
105
+
106
+ this.mentoring = {
107
+ list: async (options) => {
108
+ const user = options?.search?.me ? await this.resolveUser() : undefined
109
+ const html = await this.http.get(
110
+ '/mypage/mentoLec/list.do',
111
+ buildMentoringListParams({
112
+ status: options?.status,
113
+ type: options?.type,
114
+ page: options?.page,
115
+ search: options?.search,
116
+ user,
117
+ }),
118
+ )
119
+ return { items: formatters.parseMentoringList(html), pagination: formatters.parsePagination(html) }
120
+ },
121
+ get: async (id) =>
122
+ formatters.parseMentoringDetail(
123
+ await this.http.get('/mypage/mentoLec/view.do', { menuNo: MENU_NO.MENTORING, qustnrSn: String(id) }),
124
+ id,
125
+ ),
126
+ create: async (params) => {
127
+ await this.http.post('/mypage/mentoLec/insert.do', buildMentoringPayload(params))
128
+ },
129
+ delete: async (id) => {
130
+ await this.http.post('/mypage/mentoLec/delete.do', buildDeleteMentoringPayload(id))
131
+ },
132
+ apply: async (id) => {
133
+ await this.http.post('/application/application/application.do', buildApplicationPayload(id))
134
+ },
135
+ cancel: async (params) => {
136
+ await this.http.post('/mypage/userAnswer/cancel.do', buildCancelApplicationPayload(params))
137
+ },
138
+ history: async (options) => {
139
+ const html = await this.http.get('/mypage/userAnswer/history.do', {
140
+ menuNo: MENU_NO.APPLICATION_HISTORY,
141
+ ...(options?.page ? { pageIndex: String(options.page) } : {}),
142
+ })
143
+ return { items: formatters.parseApplicationHistory(html), pagination: formatters.parsePagination(html) }
144
+ },
145
+ }
146
+
147
+ this.room = {
148
+ list: async (options) =>
149
+ formatters.parseRoomList(
150
+ await this.http.post('/mypage/officeMng/list.do', {
151
+ menuNo: MENU_NO.ROOM,
152
+ sdate: options?.date ?? new Date().toISOString().slice(0, 10),
153
+ searchItemId: options?.room ? String(resolveRoomId(options.room)) : '',
154
+ }),
155
+ ),
156
+ available: async (roomId, date) =>
157
+ formatters.parseRoomSlots(
158
+ await this.http.post('/mypage/officeMng/rentTime.do', {
159
+ viewType: 'CONTBODY',
160
+ itemId: String(roomId),
161
+ rentDt: date,
162
+ }),
163
+ ),
164
+ reserve: async (params) => {
165
+ await this.http.post('/mypage/itemRent/insert.do', buildRoomReservationPayload(params))
166
+ },
167
+ }
168
+
169
+ this.dashboard = {
170
+ get: async () => {
171
+ const [dashboard, { items: myMentoring }] = await Promise.all([
172
+ formatters.parseDashboard(await this.http.get('/mypage/myMain/dashboard.do', { menuNo: MENU_NO.DASHBOARD })),
173
+ this.mentoring.list({ search: { field: 'author', value: '@me', me: true } }),
174
+ ])
175
+ dashboard.mentoringSessions = myMentoring.map((item) => ({
176
+ title: item.title,
177
+ url: `/mypage/mentoLec/view.do?qustnrSn=${item.id}`,
178
+ status: item.status,
179
+ }))
180
+ return dashboard
181
+ },
182
+ }
183
+
184
+ this.notice = {
185
+ list: async (options) => {
186
+ const html = await this.http.get('/mypage/myNotice/list.do', {
187
+ menuNo: MENU_NO.NOTICE,
188
+ ...(options?.page ? { pageIndex: String(options.page) } : {}),
189
+ })
190
+ return { items: formatters.parseNoticeList(html), pagination: formatters.parsePagination(html) }
191
+ },
192
+ get: async (id) =>
193
+ formatters.parseNoticeDetail(
194
+ await this.http.get('/mypage/myNotice/view.do', { menuNo: MENU_NO.NOTICE, nttId: String(id) }),
195
+ id,
196
+ ),
197
+ }
198
+
199
+ this.team = {
200
+ show: async () =>
201
+ formatters.parseTeamInfo(await this.http.get('/mypage/myTeam/team.do', { menuNo: MENU_NO.TEAM })),
202
+ }
203
+
204
+ this.member = {
205
+ show: async () =>
206
+ formatters.parseMemberInfo(
207
+ await this.http.get('/mypage/myInfo/forUpdateMy.do', { menuNo: MENU_NO.MEMBER_INFO }),
208
+ ),
209
+ }
210
+
211
+ this.event = {
212
+ list: async (options) => {
213
+ const html = await this.http.get('/mypage/applicants/list.do', {
214
+ menuNo: MENU_NO.EVENT,
215
+ ...(options?.page ? { pageIndex: String(options.page) } : {}),
216
+ })
217
+ return { items: formatters.parseEventList(html), pagination: formatters.parsePagination(html) }
218
+ },
219
+ get: async (id) =>
220
+ parseEventDetail(
221
+ await this.http.get('/mypage/applicants/view.do', { menuNo: MENU_NO.EVENT, bbsId: String(id) }),
222
+ ),
223
+ apply: async (id) => {
224
+ await this.http.post('/application/application/application.do', buildApplicationPayload(id))
225
+ },
226
+ }
227
+ }
228
+
229
+ private async resolveUser(): Promise<UserIdentity | undefined> {
230
+ const identity = await this.http.checkLogin()
231
+ return identity ?? undefined
232
+ }
233
+
234
+ async login(username?: string, password?: string): Promise<void> {
235
+ const resolvedUsername = username ?? this.options.username
236
+ const resolvedPassword = password ?? this.options.password
237
+
238
+ if (!resolvedUsername || !resolvedPassword) {
239
+ throw new Error('Username and password are required')
240
+ }
241
+
242
+ await this.http.login(resolvedUsername, resolvedPassword)
243
+ }
244
+
245
+ async isLoggedIn(): Promise<boolean> {
246
+ return Boolean(await this.http.checkLogin())
247
+ }
248
+
249
+ async saveCredentials(manager = new CredentialManager()): Promise<void> {
250
+ const sessionCookie = this.http.getSessionCookie()
251
+ const csrfToken = this.http.getCsrfToken()
252
+
253
+ if (!sessionCookie || !csrfToken) {
254
+ throw new Error('Missing session cookie or CSRF token')
255
+ }
256
+
257
+ await manager.setCredentials({
258
+ sessionCookie,
259
+ csrfToken,
260
+ username: this.options.username,
261
+ loggedInAt: new Date().toISOString(),
262
+ })
263
+ }
264
+ }
@@ -0,0 +1,153 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { CredentialManager } from '@/credential-manager'
4
+ import { SomaHttp } from '@/http'
5
+ import { handleError } from '@/shared/utils/error-handler'
6
+ import { formatOutput } from '@/shared/utils/output'
7
+ import { warn } from '@/shared/utils/stderr'
8
+
9
+ type LoginOptions = { username?: string; password?: string; pretty?: boolean }
10
+ type StatusOptions = { pretty?: boolean }
11
+ type ExtractOptions = { pretty?: boolean }
12
+
13
+ async function loginAction(options: LoginOptions): Promise<void> {
14
+ try {
15
+ const username = options.username ?? process.env.OPENSOMA_USERNAME
16
+ const password = options.password ?? process.env.OPENSOMA_PASSWORD
17
+ if (!username || !password) {
18
+ throw new Error('Provide --username and --password or set OPENSOMA_USERNAME and OPENSOMA_PASSWORD')
19
+ }
20
+
21
+ const http = new SomaHttp()
22
+ await http.login(username, password)
23
+
24
+ const sessionCookie = http.getSessionCookie()
25
+ const csrfToken = http.getCsrfToken()
26
+ if (!sessionCookie || !csrfToken) {
27
+ throw new Error('Login succeeded but session information is missing')
28
+ }
29
+
30
+ await new CredentialManager().setCredentials({
31
+ sessionCookie,
32
+ csrfToken,
33
+ username,
34
+ loggedInAt: new Date().toISOString(),
35
+ })
36
+
37
+ console.log(formatOutput({ ok: true, username, loggedIn: true }, options.pretty))
38
+ } catch (error) {
39
+ handleError(error)
40
+ }
41
+ }
42
+
43
+ async function logoutAction(options: StatusOptions): Promise<void> {
44
+ try {
45
+ await new CredentialManager().remove()
46
+ console.log(formatOutput({ ok: true, loggedIn: false }, options.pretty))
47
+ } catch (error) {
48
+ handleError(error)
49
+ }
50
+ }
51
+
52
+ async function statusAction(options: StatusOptions): Promise<void> {
53
+ try {
54
+ const manager = new CredentialManager()
55
+ const creds = await manager.getCredentials()
56
+ if (!creds) {
57
+ console.log(formatOutput({ authenticated: false, credentials: null }, options.pretty))
58
+ return
59
+ }
60
+
61
+ let valid = false
62
+ try {
63
+ const http = new SomaHttp({ sessionCookie: creds.sessionCookie, csrfToken: creds.csrfToken })
64
+ valid = Boolean(await http.checkLogin())
65
+ } catch {
66
+ valid = false
67
+ }
68
+
69
+ console.log(
70
+ formatOutput(
71
+ {
72
+ authenticated: true,
73
+ valid,
74
+ username: creds.username ?? null,
75
+ loggedInAt: creds.loggedInAt ?? null,
76
+ ...(valid ? {} : { hint: 'Session expired. Run: opensoma auth login or opensoma auth extract' }),
77
+ },
78
+ options.pretty,
79
+ ),
80
+ )
81
+ } catch (error) {
82
+ handleError(error)
83
+ }
84
+ }
85
+
86
+ async function extractAction(options: ExtractOptions): Promise<void> {
87
+ try {
88
+ const modulePath = '@/token-extractor'
89
+ const imported = (await import(modulePath)) as {
90
+ TokenExtractor: new () => { extract: () => Promise<{ sessionCookie: string } | null> }
91
+ }
92
+ const extractor = new imported.TokenExtractor()
93
+ const result = await extractor.extract()
94
+ if (!result) {
95
+ throw new Error('No SWMaestro session found in any browser. Login to swmaestro.ai in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.')
96
+ }
97
+
98
+ const http = new SomaHttp({ sessionCookie: result.sessionCookie })
99
+
100
+ let valid = false
101
+ let csrfToken: string | undefined
102
+ try {
103
+ valid = Boolean(await http.checkLogin())
104
+ if (valid) {
105
+ csrfToken = await http.extractCsrfToken()
106
+ }
107
+ } catch {
108
+ valid = false
109
+ }
110
+
111
+ if (!valid) {
112
+ warn('Warning: Could not validate session. Cookies may be expired.')
113
+ }
114
+
115
+ await new CredentialManager().setCredentials({
116
+ sessionCookie: result.sessionCookie,
117
+ csrfToken: csrfToken ?? '',
118
+ loggedInAt: new Date().toISOString(),
119
+ })
120
+ console.log(formatOutput({ ok: true, extracted: true, valid }, options.pretty))
121
+ } catch (error) {
122
+ handleError(error)
123
+ }
124
+ }
125
+
126
+ export const authCommand = new Command('auth')
127
+ .description('Manage authentication')
128
+ .addCommand(
129
+ new Command('login')
130
+ .description('Login with username and password')
131
+ .option('--username <username>', 'SWMaestro username')
132
+ .option('--password <password>', 'SWMaestro password')
133
+ .option('--pretty', 'Pretty print JSON output')
134
+ .action(loginAction),
135
+ )
136
+ .addCommand(
137
+ new Command('logout')
138
+ .description('Remove saved credentials')
139
+ .option('--pretty', 'Pretty print JSON output')
140
+ .action(logoutAction),
141
+ )
142
+ .addCommand(
143
+ new Command('status')
144
+ .description('Show authentication status')
145
+ .option('--pretty', 'Pretty print JSON output')
146
+ .action(statusAction),
147
+ )
148
+ .addCommand(
149
+ new Command('extract')
150
+ .description('Extract browser credentials')
151
+ .option('--pretty', 'Pretty print JSON output')
152
+ .action(extractAction),
153
+ )