opensoma 0.2.0 → 0.3.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 (87) hide show
  1. package/dist/package.json +2 -2
  2. package/dist/src/cli.d.ts.map +1 -1
  3. package/dist/src/cli.js +2 -1
  4. package/dist/src/cli.js.map +1 -1
  5. package/dist/src/client.d.ts +26 -1
  6. package/dist/src/client.d.ts.map +1 -1
  7. package/dist/src/client.js +141 -3
  8. package/dist/src/client.js.map +1 -1
  9. package/dist/src/commands/auth.d.ts +12 -0
  10. package/dist/src/commands/auth.d.ts.map +1 -1
  11. package/dist/src/commands/auth.js +151 -23
  12. package/dist/src/commands/auth.js.map +1 -1
  13. package/dist/src/commands/helpers.d.ts +12 -0
  14. package/dist/src/commands/helpers.d.ts.map +1 -1
  15. package/dist/src/commands/helpers.js +55 -9
  16. package/dist/src/commands/helpers.js.map +1 -1
  17. package/dist/src/commands/index.d.ts +1 -0
  18. package/dist/src/commands/index.d.ts.map +1 -1
  19. package/dist/src/commands/index.js +1 -0
  20. package/dist/src/commands/index.js.map +1 -1
  21. package/dist/src/commands/mentoring.d.ts.map +1 -1
  22. package/dist/src/commands/mentoring.js +37 -1
  23. package/dist/src/commands/mentoring.js.map +1 -1
  24. package/dist/src/commands/report.d.ts +27 -0
  25. package/dist/src/commands/report.d.ts.map +1 -0
  26. package/dist/src/commands/report.js +224 -0
  27. package/dist/src/commands/report.js.map +1 -0
  28. package/dist/src/constants.d.ts +2 -0
  29. package/dist/src/constants.d.ts.map +1 -1
  30. package/dist/src/constants.js +2 -0
  31. package/dist/src/constants.js.map +1 -1
  32. package/dist/src/credential-manager.d.ts +7 -0
  33. package/dist/src/credential-manager.d.ts.map +1 -1
  34. package/dist/src/credential-manager.js +76 -2
  35. package/dist/src/credential-manager.js.map +1 -1
  36. package/dist/src/formatters.d.ts +4 -1
  37. package/dist/src/formatters.d.ts.map +1 -1
  38. package/dist/src/formatters.js +91 -1
  39. package/dist/src/formatters.js.map +1 -1
  40. package/dist/src/http.d.ts +3 -0
  41. package/dist/src/http.d.ts.map +1 -1
  42. package/dist/src/http.js +109 -4
  43. package/dist/src/http.js.map +1 -1
  44. package/dist/src/session-recovery.d.ts +12 -0
  45. package/dist/src/session-recovery.d.ts.map +1 -0
  46. package/dist/src/session-recovery.js +34 -0
  47. package/dist/src/session-recovery.js.map +1 -0
  48. package/dist/src/shared/utils/report-params.d.ts +11 -0
  49. package/dist/src/shared/utils/report-params.d.ts.map +1 -0
  50. package/dist/src/shared/utils/report-params.js +27 -0
  51. package/dist/src/shared/utils/report-params.js.map +1 -0
  52. package/dist/src/shared/utils/swmaestro.d.ts +24 -0
  53. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  54. package/dist/src/shared/utils/swmaestro.js +50 -2
  55. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  56. package/dist/src/token-extractor.d.ts.map +1 -1
  57. package/dist/src/token-extractor.js +38 -8
  58. package/dist/src/token-extractor.js.map +1 -1
  59. package/dist/src/types.d.ts +121 -0
  60. package/dist/src/types.d.ts.map +1 -1
  61. package/dist/src/types.js +72 -0
  62. package/dist/src/types.js.map +1 -1
  63. package/package.json +2 -2
  64. package/src/cli.ts +2 -0
  65. package/src/client.test.ts +95 -6
  66. package/src/client.ts +172 -4
  67. package/src/commands/auth.test.ts +152 -1
  68. package/src/commands/auth.ts +174 -28
  69. package/src/commands/helpers.test.ts +216 -0
  70. package/src/commands/helpers.ts +77 -12
  71. package/src/commands/index.ts +1 -0
  72. package/src/commands/mentoring.ts +55 -0
  73. package/src/commands/report.test.ts +49 -0
  74. package/src/commands/report.ts +322 -0
  75. package/src/constants.ts +2 -0
  76. package/src/credential-manager.test.ts +41 -1
  77. package/src/credential-manager.ts +103 -2
  78. package/src/formatters.test.ts +287 -0
  79. package/src/formatters.ts +105 -0
  80. package/src/http.test.ts +190 -4
  81. package/src/http.ts +132 -4
  82. package/src/session-recovery.ts +56 -0
  83. package/src/shared/utils/report-params.ts +41 -0
  84. package/src/shared/utils/swmaestro.ts +77 -5
  85. package/src/token-extractor.ts +59 -20
  86. package/src/types.test.ts +97 -0
  87. package/src/types.ts +84 -0
