opensoma 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/package.json +18 -2
  2. package/dist/src/client.d.ts +8 -0
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +123 -21
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +8 -0
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +35 -23
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/dashboard.d.ts.map +1 -1
  11. package/dist/src/commands/dashboard.js +1 -1
  12. package/dist/src/commands/dashboard.js.map +1 -1
  13. package/dist/src/commands/event.d.ts.map +1 -1
  14. package/dist/src/commands/event.js.map +1 -1
  15. package/dist/src/commands/helpers.d.ts.map +1 -1
  16. package/dist/src/commands/helpers.js +12 -2
  17. package/dist/src/commands/helpers.js.map +1 -1
  18. package/dist/src/commands/member.d.ts.map +1 -1
  19. package/dist/src/commands/member.js.map +1 -1
  20. package/dist/src/commands/mentoring.d.ts.map +1 -1
  21. package/dist/src/commands/mentoring.js +14 -5
  22. package/dist/src/commands/mentoring.js.map +1 -1
  23. package/dist/src/commands/notice.d.ts.map +1 -1
  24. package/dist/src/commands/notice.js.map +1 -1
  25. package/dist/src/commands/room.d.ts.map +1 -1
  26. package/dist/src/commands/room.js.map +1 -1
  27. package/dist/src/commands/team.d.ts.map +1 -1
  28. package/dist/src/commands/team.js.map +1 -1
  29. package/dist/src/errors.d.ts +8 -0
  30. package/dist/src/errors.d.ts.map +1 -0
  31. package/dist/src/errors.js +11 -0
  32. package/dist/src/errors.js.map +1 -0
  33. package/dist/src/formatters.d.ts.map +1 -1
  34. package/dist/src/formatters.js +54 -7
  35. package/dist/src/formatters.js.map +1 -1
  36. package/dist/src/http.d.ts +5 -0
  37. package/dist/src/http.d.ts.map +1 -1
  38. package/dist/src/http.js +140 -6
  39. package/dist/src/http.js.map +1 -1
  40. package/dist/src/index.d.ts +1 -0
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/index.js.map +1 -1
  44. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
  45. package/dist/src/shared/utils/mentoring-params.js +4 -1
  46. package/dist/src/shared/utils/mentoring-params.js.map +1 -1
  47. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  48. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  49. package/dist/src/token-extractor.d.ts +12 -0
  50. package/dist/src/token-extractor.d.ts.map +1 -1
  51. package/dist/src/token-extractor.js +83 -18
  52. package/dist/src/token-extractor.js.map +1 -1
  53. package/dist/src/types.d.ts +17 -0
  54. package/dist/src/types.d.ts.map +1 -1
  55. package/dist/src/types.js +6 -0
  56. package/dist/src/types.js.map +1 -1
  57. package/package.json +17 -1
  58. package/src/client.test.ts +112 -12
  59. package/src/client.ts +136 -36
  60. package/src/commands/auth.test.ts +55 -0
  61. package/src/commands/auth.ts +57 -33
  62. package/src/commands/dashboard.ts +5 -6
  63. package/src/commands/event.ts +5 -6
  64. package/src/commands/helpers.ts +21 -4
  65. package/src/commands/member.ts +4 -5
  66. package/src/commands/mentoring.ts +36 -19
  67. package/src/commands/notice.ts +4 -5
  68. package/src/commands/room.ts +4 -5
  69. package/src/commands/team.ts +4 -5
  70. package/src/credential-manager.test.ts +1 -1
  71. package/src/credential-manager.ts +1 -1
  72. package/src/errors.ts +10 -0
  73. package/src/formatters.test.ts +1 -1
  74. package/src/formatters.ts +91 -18
  75. package/src/http.test.ts +43 -7
  76. package/src/http.ts +174 -8
  77. package/src/index.ts +1 -0
  78. package/src/shared/utils/mentoring-params.test.ts +9 -4
  79. package/src/shared/utils/mentoring-params.ts +6 -3
  80. package/src/shared/utils/swmaestro.ts +2 -2
  81. package/src/token-extractor.test.ts +84 -8
  82. package/src/token-extractor.ts +118 -22
  83. package/src/types.test.ts +4 -2
  84. package/src/types.ts +6 -0
  85. package/.claude-plugin/README.md +0 -145
  86. package/.claude-plugin/plugin.json +0 -23
  87. package/.github/workflows/release.yml +0 -86
  88. package/.oxfmtrc.json +0 -9
  89. package/.oxlintrc.json +0 -4
  90. package/AGENTS.md +0 -78
  91. package/README.md +0 -252
  92. package/bun.lock +0 -297
  93. package/bunfig.toml +0 -2
  94. package/e2e/.gitkeep +0 -0
  95. package/skills/opensoma/SKILL.md +0 -345
  96. package/skills/opensoma/references/common-patterns.md +0 -182
  97. package/skills/opensoma/references/output-format.md +0 -130
