opensoma 0.6.0 → 0.8.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.
- package/dist/package.json +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +6 -4
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +4 -0
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +6 -0
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/index.d.ts +1 -0
- package/dist/src/commands/index.d.ts.map +1 -1
- package/dist/src/commands/index.js +1 -0
- package/dist/src/commands/index.js.map +1 -1
- package/dist/src/commands/toz.d.ts +16 -0
- package/dist/src/commands/toz.d.ts.map +1 -0
- package/dist/src/commands/toz.js +488 -0
- package/dist/src/commands/toz.js.map +1 -0
- package/dist/src/credential-manager.d.ts +8 -2
- package/dist/src/credential-manager.d.ts.map +1 -1
- package/dist/src/credential-manager.js +27 -5
- package/dist/src/credential-manager.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/session-recovery.js +2 -0
- package/dist/src/session-recovery.js.map +1 -1
- package/dist/src/shared/utils/config-dir.d.ts +11 -0
- package/dist/src/shared/utils/config-dir.d.ts.map +1 -0
- package/dist/src/shared/utils/config-dir.js +19 -0
- package/dist/src/shared/utils/config-dir.js.map +1 -0
- package/dist/src/toz-client.d.ts +89 -0
- package/dist/src/toz-client.d.ts.map +1 -0
- package/dist/src/toz-client.js +204 -0
- package/dist/src/toz-client.js.map +1 -0
- package/dist/src/toz-pending-store.d.ts +30 -0
- package/dist/src/toz-pending-store.d.ts.map +1 -0
- package/dist/src/toz-pending-store.js +36 -0
- package/dist/src/toz-pending-store.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +6 -3
- package/src/client.ts +10 -0
- package/src/commands/helpers.test.ts +4 -0
- package/src/commands/index.ts +1 -0
- package/src/commands/toz.test.ts +51 -0
- package/src/commands/toz.ts +607 -0
- package/src/credential-manager.test.ts +54 -0
- package/src/credential-manager.ts +28 -5
- package/src/index.ts +13 -0
- package/src/session-recovery.ts +2 -0
- package/src/shared/utils/config-dir.test.ts +41 -0
- package/src/shared/utils/config-dir.ts +20 -0
- package/src/toz-client.test.ts +243 -0
- package/src/toz-client.ts +311 -0
- package/src/toz-pending-store.test.ts +91 -0
- package/src/toz-pending-store.ts +63 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from 'node:process'
|
|
2
|
+
import { createInterface } from 'node:readline/promises'
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander'
|
|
5
|
+
|
|
6
|
+
import { TOZ_BRANCHES } from '../constants'
|
|
7
|
+
import { CredentialManager } from '../credential-manager'
|
|
8
|
+
import { handleError } from '../shared/utils/error-handler'
|
|
9
|
+
import { formatOutput } from '../shared/utils/output'
|
|
10
|
+
import * as stderr from '../shared/utils/stderr'
|
|
11
|
+
import { formatPhone, parsePhone } from '../shared/utils/toz'
|
|
12
|
+
import { TozClient } from '../toz-client'
|
|
13
|
+
import type { TozPendingReservation } from '../toz-pending-store'
|
|
14
|
+
import { TozPendingStore } from '../toz-pending-store'
|
|
15
|
+
|
|
16
|
+
type LoginOpts = { name: string; phone: string; pretty?: boolean }
|
|
17
|
+
type StatusOpts = { pretty?: boolean }
|
|
18
|
+
type BranchesOpts = { pretty?: boolean }
|
|
19
|
+
type MeetingsOpts = { pretty?: boolean }
|
|
20
|
+
type AvailableOpts = {
|
|
21
|
+
date: string
|
|
22
|
+
start: string
|
|
23
|
+
duration: string
|
|
24
|
+
userCount: string
|
|
25
|
+
branch?: string[]
|
|
26
|
+
pretty?: boolean
|
|
27
|
+
}
|
|
28
|
+
type CheckOpts = {
|
|
29
|
+
date: string
|
|
30
|
+
time: string[]
|
|
31
|
+
duration: string
|
|
32
|
+
userCount: string
|
|
33
|
+
branch?: string[]
|
|
34
|
+
pretty?: boolean
|
|
35
|
+
}
|
|
36
|
+
type ReserveRequestOpts = {
|
|
37
|
+
date: string
|
|
38
|
+
start: string
|
|
39
|
+
duration: string
|
|
40
|
+
userCount: string
|
|
41
|
+
boothId: string
|
|
42
|
+
meetingId?: string
|
|
43
|
+
newMeeting?: string
|
|
44
|
+
email: string
|
|
45
|
+
memo?: string
|
|
46
|
+
name?: string
|
|
47
|
+
phone?: string
|
|
48
|
+
pretty?: boolean
|
|
49
|
+
}
|
|
50
|
+
type ReserveConfirmOpts = { pin?: string; pretty?: boolean }
|
|
51
|
+
type ReserveOpts = ReserveRequestOpts & { pin?: string }
|
|
52
|
+
type ListOpts = {
|
|
53
|
+
start?: string
|
|
54
|
+
end?: string
|
|
55
|
+
meeting?: string
|
|
56
|
+
name?: string
|
|
57
|
+
phone?: string
|
|
58
|
+
pretty?: boolean
|
|
59
|
+
}
|
|
60
|
+
type CancelOpts = { name?: string; phone?: string; pretty?: boolean }
|
|
61
|
+
type LogoutOpts = { pretty?: boolean }
|
|
62
|
+
type TozIdentityStore = Pick<CredentialManager, 'getTozIdentity'>
|
|
63
|
+
type TozIdentityPrompt = () => Promise<string>
|
|
64
|
+
type TozPinPrompt = () => Promise<string>
|
|
65
|
+
|
|
66
|
+
function parseStrictPositiveInt(value: string, flag: string): number {
|
|
67
|
+
if (!/^\d+$/.test(value)) throw new Error(`Invalid --${flag}: ${value} (expected positive integer)`)
|
|
68
|
+
const n = Number.parseInt(value, 10)
|
|
69
|
+
if (!Number.isSafeInteger(n) || n <= 0) throw new Error(`Invalid --${flag}: ${value} (expected positive integer)`)
|
|
70
|
+
return n
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseDurationFlag(value: string): number {
|
|
74
|
+
const match = /^(\d+)\s*([hm])?$/i.exec(value.trim())
|
|
75
|
+
if (!match) throw new Error(`Invalid --duration: ${value} (use e.g. "2h", "120", "150m")`)
|
|
76
|
+
const n = Number.parseInt(match[1], 10)
|
|
77
|
+
const unit = (match[2] ?? 'm').toLowerCase()
|
|
78
|
+
return unit === 'h' ? n * 60 : n
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseUserCount(value: string): number {
|
|
82
|
+
const n = Number.parseInt(value, 10)
|
|
83
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`Invalid --user-count: ${value}`)
|
|
84
|
+
return n
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveBranchIds(values: readonly string[] | undefined): number[] {
|
|
88
|
+
if (!values || values.length === 0) return TOZ_BRANCHES.map((b) => b.id)
|
|
89
|
+
const ids: number[] = []
|
|
90
|
+
for (const value of values) {
|
|
91
|
+
const numeric = Number.parseInt(value, 10)
|
|
92
|
+
if (Number.isFinite(numeric)) {
|
|
93
|
+
ids.push(numeric)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const matched = TOZ_BRANCHES.find((b) => b.name === value || b.name.includes(value))
|
|
97
|
+
if (!matched) throw new Error(`Unknown branch: ${value}`)
|
|
98
|
+
ids.push(matched.id)
|
|
99
|
+
}
|
|
100
|
+
return ids
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function resolveTozIdentity(
|
|
104
|
+
flagName: string | undefined,
|
|
105
|
+
flagPhone: string | undefined,
|
|
106
|
+
options: { promptPhone?: TozIdentityPrompt; store?: TozIdentityStore } = {},
|
|
107
|
+
): Promise<{ name: string; phone: string }> {
|
|
108
|
+
if (flagName && flagPhone) return { name: flagName, phone: flagPhone }
|
|
109
|
+
const store = options.store ?? new CredentialManager()
|
|
110
|
+
const stored = await store.getTozIdentity()
|
|
111
|
+
const name = flagName ?? stored?.name
|
|
112
|
+
let phone = flagPhone ?? stored?.phone
|
|
113
|
+
if (!name) {
|
|
114
|
+
throw new Error('Toz identity not set. Run: opensoma toz login --name <name> --phone <phone>')
|
|
115
|
+
}
|
|
116
|
+
if (!phone) {
|
|
117
|
+
if (!options.promptPhone) {
|
|
118
|
+
throw new Error('Toz identity not set. Run: opensoma toz login --name <name> --phone <phone>')
|
|
119
|
+
}
|
|
120
|
+
phone = await options.promptPhone()
|
|
121
|
+
}
|
|
122
|
+
return { name, phone }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function resolveTozPin(pin: string | undefined, promptPin: TozPinPrompt): Promise<string> {
|
|
126
|
+
return pin ?? (await promptPin())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function loginAction(options: LoginOpts): Promise<void> {
|
|
130
|
+
try {
|
|
131
|
+
const manager = new CredentialManager()
|
|
132
|
+
await manager.setTozIdentity(options.name, options.phone)
|
|
133
|
+
console.log(
|
|
134
|
+
formatOutput(
|
|
135
|
+
{ ok: true, message: `Toz identity saved for ${options.name} (${maskPhone(options.phone)})` },
|
|
136
|
+
options.pretty,
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
handleError(error)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function logoutAction(options: LogoutOpts): Promise<void> {
|
|
145
|
+
try {
|
|
146
|
+
await new CredentialManager().clearTozIdentity()
|
|
147
|
+
await new TozPendingStore().clear()
|
|
148
|
+
console.log(formatOutput({ ok: true }, options.pretty))
|
|
149
|
+
} catch (error) {
|
|
150
|
+
handleError(error)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function statusAction(options: StatusOpts): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
const stored = await new CredentialManager().getTozIdentity()
|
|
157
|
+
if (!stored) {
|
|
158
|
+
console.log(formatOutput({ loggedIn: false }, options.pretty))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
console.log(formatOutput({ loggedIn: true, name: stored.name, phone: maskPhone(stored.phone) }, options.pretty))
|
|
162
|
+
} catch (error) {
|
|
163
|
+
handleError(error)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function branchesAction(options: BranchesOpts): Promise<void> {
|
|
168
|
+
try {
|
|
169
|
+
const branches = await new TozClient().branches()
|
|
170
|
+
console.log(formatOutput(branches, options.pretty))
|
|
171
|
+
} catch (error) {
|
|
172
|
+
handleError(error)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function meetingsAction(options: MeetingsOpts): Promise<void> {
|
|
177
|
+
try {
|
|
178
|
+
const meetings = await new TozClient().meetings()
|
|
179
|
+
console.log(formatOutput(meetings, options.pretty))
|
|
180
|
+
} catch (error) {
|
|
181
|
+
handleError(error)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function availableAction(options: AvailableOpts): Promise<void> {
|
|
186
|
+
try {
|
|
187
|
+
const booths = await new TozClient().available({
|
|
188
|
+
date: options.date,
|
|
189
|
+
startTime: options.start,
|
|
190
|
+
durationMinutes: parseDurationFlag(options.duration),
|
|
191
|
+
userCount: parseUserCount(options.userCount),
|
|
192
|
+
branchIds: resolveBranchIds(options.branch),
|
|
193
|
+
})
|
|
194
|
+
console.log(formatOutput(booths, options.pretty))
|
|
195
|
+
} catch (error) {
|
|
196
|
+
handleError(error)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function checkAction(options: CheckOpts): Promise<void> {
|
|
201
|
+
try {
|
|
202
|
+
const startedAt = Date.now()
|
|
203
|
+
const checked = await new TozClient().check({
|
|
204
|
+
date: options.date,
|
|
205
|
+
startTimes: options.time,
|
|
206
|
+
durationMinutes: parseDurationFlag(options.duration),
|
|
207
|
+
userCount: parseUserCount(options.userCount),
|
|
208
|
+
branchIds: resolveBranchIds(options.branch),
|
|
209
|
+
})
|
|
210
|
+
console.log(
|
|
211
|
+
formatOutput(
|
|
212
|
+
{
|
|
213
|
+
date: options.date,
|
|
214
|
+
durationMinutes: parseDurationFlag(options.duration),
|
|
215
|
+
userCount: parseUserCount(options.userCount),
|
|
216
|
+
checked,
|
|
217
|
+
requestsIssued: options.time.length,
|
|
218
|
+
wallTimeMs: Date.now() - startedAt,
|
|
219
|
+
},
|
|
220
|
+
options.pretty,
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
handleError(error)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function reserveRequestAction(options: ReserveRequestOpts): Promise<void> {
|
|
229
|
+
try {
|
|
230
|
+
const identity = await resolveTozIdentity(options.name, options.phone, { promptPhone })
|
|
231
|
+
const durationMinutes = parseDurationFlag(options.duration)
|
|
232
|
+
const userCount = parseUserCount(options.userCount)
|
|
233
|
+
const boothId = parseStrictPositiveInt(options.boothId, 'booth-id')
|
|
234
|
+
const meetingId = options.meetingId ? parseStrictPositiveInt(options.meetingId, 'meeting-id') : undefined
|
|
235
|
+
if (!meetingId && !options.newMeeting) throw new Error('--meeting-id or --new-meeting is required')
|
|
236
|
+
|
|
237
|
+
const client = new TozClient()
|
|
238
|
+
const reserved = await client.reserveBooth({
|
|
239
|
+
date: options.date,
|
|
240
|
+
startTime: options.start,
|
|
241
|
+
durationMinutes,
|
|
242
|
+
userCount,
|
|
243
|
+
boothId,
|
|
244
|
+
})
|
|
245
|
+
await client.skipEquipment({
|
|
246
|
+
reservationId: reserved.reservationId,
|
|
247
|
+
date: options.date,
|
|
248
|
+
startTime: options.start,
|
|
249
|
+
durationMinutes,
|
|
250
|
+
})
|
|
251
|
+
await client.sendOtp(identity.phone)
|
|
252
|
+
|
|
253
|
+
const endTime = computeEndTime(options.start, durationMinutes)
|
|
254
|
+
const now = new Date()
|
|
255
|
+
const pending: TozPendingReservation = {
|
|
256
|
+
reservationId: reserved.reservationId,
|
|
257
|
+
cookies: client.http.getCookies(),
|
|
258
|
+
branchName: reserved.branchName,
|
|
259
|
+
branchTel: reserved.branchTel,
|
|
260
|
+
boothGroupName: reserved.boothGroupName,
|
|
261
|
+
isLargeBooth: reserved.isLargeBooth,
|
|
262
|
+
date: options.date,
|
|
263
|
+
startTime: options.start,
|
|
264
|
+
endTime,
|
|
265
|
+
durationMinutes,
|
|
266
|
+
userCount,
|
|
267
|
+
boothId,
|
|
268
|
+
meetingId,
|
|
269
|
+
newMeetingName: options.newMeeting,
|
|
270
|
+
email: options.email,
|
|
271
|
+
memo: options.memo,
|
|
272
|
+
name: identity.name,
|
|
273
|
+
phone: identity.phone,
|
|
274
|
+
createdAt: now.toISOString(),
|
|
275
|
+
expiresAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
|
|
276
|
+
}
|
|
277
|
+
await new TozPendingStore().set(pending)
|
|
278
|
+
|
|
279
|
+
console.log(
|
|
280
|
+
formatOutput(
|
|
281
|
+
{
|
|
282
|
+
ok: true,
|
|
283
|
+
reservationId: reserved.reservationId,
|
|
284
|
+
branchName: reserved.branchName,
|
|
285
|
+
boothGroupName: reserved.boothGroupName,
|
|
286
|
+
date: options.date,
|
|
287
|
+
startTime: options.start,
|
|
288
|
+
endTime,
|
|
289
|
+
expiresAt: pending.expiresAt,
|
|
290
|
+
message: `SMS sent to ${maskPhone(identity.phone)}. Run: opensoma toz reserve-confirm --pin <pin>`,
|
|
291
|
+
},
|
|
292
|
+
options.pretty,
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
} catch (error) {
|
|
296
|
+
handleError(error)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function reserveConfirmAction(options: ReserveConfirmOpts): Promise<void> {
|
|
301
|
+
try {
|
|
302
|
+
const store = new TozPendingStore()
|
|
303
|
+
const pending = await store.get()
|
|
304
|
+
if (!pending) {
|
|
305
|
+
throw new Error('No pending toz reservation. Run: opensoma toz reserve-request first.')
|
|
306
|
+
}
|
|
307
|
+
if (Date.now() > new Date(pending.expiresAt).getTime()) {
|
|
308
|
+
await store.clear()
|
|
309
|
+
throw new Error(
|
|
310
|
+
'Pending toz reservation expired (5-minute hold lapsed). Run: opensoma toz reserve-request again.',
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const pin = await resolveTozPin(options.pin, promptPin)
|
|
315
|
+
const client = new TozClient({ cookies: pending.cookies })
|
|
316
|
+
const result = await client.confirm({
|
|
317
|
+
reservationId: pending.reservationId,
|
|
318
|
+
date: pending.date,
|
|
319
|
+
startTime: pending.startTime,
|
|
320
|
+
durationMinutes: pending.durationMinutes,
|
|
321
|
+
name: pending.name,
|
|
322
|
+
phone: pending.phone,
|
|
323
|
+
email: pending.email,
|
|
324
|
+
pinNum: pin,
|
|
325
|
+
meetingId: pending.meetingId,
|
|
326
|
+
newMeetingName: pending.newMeetingName,
|
|
327
|
+
memo: pending.memo,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
await store.clear()
|
|
331
|
+
console.log(
|
|
332
|
+
formatOutput(
|
|
333
|
+
{
|
|
334
|
+
ok: true,
|
|
335
|
+
reservationId: pending.reservationId,
|
|
336
|
+
branchName: pending.branchName,
|
|
337
|
+
boothGroupName: pending.boothGroupName,
|
|
338
|
+
date: pending.date,
|
|
339
|
+
startTime: pending.startTime,
|
|
340
|
+
endTime: pending.endTime,
|
|
341
|
+
message: result.resultMsg,
|
|
342
|
+
},
|
|
343
|
+
options.pretty,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
} catch (error) {
|
|
347
|
+
handleError(error)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function reserveAction(options: ReserveOpts): Promise<void> {
|
|
352
|
+
try {
|
|
353
|
+
const identity = await resolveTozIdentity(options.name, options.phone, { promptPhone })
|
|
354
|
+
const durationMinutes = parseDurationFlag(options.duration)
|
|
355
|
+
const userCount = parseUserCount(options.userCount)
|
|
356
|
+
const boothId = parseStrictPositiveInt(options.boothId, 'booth-id')
|
|
357
|
+
const meetingId = options.meetingId ? parseStrictPositiveInt(options.meetingId, 'meeting-id') : undefined
|
|
358
|
+
if (!meetingId && !options.newMeeting) throw new Error('--meeting-id or --new-meeting is required')
|
|
359
|
+
|
|
360
|
+
const client = new TozClient()
|
|
361
|
+
stderr.info(`[1/4] Reserving booth ${boothId}...`)
|
|
362
|
+
const reserved = await client.reserveBooth({
|
|
363
|
+
date: options.date,
|
|
364
|
+
startTime: options.start,
|
|
365
|
+
durationMinutes,
|
|
366
|
+
userCount,
|
|
367
|
+
boothId,
|
|
368
|
+
})
|
|
369
|
+
stderr.info(` ok (reservationId=${reserved.reservationId}, ${reserved.branchName} ${reserved.boothGroupName})`)
|
|
370
|
+
|
|
371
|
+
stderr.info('[2/4] Skipping equipment...')
|
|
372
|
+
await client.skipEquipment({
|
|
373
|
+
reservationId: reserved.reservationId,
|
|
374
|
+
date: options.date,
|
|
375
|
+
startTime: options.start,
|
|
376
|
+
durationMinutes,
|
|
377
|
+
})
|
|
378
|
+
stderr.info(' ok')
|
|
379
|
+
|
|
380
|
+
stderr.info(`[3/4] Sending SMS to ${maskPhone(identity.phone)}...`)
|
|
381
|
+
await client.sendOtp(identity.phone)
|
|
382
|
+
stderr.info(' ok')
|
|
383
|
+
|
|
384
|
+
const pin = options.pin ?? (await promptPin())
|
|
385
|
+
stderr.info('[4/4] Confirming reservation...')
|
|
386
|
+
const result = await client.confirm({
|
|
387
|
+
reservationId: reserved.reservationId,
|
|
388
|
+
date: options.date,
|
|
389
|
+
startTime: options.start,
|
|
390
|
+
durationMinutes,
|
|
391
|
+
name: identity.name,
|
|
392
|
+
phone: identity.phone,
|
|
393
|
+
email: options.email,
|
|
394
|
+
pinNum: pin,
|
|
395
|
+
meetingId,
|
|
396
|
+
newMeetingName: options.newMeeting,
|
|
397
|
+
memo: options.memo,
|
|
398
|
+
})
|
|
399
|
+
stderr.info(' ok')
|
|
400
|
+
|
|
401
|
+
console.log(
|
|
402
|
+
formatOutput(
|
|
403
|
+
{
|
|
404
|
+
ok: true,
|
|
405
|
+
reservationId: reserved.reservationId,
|
|
406
|
+
branchName: reserved.branchName,
|
|
407
|
+
boothGroupName: reserved.boothGroupName,
|
|
408
|
+
date: options.date,
|
|
409
|
+
startTime: options.start,
|
|
410
|
+
endTime: computeEndTime(options.start, durationMinutes),
|
|
411
|
+
message: result.resultMsg,
|
|
412
|
+
},
|
|
413
|
+
options.pretty,
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
} catch (error) {
|
|
417
|
+
handleError(error)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function listAction(options: ListOpts): Promise<void> {
|
|
422
|
+
try {
|
|
423
|
+
const identity = await resolveTozIdentity(options.name, options.phone)
|
|
424
|
+
const reservations = await new TozClient().myReservations({
|
|
425
|
+
name: identity.name,
|
|
426
|
+
phone: identity.phone,
|
|
427
|
+
startDate: options.start,
|
|
428
|
+
endDate: options.end,
|
|
429
|
+
meetingName: options.meeting,
|
|
430
|
+
})
|
|
431
|
+
console.log(formatOutput(reservations, options.pretty))
|
|
432
|
+
} catch (error) {
|
|
433
|
+
handleError(error)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function cancelAction(reservationId: string, options: CancelOpts): Promise<void> {
|
|
438
|
+
try {
|
|
439
|
+
const id = parseStrictPositiveInt(reservationId, 'reservationId')
|
|
440
|
+
const identity = await resolveTozIdentity(options.name, options.phone)
|
|
441
|
+
await new TozClient().cancel({ reservationId: id, name: identity.name, phone: identity.phone })
|
|
442
|
+
console.log(formatOutput({ ok: true, message: '취소되었습니다.' }, options.pretty))
|
|
443
|
+
} catch (error) {
|
|
444
|
+
handleError(error)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function maskPhone(phone: string): string {
|
|
449
|
+
return phone.replace(
|
|
450
|
+
/^(\d{3})-?(\d{3,4})-?(\d{4})$/,
|
|
451
|
+
(_, a: string, _b: string, c: string) => `${a}-${'*'.repeat(_b.length)}-${c}`,
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function computeEndTime(start: string, durationMinutes: number): string {
|
|
456
|
+
const [h, m] = start.split(':').map((s) => Number.parseInt(s, 10))
|
|
457
|
+
const total = h * 60 + m + durationMinutes
|
|
458
|
+
const eh = Math.floor(total / 60) % 24
|
|
459
|
+
const em = total % 60
|
|
460
|
+
return `${String(eh).padStart(2, '0')}:${String(em).padStart(2, '0')}`
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function promptPin(): Promise<string> {
|
|
464
|
+
const rl = createInterface({ input, output })
|
|
465
|
+
try {
|
|
466
|
+
const answer = await rl.question('Enter 6-digit PIN from SMS: ')
|
|
467
|
+
return answer.trim()
|
|
468
|
+
} finally {
|
|
469
|
+
rl.close()
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function promptPhone(): Promise<string> {
|
|
474
|
+
const rl = createInterface({ input, output })
|
|
475
|
+
try {
|
|
476
|
+
while (true) {
|
|
477
|
+
const answer = await rl.question('Phone number (e.g. 010-1234-5678): ')
|
|
478
|
+
const phone = answer.trim()
|
|
479
|
+
try {
|
|
480
|
+
return formatPhone(parsePhone(phone))
|
|
481
|
+
} catch (error) {
|
|
482
|
+
stderr.warn(error instanceof Error ? error.message : String(error))
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} finally {
|
|
486
|
+
rl.close()
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export const tozCommand = new Command('toz')
|
|
491
|
+
.description('Manage TOZ external meeting room reservations (SW마에스트로 partnership)')
|
|
492
|
+
.addCommand(
|
|
493
|
+
new Command('login')
|
|
494
|
+
.description('Save TOZ identity (name + phone) to credentials')
|
|
495
|
+
.requiredOption('--name <name>', 'Reservation holder name')
|
|
496
|
+
.requiredOption('--phone <phone>', 'Phone number, e.g. 010-1234-5678')
|
|
497
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
498
|
+
.action(loginAction),
|
|
499
|
+
)
|
|
500
|
+
.addCommand(
|
|
501
|
+
new Command('logout')
|
|
502
|
+
.description('Clear stored TOZ identity and pending reservation state')
|
|
503
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
504
|
+
.action(logoutAction),
|
|
505
|
+
)
|
|
506
|
+
.addCommand(
|
|
507
|
+
new Command('status')
|
|
508
|
+
.description('Show stored TOZ identity')
|
|
509
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
510
|
+
.action(statusAction),
|
|
511
|
+
)
|
|
512
|
+
.addCommand(
|
|
513
|
+
new Command('branches')
|
|
514
|
+
.description('List partner branches (live from booking.htm)')
|
|
515
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
516
|
+
.action(branchesAction),
|
|
517
|
+
)
|
|
518
|
+
.addCommand(
|
|
519
|
+
new Command('meetings')
|
|
520
|
+
.description('List active SW마에스트로 meetings (live from booking.htm)')
|
|
521
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
522
|
+
.action(meetingsAction),
|
|
523
|
+
)
|
|
524
|
+
.addCommand(
|
|
525
|
+
new Command('available')
|
|
526
|
+
.description('Show available booths for one date+time+duration')
|
|
527
|
+
.requiredOption('--date <date>', 'YYYY-MM-DD')
|
|
528
|
+
.requiredOption('--start <time>', 'HH:MM')
|
|
529
|
+
.requiredOption('--duration <duration>', 'e.g. 2h, 150m, 120 (default minutes)')
|
|
530
|
+
.requiredOption('--user-count <n>', 'Number of attendees')
|
|
531
|
+
.option('--branch <id-or-name...>', 'Branch IDs or names (default: all 9)')
|
|
532
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
533
|
+
.action(availableAction),
|
|
534
|
+
)
|
|
535
|
+
.addCommand(
|
|
536
|
+
new Command('check')
|
|
537
|
+
.description('Check booth availability for multiple times (max 6, serial)')
|
|
538
|
+
.requiredOption('--date <date>', 'YYYY-MM-DD')
|
|
539
|
+
.requiredOption('--time <time...>', 'HH:MM (repeatable, max 6)')
|
|
540
|
+
.requiredOption('--duration <duration>', 'e.g. 2h')
|
|
541
|
+
.requiredOption('--user-count <n>', 'Number of attendees')
|
|
542
|
+
.option('--branch <id-or-name...>', 'Branch IDs or names')
|
|
543
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
544
|
+
.action(checkAction),
|
|
545
|
+
)
|
|
546
|
+
.addCommand(
|
|
547
|
+
new Command('reserve-request')
|
|
548
|
+
.description('Step 1 of non-interactive reservation: hold booth + send SMS')
|
|
549
|
+
.requiredOption('--date <date>', 'YYYY-MM-DD')
|
|
550
|
+
.requiredOption('--start <time>', 'HH:MM')
|
|
551
|
+
.requiredOption('--duration <duration>', 'e.g. 2h')
|
|
552
|
+
.requiredOption('--user-count <n>', 'Number of attendees')
|
|
553
|
+
.requiredOption('--booth-id <id>', 'Booth ID from `toz available`')
|
|
554
|
+
.option('--meeting-id <id>', 'Meeting ID from `toz meetings`')
|
|
555
|
+
.option('--new-meeting <name>', 'Create a new meeting with this name')
|
|
556
|
+
.requiredOption('--email <email>', 'Contact email')
|
|
557
|
+
.option('--memo <text>', 'Reservation memo')
|
|
558
|
+
.option('--name <name>', 'Override stored name')
|
|
559
|
+
.option('--phone <phone>', 'Override stored phone')
|
|
560
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
561
|
+
.action(reserveRequestAction),
|
|
562
|
+
)
|
|
563
|
+
.addCommand(
|
|
564
|
+
new Command('reserve-confirm')
|
|
565
|
+
.description('Step 2 of non-interactive reservation: finalize with PIN')
|
|
566
|
+
.option('--pin <pin>', '6-digit PIN from SMS (prompts if omitted)')
|
|
567
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
568
|
+
.action(reserveConfirmAction),
|
|
569
|
+
)
|
|
570
|
+
.addCommand(
|
|
571
|
+
new Command('reserve')
|
|
572
|
+
.description('Interactive single-process reservation (prompts for PIN)')
|
|
573
|
+
.requiredOption('--date <date>', 'YYYY-MM-DD')
|
|
574
|
+
.requiredOption('--start <time>', 'HH:MM')
|
|
575
|
+
.requiredOption('--duration <duration>', 'e.g. 2h')
|
|
576
|
+
.requiredOption('--user-count <n>', 'Number of attendees')
|
|
577
|
+
.requiredOption('--booth-id <id>', 'Booth ID')
|
|
578
|
+
.option('--meeting-id <id>', 'Meeting ID')
|
|
579
|
+
.option('--new-meeting <name>', 'Create new meeting with this name')
|
|
580
|
+
.requiredOption('--email <email>', 'Contact email')
|
|
581
|
+
.option('--memo <text>', 'Reservation memo')
|
|
582
|
+
.option('--name <name>', 'Override stored name')
|
|
583
|
+
.option('--phone <phone>', 'Override stored phone')
|
|
584
|
+
.option('--pin <pin>', 'PIN if already received (skip prompt)')
|
|
585
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
586
|
+
.action(reserveAction),
|
|
587
|
+
)
|
|
588
|
+
.addCommand(
|
|
589
|
+
new Command('list')
|
|
590
|
+
.description('List my TOZ reservations')
|
|
591
|
+
.option('--start <date>', 'YYYY-MM-DD')
|
|
592
|
+
.option('--end <date>', 'YYYY-MM-DD')
|
|
593
|
+
.option('--meeting <name>', 'Filter by meeting name')
|
|
594
|
+
.option('--name <name>', 'Override stored name')
|
|
595
|
+
.option('--phone <phone>', 'Override stored phone')
|
|
596
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
597
|
+
.action(listAction),
|
|
598
|
+
)
|
|
599
|
+
.addCommand(
|
|
600
|
+
new Command('cancel')
|
|
601
|
+
.description('Cancel a TOZ reservation')
|
|
602
|
+
.argument('<reservationId>')
|
|
603
|
+
.option('--name <name>', 'Override stored name')
|
|
604
|
+
.option('--phone <phone>', 'Override stored phone')
|
|
605
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
606
|
+
.action(cancelAction),
|
|
607
|
+
)
|
|
@@ -75,6 +75,8 @@ describe('CredentialManager', () => {
|
|
|
75
75
|
csrfToken: 'csrf-value',
|
|
76
76
|
username: 'mentor@example.com',
|
|
77
77
|
password: 'secret-password',
|
|
78
|
+
tozName: 'Mentor One',
|
|
79
|
+
tozPhone: '010-1234-5678',
|
|
78
80
|
loggedInAt: '2026-04-09T00:00:00.000Z',
|
|
79
81
|
})
|
|
80
82
|
|
|
@@ -85,6 +87,8 @@ describe('CredentialManager', () => {
|
|
|
85
87
|
csrfToken: '',
|
|
86
88
|
username: 'mentor@example.com',
|
|
87
89
|
password: 'secret-password',
|
|
90
|
+
tozName: 'Mentor One',
|
|
91
|
+
tozPhone: '010-1234-5678',
|
|
88
92
|
})
|
|
89
93
|
})
|
|
90
94
|
|
|
@@ -110,6 +114,56 @@ describe('CredentialManager', () => {
|
|
|
110
114
|
await expect(manager.getCredentials()).resolves.toBeNull()
|
|
111
115
|
})
|
|
112
116
|
|
|
117
|
+
it('setTozIdentity merges into existing credentials', async () => {
|
|
118
|
+
const dir = await makeTempDir()
|
|
119
|
+
const manager = new CredentialManager(dir)
|
|
120
|
+
|
|
121
|
+
await manager.setCredentials({
|
|
122
|
+
sessionCookie: 'session-value',
|
|
123
|
+
csrfToken: 'csrf-value',
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
await manager.setTozIdentity('Mentor One', '010-1234-5678')
|
|
127
|
+
|
|
128
|
+
await expect(manager.getCredentials()).resolves.toMatchObject({
|
|
129
|
+
sessionCookie: 'session-value',
|
|
130
|
+
csrfToken: 'csrf-value',
|
|
131
|
+
tozName: 'Mentor One',
|
|
132
|
+
tozPhone: '010-1234-5678',
|
|
133
|
+
})
|
|
134
|
+
await expect(manager.getTozIdentity()).resolves.toEqual({
|
|
135
|
+
name: 'Mentor One',
|
|
136
|
+
phone: '010-1234-5678',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('setTozIdentity throws without SWMaestro credentials', async () => {
|
|
141
|
+
const dir = await makeTempDir()
|
|
142
|
+
const manager = new CredentialManager(dir)
|
|
143
|
+
|
|
144
|
+
await expect(manager.setTozIdentity('Mentor One', '010-1234-5678')).rejects.toThrow(/auth login/)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('clearTozIdentity removes only toz fields', async () => {
|
|
148
|
+
const dir = await makeTempDir()
|
|
149
|
+
const manager = new CredentialManager(dir)
|
|
150
|
+
|
|
151
|
+
await manager.setCredentials({
|
|
152
|
+
sessionCookie: 'session-value',
|
|
153
|
+
csrfToken: 'csrf-value',
|
|
154
|
+
tozName: 'Mentor One',
|
|
155
|
+
tozPhone: '010-1234-5678',
|
|
156
|
+
})
|
|
157
|
+
await manager.clearTozIdentity()
|
|
158
|
+
|
|
159
|
+
const creds = await manager.getCredentials()
|
|
160
|
+
expect(creds?.tozName).toBeUndefined()
|
|
161
|
+
expect(creds?.tozPhone).toBeUndefined()
|
|
162
|
+
expect(creds?.sessionCookie).toBe('session-value')
|
|
163
|
+
expect(creds?.csrfToken).toBe('csrf-value')
|
|
164
|
+
await expect(manager.getTozIdentity()).resolves.toBeNull()
|
|
165
|
+
})
|
|
166
|
+
|
|
113
167
|
it('preserves session credentials but drops the password when the encryption key is missing', async () => {
|
|
114
168
|
const dir = await makeTempDir()
|
|
115
169
|
const manager = new CredentialManager(dir)
|