@@ -2,14 +2,86 @@ import { Command } from 'commander'
2
2
 
3
3
  import { CredentialManager } from '../credential-manager'
4
4
  import { SomaHttp } from '../http'
5
+ import { recoverSession } from '../session-recovery'
5
6
  import { handleError } from '../shared/utils/error-handler'
6
7
  import { formatOutput } from '../shared/utils/output'
7
8
  import type { ExtractedSessionCandidate } from '../token-extractor'
8
9
 
10
+ async function promptInput(message: string): Promise<string> {
11
+ process.stdout.write(message)
12
+ const input = await Bun.stdin.text()
13
+ return input.trim()
14
+ }
15
+
16
+ async function promptPassword(message: string): Promise<string> {
17
+ process.stdout.write(message)
18
+
19
+ const originalStdin = process.stdin.isTTY
20
+ ? (process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void })
21
+ : null
22
+
23
+ if (originalStdin?.setRawMode) {
24
+ originalStdin.setRawMode(true)
25
+ }
26
+
27
+ let password = ''
28
+ const decoder = new TextDecoder()
29
+
30
+ try {
31
+ for await (const chunk of Bun.stdin.stream()) {
32
+ const text = decoder.decode(chunk)
33
+ for (const char of text) {
34
+ const code = char.charCodeAt(0)
35
+ if (code === 13 || code === 10) {
36
+ // Enter key
37
+ process.stdout.write('\n')
38
+ return password
39
+ } else if (code === 3) {
40
+ // Ctrl+C
41
+ process.exit(1)
42
+ } else if (code === 127) {
43
+ // Backspace
44
+ if (password.length > 0) {
45
+ password = password.slice(0, -1)
46
+ process.stdout.write('\b \b')
47
+ }
48
+ } else if (code >= 32 && code <= 126) {
49
+ // Printable characters
50
+ password += char
51
+ process.stdout.write('*')
52
+ }
53
+ }
54
+ }
55
+ } finally {
56
+ if (originalStdin?.setRawMode) {
57
+ originalStdin.setRawMode(false)
58
+ }
59
+ }
60
+
61
+ return password
62
+ }
63
+
9
64
  type LoginOptions = { username?: string; password?: string; pretty?: boolean }
10
65
  type StatusOptions = { pretty?: boolean }
11
66
  type ExtractOptions = { pretty?: boolean }
12
67
  type ExtractedSessionValidator = Pick<SomaHttp, 'checkLogin' | 'extractCsrfToken'>