@@ -1,8 +1,9 @@
1
1
  import { afterEach, describe, expect, mock, test } from 'bun:test'
2
2
 
3
- import { SomaClient } from '@/client'
4
- import { MENU_NO } from '@/constants'
5
- import { SomaHttp } from '@/http'
3
+ import { SomaClient } from './client'
4
+ import { MENU_NO } from './constants'
5
+ import { AuthenticationError } from './errors'
6
+ import { SomaHttp } from './http'
6
7
 
7
8
  afterEach(() => {
8
9
  mock.restore()
@@ -21,6 +22,7 @@ describe('SomaClient', () => {
21
22
  const client = new SomaClient()
22
23
  const calls: Array<{ method: string; path: string; data: Record<string, string> | undefined }> = []
23
24
  Reflect.set(client, 'http', {
25
+ checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
24
26
  get: async (path: string, data?: Record<string, string>) => {
25
27
  calls.push({ method: 'get', path, data })
26
28
  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>'
@@ -33,7 +35,12 @@ describe('SomaClient', () => {
33
35
  {
34
36
  method: 'get',
35
37
  path: '/mypage/mentoLec/list.do',
36
- data: { menuNo: MENU_NO.MENTORING, searchStatMentolec: 'A', searchGubunMentolec: 'MRC010', pageIndex: '2' },
38
+ data: {
39
+ menuNo: MENU_NO.MENTORING,
40
+ searchStatMentolec: 'A',
41
+ searchGubunMentolec: 'MRC010',
42
+ pageIndex: '2',
43
+ },
37
44
  },
38
45
  ])
39
46
  expect(result.items[0]?.title).toBe('제목')
@@ -44,6 +51,7 @@ describe('SomaClient', () => {
44
51
  const client = new SomaClient()
45
52
  let captured: { path: string; data: Record<string, string> | undefined } | undefined
46
53
  Reflect.set(client, 'http', {
54
+ checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
47
55
  get: async (path: string, data?: Record<string, string>) => {
48
56
  captured = { path, data }
49
57
  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>'
@@ -52,7 +60,10 @@ describe('SomaClient', () => {
52
60
 
53
61
  const result = await client.mentoring.get(99)
54
62
 
55
- expect(captured).toEqual({ path: '/mypage/mentoLec/view.do', data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' } })
63
+ expect(captured).toEqual({
64
+ path: '/mypage/mentoLec/view.do',
65
+ data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' },
66
+ })
56
67
  expect(result).toMatchObject({ id: 99, title: '상세', venue: '온라인(Webex)' })
57
68
  })
58
69
 
@@ -60,6 +71,7 @@ describe('SomaClient', () => {
60
71
  const client = new SomaClient()
61
72
  const calls: Array<{ path: string; data: Record<string, string> }> = []
62
73
  Reflect.set(client, 'http', {
74
+ checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
63
75
  post: async (path: string, data: Record<string, string>) => {
64
76
  calls.push({ path, data })
65
77
  return ''
@@ -78,7 +90,12 @@ describe('SomaClient', () => {
78
90
  await client.mentoring.delete(7)
79
91
  await client.mentoring.apply(8)
80
92
  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: '회의' })
93
+ await client.room.reserve({
94
+ roomId: 17,
95
+ date: '2026-04-01',
96
+ slots: ['10:00', '10:30'],
97
+ title: '회의',
98
+ })
82
99
  await client.event.apply(11)
83
100
 
84
101
  expect(calls.map((call) => call.path)).toEqual([
@@ -89,10 +106,27 @@ describe('SomaClient', () => {
89
106
  '/mypage/itemRent/insert.do',
90
107
  '/application/application/application.do',
91
108
  ])
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' })
109
+ expect(calls[0]?.data).toMatchObject({
110
+ menuNo: MENU_NO.MENTORING,
111
+ reportCd: 'MRC020',
112
+ qustnrSj: '새 멘토링',
113
+ })
114
+ expect(calls[1]?.data).toEqual({
115
+ menuNo: MENU_NO.MENTORING,
116
+ qustnrSn: '7',
117
+ pageQueryString: '',
118
+ })
119
+ expect(calls[2]?.data).toEqual({
120
+ menuNo: MENU_NO.EVENT,
121
+ qustnrSn: '8',
122
+ applyGb: 'C',
123
+ stepHeader: '0',
124
+ })
125
+ expect(calls[3]?.data).toEqual({
126
+ menuNo: MENU_NO.APPLICATION_HISTORY,
127
+ applySn: '9',
128
+ qustnrSn: '10',
129
+ })
96
130
  expect(calls[4]?.data).toMatchObject({
97
131
  menuNo: MENU_NO.ROOM,
98
132
  itemId: '17',
@@ -100,7 +134,12 @@ describe('SomaClient', () => {
100
134
  'time[0]': '10:00',
101
135
  'time[1]': '10:30',
102
136
  })
103
- expect(calls[5]?.data).toEqual({ menuNo: MENU_NO.EVENT, qustnrSn: '11', applyGb: 'C', stepHeader: '0' })
137
+ expect(calls[5]?.data).toEqual({
138
+ menuNo: MENU_NO.EVENT,
139
+ qustnrSn: '11',
140
+ applyGb: 'C',
141
+ stepHeader: '0',
142
+ })
104
143
  })
105
144
 
106
145
  test('room, dashboard, notice, team, member, event, and history routes use expected endpoints', async () => {
@@ -160,7 +199,15 @@ describe('SomaClient', () => {
160
199
  expect(roomSlots).toEqual([{ time: '09:00', available: true }])
161
200
  expect(dashboard.name).toBe('전수열')
162
201
  expect(dashboard.mentoringSessions).toEqual([
163
- { title: '내 멘토링', url: '/mypage/mentoLec/view.do?qustnrSn=100', status: '접수중' },
202
+ {
203
+ title: '내 멘토링',
204
+ url: '/mypage/mentoLec/view.do?qustnrSn=100',
205
+ status: '접수중',
206
+ date: '2026-04-03',
207
+ time: '10:00',
208
+ timeEnd: '11:00',
209
+ type: '멘토 특강',
210
+ },
164
211
  ])
165
212
  expect(noticeList.items[0]?.title).toBe('공지')
166
213
  expect(noticeDetail).toMatchObject({ id: 1, title: '공지' })
@@ -207,4 +254,57 @@ describe('SomaClient', () => {
207
254
  expect(calls).toEqual(['neo@example.com:secret'])
208
255
  await expect(client.isLoggedIn()).resolves.toBe(true)
209
256
  })
257
+
258
+ test('auth-required operations throw AuthenticationError when not logged in', async () => {
259
+ const client = new SomaClient()
260
+ Reflect.set(client, 'http', {
261
+ checkLogin: async () => null,
262
+ })
263
+
264
+ await expect(client.mentoring.list()).rejects.toBeInstanceOf(AuthenticationError)
265
+ await expect(client.mentoring.get(1)).rejects.toBeInstanceOf(AuthenticationError)
266
+ await expect(
267
+ client.mentoring.create({
268
+ title: 'Test',
269
+ type: 'public',
270
+ date: '2026-04-01',
271
+ startTime: '10:00',
272
+ endTime: '11:00',
273
+ venue: 'Test',
274
+ }),
275
+ ).rejects.toBeInstanceOf(AuthenticationError)
276
+ await expect(client.mentoring.delete(1)).rejects.toBeInstanceOf(AuthenticationError)
277
+ await expect(client.mentoring.apply(1)).rejects.toBeInstanceOf(AuthenticationError)
278
+ await expect(client.mentoring.cancel({ applySn: 1, qustnrSn: 2 })).rejects.toBeInstanceOf(AuthenticationError)
279
+ await expect(client.mentoring.history()).rejects.toBeInstanceOf(AuthenticationError)
280
+ await expect(client.room.list()).rejects.toBeInstanceOf(AuthenticationError)
281
+ await expect(client.room.available(1, '2026-04-01')).rejects.toBeInstanceOf(AuthenticationError)
282
+ await expect(
283
+ client.room.reserve({ roomId: 1, date: '2026-04-01', slots: ['10:00'], title: 'Test' }),
284
+ ).rejects.toBeInstanceOf(AuthenticationError)
285
+ await expect(client.dashboard.get()).rejects.toBeInstanceOf(AuthenticationError)
286
+ await expect(client.notice.list()).rejects.toBeInstanceOf(AuthenticationError)
287
+ await expect(client.notice.get(1)).rejects.toBeInstanceOf(AuthenticationError)
288
+ await expect(client.team.show()).rejects.toBeInstanceOf(AuthenticationError)
289
+ await expect(client.member.show()).rejects.toBeInstanceOf(AuthenticationError)
290
+ await expect(client.event.list()).rejects.toBeInstanceOf(AuthenticationError)
291
+ await expect(client.event.get(1)).rejects.toBeInstanceOf(AuthenticationError)
292
+ await expect(client.event.apply(1)).rejects.toBeInstanceOf(AuthenticationError)
293
+ })
294
+
295
+ test('AuthenticationError has helpful message', async () => {
296
+ const client = new SomaClient()
297
+ Reflect.set(client, 'http', {
298
+ checkLogin: async () => null,
299
+ })
300
+
301
+ try {
302
+ await client.mentoring.list()
303
+ expect.fail('Should have thrown')
304
+ } catch (error) {
305
+ expect(error).toBeInstanceOf(AuthenticationError)
306
+ expect((error as Error).message).toContain('opensoma auth login')
307
+ expect((error as Error).message).toContain('opensoma auth extract')
308
+ }
309
+ })
210
310
  })
package/src/client.ts CHANGED
@@ -1,8 +1,9 @@
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'
1
+ import { MENU_NO } from './constants'
2
+ import { CredentialManager } from './credential-manager'
3
+ import { AuthenticationError } from './errors'
4
+ import * as formatters from './formatters'
5
+ import { type UserIdentity, SomaHttp } from './http'
6
+ import { type MentoringSearchQuery, buildMentoringListParams } from './shared/utils/mentoring-params'
6
7
  import {
7
8
  buildApplicationPayload,
8
9
  buildCancelApplicationPayload,
@@ -11,7 +12,7 @@ import {
11
12
  buildRoomReservationPayload,
12
13
  parseEventDetail,
13
14
  resolveRoomId,
14
- } from '@/shared/utils/swmaestro'
15
+ } from './shared/utils/swmaestro'
15
16
  import type {
16
17
  ApplicationHistoryItem,
17
18
  Dashboard,
@@ -24,13 +25,14 @@ import type {
24
25
  Pagination,
25
26
  RoomCard,
26
27
  TeamInfo,
27
- } from '@/types'
28
+ } from './types'
28
29
 
29
30
  export interface SomaClientOptions {
30
31
  sessionCookie?: string
31
32
  csrfToken?: string
32
33
  username?: string
33
34
  password?: string
35
+ verbose?: boolean
34
36
  }
35
37
 
36
38
  export class SomaClient {
@@ -101,10 +103,15 @@ export class SomaClient {
101
103
 
102
104
  constructor(options: SomaClientOptions = {}) {
103
105
  this.options = options
104
- this.http = new SomaHttp({ sessionCookie: options.sessionCookie, csrfToken: options.csrfToken })
106
+ this.http = new SomaHttp({
107
+ sessionCookie: options.sessionCookie,
108
+ csrfToken: options.csrfToken,
109
+ verbose: options.verbose,
110
+ })
105
111
 
106
112
  this.mentoring = {
107
113
  list: async (options) => {
114
+ await this.requireAuth()
108
115
  const user = options?.search?.me ? await this.resolveUser() : undefined
109
116
  const html = await this.http.get(
110
117
  '/mypage/mentoLec/list.do',
@@ -116,58 +123,83 @@ export class SomaClient {
116
123
  user,
117
124
  }),
118
125
  )
119
- return { items: formatters.parseMentoringList(html), pagination: formatters.parsePagination(html) }
126
+ return {
127
+ items: formatters.parseMentoringList(html),
128
+ pagination: formatters.parsePagination(html),
129
+ }
120
130
  },
121
- get: async (id) =>
122
- formatters.parseMentoringDetail(
123
- await this.http.get('/mypage/mentoLec/view.do', { menuNo: MENU_NO.MENTORING, qustnrSn: String(id) }),
131
+ get: async (id) => {
132
+ await this.requireAuth()
133
+ return formatters.parseMentoringDetail(
134
+ await this.http.get('/mypage/mentoLec/view.do', {
135
+ menuNo: MENU_NO.MENTORING,
136
+ qustnrSn: String(id),
137
+ }),
124
138
  id,
125
- ),
139
+ )
140
+ },
126
141
  create: async (params) => {
127
- await this.http.post('/mypage/mentoLec/insert.do', buildMentoringPayload(params))
142
+ await this.requireAuth()
143
+ const html = await this.http.post('/mypage/mentoLec/insert.do', buildMentoringPayload(params))
144
+ if (this.containsErrorIndicator(html)) {
145
+ throw new Error(this.extractErrorMessage(html) || '멘토링 등록에 실패했습니다.')
146
+ }
128
147
  },
129
148
  delete: async (id) => {
149
+ await this.requireAuth()
130
150
  await this.http.post('/mypage/mentoLec/delete.do', buildDeleteMentoringPayload(id))
131
151
  },
132
152
  apply: async (id) => {
153
+ await this.requireAuth()
133
154
  await this.http.post('/application/application/application.do', buildApplicationPayload(id))
134
155
  },
135
156
  cancel: async (params) => {
157
+ await this.requireAuth()
136
158
  await this.http.post('/mypage/userAnswer/cancel.do', buildCancelApplicationPayload(params))
137
159
  },
138
160
  history: async (options) => {
161
+ await this.requireAuth()
139
162
  const html = await this.http.get('/mypage/userAnswer/history.do', {
140
163
  menuNo: MENU_NO.APPLICATION_HISTORY,
141
164
  ...(options?.page ? { pageIndex: String(options.page) } : {}),
142
165
  })
143
- return { items: formatters.parseApplicationHistory(html), pagination: formatters.parsePagination(html) }
166
+ return {
167
+ items: formatters.parseApplicationHistory(html),
168
+ pagination: formatters.parsePagination(html),
169
+ }
144
170
  },
145
171
  }
146
172
 
147
173
  this.room = {
148
- list: async (options) =>
149
- formatters.parseRoomList(
174
+ list: async (options) => {
175
+ await this.requireAuth()
176
+ return formatters.parseRoomList(
150
177
  await this.http.post('/mypage/officeMng/list.do', {
151
178
  menuNo: MENU_NO.ROOM,
152
179
  sdate: options?.date ?? new Date().toISOString().slice(0, 10),
153
180
  searchItemId: options?.room ? String(resolveRoomId(options.room)) : '',
154
181
  }),
155
- ),
156
- available: async (roomId, date) =>
157
- formatters.parseRoomSlots(
182
+ )
183
+ },
184
+ available: async (roomId, date) => {
185
+ await this.requireAuth()
186
+ return formatters.parseRoomSlots(
158
187
  await this.http.post('/mypage/officeMng/rentTime.do', {
159
188
  viewType: 'CONTBODY',
160
189
  itemId: String(roomId),
161
190
  rentDt: date,
162
191
  }),
163
- ),
192
+ )
193
+ },
164
194
  reserve: async (params) => {
195
+ await this.requireAuth()
165
196
  await this.http.post('/mypage/itemRent/insert.do', buildRoomReservationPayload(params))
166
197
  },
167
198
  }
168
199
 
169
200
  this.dashboard = {
170
201
  get: async () => {
202
+ await this.requireAuth()
171
203
  const [dashboard, { items: myMentoring }] = await Promise.all([
172
204
  formatters.parseDashboard(await this.http.get('/mypage/myMain/dashboard.do', { menuNo: MENU_NO.DASHBOARD })),
173
205
  this.mentoring.list({ search: { field: 'author', value: '@me', me: true } }),
@@ -176,6 +208,10 @@ export class SomaClient {
176
208
  title: item.title,
177
209
  url: `/mypage/mentoLec/view.do?qustnrSn=${item.id}`,
178
210
  status: item.status,
211
+ date: item.sessionDate,
212
+ time: item.sessionTime.start,
213
+ timeEnd: item.sessionTime.end,
214
+ type: item.type,
179
215
  }))
180
216
  return dashboard
181
217
  },
@@ -183,49 +219,86 @@ export class SomaClient {
183
219
 
184
220
  this.notice = {
185
221
  list: async (options) => {
222
+ await this.requireAuth()
186
223
  const html = await this.http.get('/mypage/myNotice/list.do', {
187
224
  menuNo: MENU_NO.NOTICE,
188
225
  ...(options?.page ? { pageIndex: String(options.page) } : {}),
189
226
  })
190
- return { items: formatters.parseNoticeList(html), pagination: formatters.parsePagination(html) }
227
+ return {
228
+ items: formatters.parseNoticeList(html),
229
+ pagination: formatters.parsePagination(html),
230
+ }
191
231
  },
192
- get: async (id) =>
193
- formatters.parseNoticeDetail(
194
- await this.http.get('/mypage/myNotice/view.do', { menuNo: MENU_NO.NOTICE, nttId: String(id) }),
232
+ get: async (id) => {
233
+ await this.requireAuth()
234
+ return formatters.parseNoticeDetail(
235
+ await this.http.get('/mypage/myNotice/view.do', {
236
+ menuNo: MENU_NO.NOTICE,
237
+ nttId: String(id),
238
+ }),
195
239
  id,
196
- ),
240
+ )
241
+ },
197
242
  }
198
243
 
199
244
  this.team = {
200
- show: async () =>
201
- formatters.parseTeamInfo(await this.http.get('/mypage/myTeam/team.do', { menuNo: MENU_NO.TEAM })),
245
+ show: async () => {
246
+ await this.requireAuth()
247
+ return formatters.parseTeamInfo(await this.http.get('/mypage/myTeam/team.do', { menuNo: MENU_NO.TEAM }))
248
+ },
202
249
  }
203
250
 
204
251
  this.member = {
205
- show: async () =>
206
- formatters.parseMemberInfo(
252
+ show: async () => {
253
+ await this.requireAuth()
254
+ return formatters.parseMemberInfo(
207
255
  await this.http.get('/mypage/myInfo/forUpdateMy.do', { menuNo: MENU_NO.MEMBER_INFO }),
208
- ),
256
+ )
257
+ },
209
258
  }
210
259
 
211
260
  this.event = {
212
261
  list: async (options) => {
262
+ await this.requireAuth()
213
263
  const html = await this.http.get('/mypage/applicants/list.do', {
214
264
  menuNo: MENU_NO.EVENT,
215
265
  ...(options?.page ? { pageIndex: String(options.page) } : {}),
216
266
  })
217
- return { items: formatters.parseEventList(html), pagination: formatters.parsePagination(html) }
267
+ return {
268
+ items: formatters.parseEventList(html),
269
+ pagination: formatters.parsePagination(html),
270
+ }
271
+ },
272
+ get: async (id) => {
273
+ await this.requireAuth()
274
+ return parseEventDetail(
275
+ await this.http.get('/mypage/applicants/view.do', {
276
+ menuNo: MENU_NO.EVENT,
277
+ bbsId: String(id),
278
+ }),
279
+ )
218
280
  },
219
- get: async (id) =>
220
- parseEventDetail(
221
- await this.http.get('/mypage/applicants/view.do', { menuNo: MENU_NO.EVENT, bbsId: String(id) }),
222
- ),
223
281
  apply: async (id) => {
282
+ await this.requireAuth()
224
283
  await this.http.post('/application/application/application.do', buildApplicationPayload(id))
225
284
  },
226
285
  }
227
286
  }
228
287
 
288
+ getSessionData(): { sessionCookie: string | undefined; csrfToken: string | null } {
289
+ return {
290
+ sessionCookie: this.http.getSessionCookie(),
291
+ csrfToken: this.http.getCsrfToken(),
292
+ }
293
+ }
294
+
295
+ private async requireAuth(): Promise<void> {
296
+ const identity = await this.http.checkLogin()
297
+ if (!identity) {
298
+ throw new AuthenticationError()
299
+ }
300
+ }
301
+
229
302
  private async resolveUser(): Promise<UserIdentity | undefined> {
230
303
  const identity = await this.http.checkLogin()
231
304
  return identity ?? undefined
@@ -261,4 +334,31 @@ export class SomaClient {
261
334
  loggedInAt: new Date().toISOString(),
262
335
  })
263
336
  }
337
+
338
+ private containsErrorIndicator(html: string): boolean {
339
+ const errorPatterns = [
340
+ 'class="error"',
341
+ 'class="alert-danger"',
342
+ 'alert-error',
343
+ '오류가 발생했습니다',
344
+ '등록에 실패했습니다',
345
+ '실패하였습니다',
346
+ '잘못된 접근',
347
+ '권한이 없습니다',
348
+ '<script>alert(',
349
+ ]
350
+ return errorPatterns.some((pattern) => html.includes(pattern))
351
+ }
352
+
353
+ private extractErrorMessage(html: string): string | null {
354
+ const alertMatch = html.match(/<script>alert\(['"](.+?)['"]\)/)
355
+ if (alertMatch) {
356
+ return alertMatch[1]
357
+ }
358
+ const errorDivMatch = html.match(/class="error[^"]*"[^>]*>\s*([^<]+)/)
359
+ if (errorDivMatch) {
360
+ return errorDivMatch[1].trim()
361
+ }
362
+ return null
363
+ }
264
364
  }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { resolveExtractedCredentials } from './auth'
4
+
5
+ describe('resolveExtractedCredentials', () => {
6
+ test('returns the first candidate that validates successfully', async () => {
7
+ const calls: string[] = []
8
+
9
+ const credentials = await resolveExtractedCredentials(
10
+ [
11
+ { browser: 'Chrome', lastAccessUtc: 30, profile: 'Default', sessionCookie: 'stale-session' },
12
+ { browser: 'Chrome', lastAccessUtc: 20, profile: 'Profile 1', sessionCookie: 'valid-session' },
13
+ ],
14
+ (sessionCookie) => ({
15
+ checkLogin: async () => {
16
+ calls.push(`check:${sessionCookie}`)
17
+ return sessionCookie === 'valid-session' ? { userId: 'neo', userNm: 'Neo' } : null
18
+ },
19
+ extractCsrfToken: async () => {
20
+ calls.push(`csrf:${sessionCookie}`)
21
+ return `${sessionCookie}-csrf`
22
+ },
23
+ }),
24
+ )
25
+
26
+ expect(credentials).toEqual({
27
+ sessionCookie: 'valid-session',
28
+ csrfToken: 'valid-session-csrf',
29
+ })
30
+ expect(calls).toEqual(['check:stale-session', 'check:valid-session', 'csrf:valid-session'])
31
+ })
32
+
33
+ test('returns null when every candidate is invalid or throws', async () => {
34
+ const credentials = await resolveExtractedCredentials(
35
+ [
36
+ { browser: 'Chrome', lastAccessUtc: 30, profile: 'Default', sessionCookie: 'stale-session' },
37
+ { browser: 'Edge', lastAccessUtc: 20, profile: 'Profile 1', sessionCookie: 'broken-session' },
38
+ ],
39
+ (sessionCookie) => ({
40
+ checkLogin: async () => {
41
+ if (sessionCookie === 'broken-session') {
42
+ throw new Error('network error')
43
+ }
44
+
45
+ return null
46
+ },
47
+ extractCsrfToken: async () => {
48
+ throw new Error('should not be called')
49
+ },
50
+ }),
51
+ )
52
+
53
+ expect(credentials).toBeNull()
54
+ })
55
+ })
@@ -1,14 +1,39 @@
1
1
  import { Command } from 'commander'
2
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'
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 type { ExtractedSessionCandidate } from '../token-extractor'
8
8
 
9
9
  type LoginOptions = { username?: string; password?: string; pretty?: boolean }
10
10
  type StatusOptions = { pretty?: boolean }
11
11
  type ExtractOptions = { pretty?: boolean }
12
+ type ExtractedSessionValidator = Pick<SomaHttp, 'checkLogin' | 'extractCsrfToken'>
13
+
14
+ export async function resolveExtractedCredentials(
15
+ candidates: ExtractedSessionCandidate[],
16
+ createValidator: (sessionCookie: string) => ExtractedSessionValidator = (sessionCookie) =>
17
+ new SomaHttp({ sessionCookie }),
18
+ ): Promise<{ csrfToken: string; sessionCookie: string } | null> {
19
+ for (const candidate of candidates) {
20
+ const http = createValidator(candidate.sessionCookie)
21
+
22
+ try {
23
+ const valid = Boolean(await http.checkLogin())
24
+ if (!valid) {
25
+ continue
26
+ }
27
+
28
+ return {
29
+ sessionCookie: candidate.sessionCookie,
30
+ csrfToken: await http.extractCsrfToken(),
31
+ }
32
+ } catch {}
33
+ }
34
+
35
+ return null
36
+ }
12
37
 
13
38
  async function loginAction(options: LoginOptions): Promise<void> {
14
39
  try {
@@ -21,10 +46,19 @@ async function loginAction(options: LoginOptions): Promise<void> {
21
46
  const http = new SomaHttp()
22
47
  await http.login(username, password)
23
48
 
24
- const sessionCookie = http.getSessionCookie()
25
49
  const csrfToken = http.getCsrfToken()
26
- if (!sessionCookie || !csrfToken) {
27
- throw new Error('Login succeeded but session information is missing')
50
+ if (!csrfToken) {
51
+ throw new Error('Login succeeded but CSRF token is missing')
52
+ }
53
+
54
+ const valid = Boolean(await http.checkLogin())
55
+ if (!valid) {
56
+ throw new Error('Login succeeded but session is not valid')
57
+ }
58
+
59
+ const sessionCookie = http.getSessionCookie()
60
+ if (!sessionCookie) {
61
+ throw new Error('Login succeeded but session cookie is missing')
28
62
  }
29
63
 
30
64
  await new CredentialManager().setCredentials({
@@ -85,40 +119,30 @@ async function statusAction(options: StatusOptions): Promise<void> {
85
119
 
86
120
  async function extractAction(options: ExtractOptions): Promise<void> {
87
121
  try {
88
- const { TokenExtractor } = (await import(
89
- '../token-extractor'
90
- )) as {
91
- TokenExtractor: new () => { extract: () => Promise<{ sessionCookie: string } | null> }
122
+ const { TokenExtractor } = (await import('../token-extractor')) as {
123
+ TokenExtractor: new () => { extractCandidates: () => Promise<ExtractedSessionCandidate[]> }
92
124
  }
93
125
  const extractor = new TokenExtractor()
94
- const result = await extractor.extract()
95
- if (!result) {
96
- 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.')
126
+ const candidates = await extractor.extractCandidates()
127
+ if (candidates.length === 0) {
128
+ throw new Error(
129
+ 'No SWMaestro session found in any browser. Login to swmaestro.ai in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
130
+ )
97
131
  }
98
132
 
99
- const http = new SomaHttp({ sessionCookie: result.sessionCookie })
100
-
101
- let valid = false
102
- let csrfToken: string | undefined
103
- try {
104
- valid = Boolean(await http.checkLogin())
105
- if (valid) {
106
- csrfToken = await http.extractCsrfToken()
107
- }
108
- } catch {
109
- valid = false
110
- }
111
-
112
- if (!valid) {
113
- warn('Warning: Could not validate session. Cookies may be expired.')
133
+ const credentials = await resolveExtractedCredentials(candidates)
134
+ if (!credentials) {
135
+ throw new Error(
136
+ 'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai login in a supported Chromium browser and try again.',
137
+ )
114
138
  }
115
139
 
116
140
  await new CredentialManager().setCredentials({
117
- sessionCookie: result.sessionCookie,
118
- csrfToken: csrfToken ?? '',
141
+ sessionCookie: credentials.sessionCookie,
142
+ csrfToken: credentials.csrfToken,
119
143
  loggedInAt: new Date().toISOString(),
120
144
  })
121
- console.log(formatOutput({ ok: true, extracted: true, valid }, options.pretty))
145
+ console.log(formatOutput({ ok: true, extracted: true, valid: true }, options.pretty))
122
146
  } catch (error) {
123
147
  handleError(error)
124
148
  }