68
+ type CredentialStore = Pick<CredentialManager, 'getCredentials' | 'remove' | 'setCredentials'>
69
+ type StatusValidator = Pick<SomaHttp, 'checkLogin'>
70
+ type ReloginHttp = Pick<SomaHttp, 'checkLogin' | 'getCsrfToken' | 'getSessionCookie' | 'login'>
71
+ type BrowserExtractor = () => Promise<{ csrfToken: string; sessionCookie: string } | null>
72
+
73
+ async function defaultExtractBrowserCredentials(): Promise<{ csrfToken: string; sessionCookie: string } | null> {
74
+ const { TokenExtractor } = (await import('../token-extractor')) as {
75
+ TokenExtractor: new () => { extractCandidates: () => Promise<ExtractedSessionCandidate[]> }
76
+ }
77
+ const candidates = await new TokenExtractor().extractCandidates()
78
+ if (candidates.length === 0) return null
79
+ return resolveExtractedCredentials(candidates)
80
+ }
81
+
82
+ const EXPIRED_SESSION_HINT = 'Session expired. Run: opensoma auth login or opensoma auth extract'
83
+ const UNVERIFIED_SESSION_HINT =
84
+ 'Could not verify session. Try again or run: opensoma auth login or opensoma auth extract'
13
85
 
14
86
  export async function resolveExtractedCredentials(
15
87
  candidates: ExtractedSessionCandidate[],
@@ -37,10 +109,18 @@ export async function resolveExtractedCredentials(
37
109
 
38
110
  async function loginAction(options: LoginOptions): Promise<void> {
39
111
  try {
40
- const username = options.username ?? process.env.OPENSOMA_USERNAME
41
- const password = options.password ?? process.env.OPENSOMA_PASSWORD
112
+ let username = options.username ?? process.env.OPENSOMA_USERNAME
113
+ let password = options.password ?? process.env.OPENSOMA_PASSWORD
114
+
115
+ if (!username) {
116
+ username = await promptInput('Username: ')
117
+ }
118
+ if (!password) {
119
+ password = await promptPassword('Password: ')
120
+ }
121
+
42
122
  if (!username || !password) {
43
- throw new Error('Provide --username and --password or set OPENSOMA_USERNAME and OPENSOMA_PASSWORD')
123
+ throw new Error('Username and password are required')
44
124
  }
45
125
 
46
126
  const http = new SomaHttp()
@@ -65,6 +145,7 @@ async function loginAction(options: LoginOptions): Promise<void> {
65
145
  sessionCookie,
66
146
  csrfToken,
67
147
  username,
148
+ password,
68
149
  loggedInAt: new Date().toISOString(),
69
150
  })
70
151
 
@@ -76,42 +157,107 @@ async function loginAction(options: LoginOptions): Promise<void> {
76
157
 
77
158
  async function logoutAction(options: StatusOptions): Promise<void> {
78
159
  try {
79
- await new CredentialManager().remove()
80
- console.log(formatOutput({ ok: true, loggedIn: false }, options.pretty))
160
+ const manager = new CredentialManager()
161
+ const credentials = await manager.getCredentials()
162
+ let upstreamLoggedOut = false
163
+
164
+ if (credentials) {
165
+ const http = new SomaHttp({ sessionCookie: credentials.sessionCookie, csrfToken: credentials.csrfToken })
166
+
167
+ try {
168
+ await http.logout()
169
+ upstreamLoggedOut = true
170
+ } catch {}
171
+ }
172
+
173
+ await manager.remove()
174
+ console.log(formatOutput({ ok: true, loggedIn: false, upstreamLoggedOut }, options.pretty))
81
175
  } catch (error) {
82
176
  handleError(error)
83
177
  }
84
178
  }
85
179
 
86
- async function statusAction(options: StatusOptions): Promise<void> {
180
+ export async function inspectStoredAuthStatus(
181
+ manager: CredentialStore = new CredentialManager(),
182
+ createValidator: (credentials: { sessionCookie: string; csrfToken: string }) => StatusValidator = (credentials) =>
183
+ new SomaHttp({ sessionCookie: credentials.sessionCookie, csrfToken: credentials.csrfToken }),
184
+ createReloginHttp: () => ReloginHttp = () => new SomaHttp(),
185
+ recoverViaBrowser: BrowserExtractor = defaultExtractBrowserCredentials,
186
+ ): Promise<Record<string, boolean | null | string>> {
187
+ const creds = await manager.getCredentials()
188
+ if (!creds) {
189
+ return { authenticated: false, credentials: null }
190
+ }
191
+
192
+ let identity = null
87
193
  try {
88
- const manager = new CredentialManager()
89
- const creds = await manager.getCredentials()
90
- if (!creds) {
91
- console.log(formatOutput({ authenticated: false, credentials: null }, options.pretty))
92
- return
194
+ identity = await createValidator(creds).checkLogin()
195
+ } catch {
196
+ return {
197
+ authenticated: true,
198
+ valid: false,
199
+ username: creds.username ?? null,
200
+ loggedInAt: creds.loggedInAt ?? null,
201
+ hint: UNVERIFIED_SESSION_HINT,
93
202
  }
203
+ }
94
204
 
95
- let valid = false
205
+ if (!identity) {
96
206
  try {
97
- const http = new SomaHttp({ sessionCookie: creds.sessionCookie, csrfToken: creds.csrfToken })
98
- valid = Boolean(await http.checkLogin())
207
+ const refreshedCredentials = await recoverSession(creds, manager, createReloginHttp)
208
+ if (refreshedCredentials) {
209
+ return {
210
+ authenticated: true,
211
+ valid: true,
212
+ username: refreshedCredentials.username ?? null,
213
+ loggedInAt: refreshedCredentials.loggedInAt ?? null,
214
+ }
215
+ }
99
216
  } catch {
100
- valid = false
217
+ return {
218
+ authenticated: true,
219
+ valid: false,
220
+ username: creds.username ?? null,
221
+ loggedInAt: creds.loggedInAt ?? null,
222
+ hint: UNVERIFIED_SESSION_HINT,
223
+ }
101
224
  }
102
225
 
103
- console.log(
104
- formatOutput(
105
- {
106
- authenticated: true,
107
- valid,
108
- username: creds.username ?? null,
109
- loggedInAt: creds.loggedInAt ?? null,
110
- ...(valid ? {} : { hint: 'Session expired. Run: opensoma auth login or opensoma auth extract' }),
111
- },
112
- options.pretty,
113
- ),
114
- )
226
+ try {
227
+ const extracted = await recoverViaBrowser()
228
+ if (extracted) {
229
+ const loggedInAt = new Date().toISOString()
230
+ await manager.setCredentials({
231
+ sessionCookie: extracted.sessionCookie,
232
+ csrfToken: extracted.csrfToken,
233
+ loggedInAt,
234
+ })
235
+ return { authenticated: true, valid: true, username: null, loggedInAt }
236
+ }
237
+ } catch {
238
+ // Browser extraction failed — fall through to credential removal
239
+ }
240
+
241
+ await manager.remove()
242
+ return {
243
+ authenticated: false,
244
+ credentials: null,
245
+ clearedStaleCredentials: true,
246
+ hint: EXPIRED_SESSION_HINT,
247
+ }
248
+ }
249
+
250
+ return {
251
+ authenticated: true,
252
+ valid: true,
253
+ username: creds.username ?? null,
254
+ loggedInAt: creds.loggedInAt ?? null,
255
+ }
256
+ }
257
+
258
+ async function statusAction(options: StatusOptions): Promise<void> {
259
+ try {
260
+ console.log(formatOutput(await inspectStoredAuthStatus(), options.pretty))
115
261
  } catch (error) {
116
262
  handleError(error)
117
263
  }
@@ -160,7 +306,7 @@ export const authCommand = new Command('auth')
160
306
  )
161
307
  .addCommand(
162
308
  new Command('logout')
163
- .description('Remove saved credentials')
309
+ .description('Log out upstream session and remove saved credentials')
164
310
  .option('--pretty', 'Pretty print JSON output')
165
311
  .action(logoutAction),
166
312
  )
@@ -0,0 +1,216 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { createAuthenticatedHttp } from './helpers'
4
+
5
+ const noBrowserExtraction = async () => null
6
+
7
+ describe('createAuthenticatedHttp', () => {
8
+ test('throws a login hint when no credentials are stored', async () => {
9
+ const manager = {
10
+ getCredentials: async () => null,
11
+ remove: async () => {},
12
+ }
13
+
14
+ await expect(createAuthenticatedHttp(manager)).rejects.toThrow(
15
+ 'Not logged in. Run: opensoma auth login or opensoma auth extract',
16
+ )
17
+ })
18
+
19
+ test('clears stale credentials when both recovery methods fail', async () => {
20
+ let removed = false
21
+ const manager = {
22
+ getCredentials: async () => ({
23
+ sessionCookie: 'stale-session',
24
+ csrfToken: 'csrf-token',
25
+ }),
26
+ setCredentials: async () => {
27
+ throw new Error('should not save unrecoverable credentials')
28
+ },
29
+ remove: async () => {
30
+ removed = true
31
+ },
32
+ }
33
+
34
+ await expect(
35
+ createAuthenticatedHttp(manager, () => ({ checkLogin: async () => null }), undefined, noBrowserExtraction),
36
+ ).rejects.toThrow(
37
+ 'Session expired. Saved credentials were cleared. Run: opensoma auth login or opensoma auth extract',
38
+ )
39
+ expect(removed).toBe(true)
40
+ })
41
+
42
+ test('returns the authenticated http client when the session is valid', async () => {
43
+ const http = {
44
+ checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
45
+ get: async () => '',
46
+ }
47
+ const manager = {
48
+ getCredentials: async () => ({
49
+ sessionCookie: 'valid-session',
50
+ csrfToken: 'csrf-token',
51
+ }),
52
+ setCredentials: async () => {
53
+ throw new Error('should not rewrite valid credentials')
54
+ },
55
+ remove: async () => {
56
+ throw new Error('should not remove valid credentials')
57
+ },
58
+ }
59
+
60
+ await expect(createAuthenticatedHttp(manager, () => http)).resolves.toBe(http)
61
+ })
62
+
63
+ test('re-authenticates automatically when stored username/password are available', async () => {
64
+ let savedCredentials: Record<string, string> | null = null
65
+ const manager = {
66
+ getCredentials: async () => ({
67
+ sessionCookie: 'stale-session',
68
+ csrfToken: 'stale-csrf',
69
+ username: 'neo@example.com',
70
+ password: 'secret',
71
+ }),
72
+ setCredentials: async (credentials: Record<string, string>) => {
73
+ savedCredentials = credentials
74
+ },
75
+ remove: async () => {
76
+ throw new Error('should not remove recoverable credentials')
77
+ },
78
+ }
79
+ const recoveredHttp = {
80
+ checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
81
+ get: async () => '',
82
+ }
83
+
84
+ await expect(
85
+ createAuthenticatedHttp(
86
+ manager,
87
+ (credentials) => {
88
+ if (credentials.sessionCookie === 'fresh-session') {
89
+ return recoveredHttp
90
+ }
91
+
92
+ return {
93
+ checkLogin: async () => null,
94
+ }
95
+ },
96
+ () => ({
97
+ login: async () => {},
98
+ checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
99
+ getSessionCookie: () => 'fresh-session',
100
+ getCsrfToken: () => 'fresh-csrf',
101
+ }),
102
+ ),
103
+ ).resolves.toBe(recoveredHttp)
104
+
105
+ expect(savedCredentials).toMatchObject({
106
+ sessionCookie: 'fresh-session',
107
+ csrfToken: 'fresh-csrf',
108
+ username: 'neo@example.com',
109
+ password: 'secret',
110
+ })
111
+ })
112
+
113
+ test('recovers via browser extraction when no stored password is available', async () => {
114
+ let savedCredentials: Record<string, unknown> | null = null
115
+ const manager = {
116
+ getCredentials: async () => ({
117
+ sessionCookie: 'stale-session',
118
+ csrfToken: 'stale-csrf',
119
+ }),
120
+ setCredentials: async (credentials: Record<string, unknown>) => {
121
+ savedCredentials = credentials
122
+ },
123
+ remove: async () => {
124
+ throw new Error('should not remove when browser extraction succeeds')
125
+ },
126
+ }
127
+ const recoveredHttp = {
128
+ checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
129
+ }
130
+
131
+ const result = await createAuthenticatedHttp(
132
+ manager,
133
+ (credentials) => {
134
+ if (credentials.sessionCookie === 'browser-session') return recoveredHttp
135
+ return { checkLogin: async () => null }
136
+ },
137
+ undefined,
138
+ async () => ({ sessionCookie: 'browser-session', csrfToken: 'browser-csrf' }),
139
+ )
140
+
141
+ expect(result).toBe(recoveredHttp)
142
+ expect(savedCredentials).toMatchObject({
143
+ sessionCookie: 'browser-session',
144
+ csrfToken: 'browser-csrf',
145
+ })
146
+ expect(savedCredentials).toHaveProperty('loggedInAt')
147
+ })
148
+
149
+ test('falls back to browser extraction when password re-login throws', async () => {
150
+ let savedCredentials: Record<string, unknown> | null = null
151
+ const manager = {
152
+ getCredentials: async () => ({
153
+ sessionCookie: 'stale-session',
154
+ csrfToken: 'stale-csrf',
155
+ username: 'neo@example.com',
156
+ password: 'wrong-password',
157
+ }),
158
+ setCredentials: async (credentials: Record<string, unknown>) => {
159
+ savedCredentials = credentials
160
+ },
161
+ remove: async () => {
162
+ throw new Error('should not remove when browser extraction succeeds')
163
+ },
164
+ }
165
+ const recoveredHttp = {
166
+ checkLogin: async () => ({ userId: 'neo@example.com', userNm: 'Neo' }),
167
+ }
168
+
169
+ const result = await createAuthenticatedHttp(
170
+ manager,
171
+ (credentials) => {
172
+ if (credentials.sessionCookie === 'browser-session') return recoveredHttp
173
+ return { checkLogin: async () => null }
174
+ },
175
+ () => ({
176
+ login: async () => {
177
+ throw new Error('wrong password')
178
+ },
179
+ checkLogin: async () => null,
180
+ getSessionCookie: () => null,
181
+ getCsrfToken: () => null,
182
+ }),
183
+ async () => ({ sessionCookie: 'browser-session', csrfToken: 'browser-csrf' }),
184
+ )
185
+
186
+ expect(result).toBe(recoveredHttp)
187
+ expect(savedCredentials).toMatchObject({
188
+ sessionCookie: 'browser-session',
189
+ csrfToken: 'browser-csrf',
190
+ })
191
+ })
192
+
193
+ test('does not attempt browser extraction when no credentials exist', async () => {
194
+ let browserExtractionCalled = false
195
+ const manager = {
196
+ getCredentials: async () => null,
197
+ setCredentials: async () => {
198
+ throw new Error('should not save')
199
+ },
200
+ remove: async () => {},
201
+ }
202
+
203
+ await expect(
204
+ createAuthenticatedHttp(
205
+ manager,
206
+ () => ({ checkLogin: async () => null }),
207
+ undefined,
208
+ async () => {
209
+ browserExtractionCalled = true
210
+ return { sessionCookie: 's', csrfToken: 'c' }
211
+ },
212
+ ),
213
+ ).rejects.toThrow('Not logged in')
214
+ expect(browserExtractionCalled).toBe(false)
215
+ })
216
+ })
@@ -1,29 +1,94 @@
1
1
  import { CredentialManager } from '../credential-manager'
2
2
  import { SomaHttp } from '../http'
3
+ import { recoverSession } from '../session-recovery'
4
+ import * as stderr from '../shared/utils/stderr'
5
+ import type { Credentials } from '../types'
3
6
 
4
- export async function getHttpOrExit(): Promise<SomaHttp> {
5
- const manager = new CredentialManager()
7
+ type CredentialStore = Pick<CredentialManager, 'getCredentials' | 'remove' | 'setCredentials'>
8
+ type AuthenticatedHttp = Pick<SomaHttp, 'checkLogin'>
9
+ type ReloginHttp = Pick<SomaHttp, 'checkLogin' | 'getCsrfToken' | 'getSessionCookie' | 'login'>
10
+ type BrowserExtractor = () => Promise<{ csrfToken: string; sessionCookie: string } | null>
11
+
12
+ const NOT_LOGGED_IN_MESSAGE = 'Not logged in. Run: opensoma auth login or opensoma auth extract'
13
+ const STALE_SESSION_MESSAGE =
14
+ 'Session expired. Saved credentials were cleared. Run: opensoma auth login or opensoma auth extract'
15
+
16
+ function defaultCreateHttp(credentials: Credentials): SomaHttp {
17
+ return new SomaHttp({ sessionCookie: credentials.sessionCookie, csrfToken: credentials.csrfToken })
18
+ }
19
+
20
+ async function defaultExtractBrowserCredentials(): Promise<{ csrfToken: string; sessionCookie: string } | null> {
21
+ const { TokenExtractor } = await import('../token-extractor')
22
+ const { resolveExtractedCredentials } = await import('./auth')
23
+ const candidates = await new TokenExtractor().extractCandidates()
24
+ if (candidates.length === 0) return null
25
+ return resolveExtractedCredentials(candidates)
26
+ }
27
+
28
+ export function createAuthenticatedHttp(): Promise<SomaHttp>
29
+ export function createAuthenticatedHttp<T extends AuthenticatedHttp>(
30
+ manager: CredentialStore,
31
+ createHttp: (credentials: Credentials) => T,
32
+ createReloginHttp?: () => ReloginHttp,
33
+ recoverViaBrowser?: BrowserExtractor,
34
+ ): Promise<T>
35
+ export async function createAuthenticatedHttp<T extends AuthenticatedHttp>(
36
+ manager: CredentialStore = new CredentialManager(),
37
+ createHttp?: (credentials: Credentials) => T,
38
+ createReloginHttp: () => ReloginHttp = () => new SomaHttp(),
39
+ recoverViaBrowser: BrowserExtractor = defaultExtractBrowserCredentials,
40
+ ): Promise<SomaHttp | T> {
6
41
  const creds = await manager.getCredentials()
7
42
  if (!creds) {
8
- console.error(
9
- JSON.stringify({
10
- error: 'Not logged in. Run: opensoma auth login or opensoma auth extract',
11
- }),
12
- )
13
- process.exit(1)
43
+ throw new Error(NOT_LOGGED_IN_MESSAGE)
14
44
  }
15
45
 
16
- const http = new SomaHttp({ sessionCookie: creds.sessionCookie, csrfToken: creds.csrfToken })
46
+ const http = createHttp ? createHttp(creds) : defaultCreateHttp(creds)
17
47
 
18
48
  const identity = await http.checkLogin()
19
49
  if (!identity) {
50
+ try {
51
+ const refreshedCredentials = await recoverSession(creds, manager, createReloginHttp)
52
+ if (refreshedCredentials) {
53
+ return createHttp ? createHttp(refreshedCredentials) : defaultCreateHttp(refreshedCredentials)
54
+ }
55
+ } catch {
56
+ // Password recovery failed — try browser extraction next
57
+ }
58
+
59
+ try {
60
+ stderr.info('Session expired. Attempting browser token extraction...')
61
+ const extracted = await recoverViaBrowser()
62
+ if (extracted) {
63
+ const browserCredentials: Credentials = {
64
+ sessionCookie: extracted.sessionCookie,
65
+ csrfToken: extracted.csrfToken,
66
+ loggedInAt: new Date().toISOString(),
67
+ }
68
+ await manager.setCredentials(browserCredentials)
69
+ stderr.info('Browser token extraction successful.')
70
+ return createHttp ? createHttp(browserCredentials) : defaultCreateHttp(browserCredentials)
71
+ }
72
+ } catch {
73
+ // Browser extraction also failed
74
+ }
75
+
76
+ await manager.remove()
77
+ throw new Error(STALE_SESSION_MESSAGE)
78
+ }
79
+
80
+ return http
81
+ }
82
+
83
+ export async function getHttpOrExit(): Promise<SomaHttp> {
84
+ try {
85
+ return await createAuthenticatedHttp()
86
+ } catch (error) {
20
87
  console.error(
21
88
  JSON.stringify({
22
- error: 'Session expired. Run: opensoma auth login or opensoma auth extract',
89
+ error: error instanceof Error ? error.message : STALE_SESSION_MESSAGE,
23
90
  }),
24
91
  )
25
92
  process.exit(1)
26
93
  }
27
-
28
- return http
29
94
  }
@@ -6,3 +6,4 @@ export { noticeCommand } from './notice'
6
6
  export { teamCommand } from './team'
7
7
  export { memberCommand } from './member'
8
8
  export { eventCommand } from './event'
9
+ export { reportCommand } from './report'
@@ -10,6 +10,7 @@ import {
10
10
  buildCancelApplicationPayload,
11
11
  buildDeleteMentoringPayload,
12
12
  buildMentoringPayload,
13
+ buildUpdateMentoringPayload,
13
14
  } from '../shared/utils/swmaestro'
14
15
  import { getHttpOrExit } from './helpers'
15
16
 
@@ -34,6 +35,19 @@ type CreateOptions = {
34
35
  content?: string
35
36
  pretty?: boolean
36
37
  }
38
+ type UpdateOptions = {
39
+ title: string
40
+ type: 'public' | 'lecture'
41
+ date: string
42
+ start: string
43
+ end: string
44
+ venue: string
45
+ maxAttendees?: string
46
+ regStart?: string
47
+ regEnd?: string
48
+ content?: string
49
+ pretty?: boolean
50
+ }
37
51
  type CancelOptions = { applySn: string; qustnrSn: string; pretty?: boolean }
38
52
  type HistoryOptions = { page?: string; pretty?: boolean }
39
53
 
@@ -103,6 +117,30 @@ async function createAction(options: CreateOptions): Promise<void> {
103
117
  }
104
118
  }
105
119
 
120
+ async function updateAction(id: string, options: UpdateOptions): Promise<void> {
121
+ try {
122
+ const http = await getHttpOrExit()
123
+ await http.post(
124
+ '/mypage/mentoLec/update.do',
125
+ buildUpdateMentoringPayload(Number.parseInt(id, 10), {
126
+ title: options.title,
127
+ type: options.type,
128
+ date: options.date,
129
+ startTime: options.start,
130
+ endTime: options.end,
131
+ venue: options.venue,
132
+ maxAttendees: options.maxAttendees ? Number.parseInt(options.maxAttendees, 10) : undefined,
133
+ regStart: options.regStart,
134
+ regEnd: options.regEnd,
135
+ content: options.content,
136
+ }),
137
+ )
138
+ console.log(formatOutput({ ok: true }, options.pretty))
139
+ } catch (error) {
140
+ handleError(error)
141
+ }
142
+ }
143
+
106
144
  async function deleteAction(id: string, options: GetOptions): Promise<void> {
107
145
  try {
108
146
  const http = await getHttpOrExit()
@@ -195,6 +233,23 @@ export const mentoringCommand = new Command('mentoring')
195
233
  .option('--pretty', 'Pretty print JSON output')
196
234
  .action(createAction),
197
235
  )
236
+ .addCommand(
237
+ new Command('update')
238
+ .description('Update a mentoring session')
239
+ .argument('<id>')
240
+ .requiredOption('--title <title>', 'Title')
241
+ .requiredOption('--type <type>', 'Mentoring type (public|lecture)')
242
+ .requiredOption('--date <date>', 'Session date')
243
+ .requiredOption('--start <time>', 'Start time')
244
+ .requiredOption('--end <time>', 'End time')
245
+ .requiredOption('--venue <venue>', 'Venue')
246
+ .option('--max-attendees <count>', 'Maximum attendees')
247
+ .option('--reg-start <date>', 'Registration start date')
248
+ .option('--reg-end <date>', 'Registration end date')
249
+ .option('--content <html>', 'HTML content')
250
+ .option('--pretty', 'Pretty print JSON output')
251
+ .action(updateAction),
252
+ )
198
253
  .addCommand(
199
254
  new Command('delete')
200
255
  .description('Delete a mentoring session')