diff --git a/src/cli.ts b/src/cli.ts index fbfc9d9..182dd51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,252 +6,42 @@ * npx daiso 명령으로 원격 MCP 서버 정보를 확인하고 상태를 점검합니다. */ -import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { spawn } from 'node:child_process'; -import { printCommandHelp, printHelp } from './cliHelp.js'; -import { runInteractiveCli, type InteractiveCliDeps } from './cliInteractive.js'; -import { renderApiEnvelope } from './cliRenderer.js'; -import { buildDaisoStoreKeywordVariants } from './utils/daisoKeyword.js'; - -const DEFAULT_BASE_URL = 'https://mcp.aka.page'; -const DEFAULT_MCP_URL = `${DEFAULT_BASE_URL}/mcp`; - -type FetchLike = typeof fetch; - -type WriteFn = (message: string) => void; - -interface CliDeps { - fetchImpl: FetchLike; - writeOut: WriteFn; - writeErr: WriteFn; - getVersion: () => string; - nowIso: () => string; - runCommand: (command: string, args: string[]) => Promise; - isInteractiveTerminal: () => boolean; - runInteractive: (deps: InteractiveCliDeps) => Promise; -} - -function loadVersion(): string { - const cliPath = fileURLToPath(import.meta.url); - const packagePath = path.resolve(path.dirname(cliPath), '../package.json'); - - if (!existsSync(packagePath)) { - return '0.0.0'; - } - - const raw = readFileSync(packagePath, 'utf8'); - const parsed = JSON.parse(raw) as { version?: string }; - return parsed.version ?? '0.0.0'; -} - -async function execCommand(command: string, args: string[]): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(command, args, { - stdio: 'inherit', - shell: false, - }); - - child.on('error', (error) => { - reject(error); - }); - - child.on('close', (code) => { - resolve(code ?? 1); - }); - }); -} - -function createDefaultDeps(): CliDeps { - return { - fetchImpl: fetch, - writeOut: (message: string) => { - process.stdout.write(`${message}\n`); - }, - writeErr: (message: string) => { - process.stderr.write(`${message}\n`); - }, - getVersion: loadVersion, - nowIso: () => new Date().toISOString(), - runCommand: execCommand, - isInteractiveTerminal: () => Boolean(process.stdin.isTTY && process.stdout.isTTY), - runInteractive: runInteractiveCli, - }; -} - -function parseCliArgs(args: string[]): { positionals: string[]; options: Record } { - const positionals: string[] = []; - const options: Record = {}; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (!token.startsWith('--')) { - positionals.push(token); - continue; - } - - const withoutPrefix = token.slice(2); - const equalIndex = withoutPrefix.indexOf('='); - - if (equalIndex >= 0) { - const key = withoutPrefix.slice(0, equalIndex); - const value = withoutPrefix.slice(equalIndex + 1); - options[key] = value; - continue; - } - - const key = withoutPrefix; - const nextValue = args[index + 1]; - if (!nextValue || nextValue.startsWith('--')) { - options[key] = 'true'; - continue; - } - - options[key] = nextValue; - index += 1; - } - - return { positionals, options }; -} - -function toUrl(pathOrUrl: string): URL { - if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) { - return new URL(pathOrUrl); - } - - const normalizedPath = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`; - return new URL(normalizedPath, DEFAULT_BASE_URL); -} - -function applyOptionsToQuery(url: URL, options: Record): void { - for (const [key, value] of Object.entries(options)) { - url.searchParams.set(key, value); - } -} - -function toQueryOptions(options: Record): Record { - const queryOptions: Record = {}; - for (const [key, value] of Object.entries(options)) { - if (key === 'help' || key === 'json') { - continue; - } - queryOptions[key] = value; - } - return queryOptions; -} - -function toStoreCount(payload: unknown): number { - if (typeof payload !== 'object' || payload === null) { - return 0; - } - const record = payload as { success?: unknown; data?: unknown }; - if (record.success !== true || typeof record.data !== 'object' || record.data === null) { - return 0; - } - const stores = (record.data as { stores?: unknown }).stores; - return Array.isArray(stores) ? stores.length : 0; -} - -async function requestAndPrintResponse( - fetchImpl: FetchLike, - writeOut: WriteFn, - writeErr: WriteFn, - url: URL, - command: string, - asJson: boolean, -): Promise { - try { - const response = await fetchImpl(url.toString()); - - if (!response.ok) { - const bodyText = await response.text(); - writeErr(`요청 실패: HTTP ${response.status}`); - if (bodyText) { - writeErr(bodyText); - } - return 1; - } - - const payload = (await response.json()) as unknown; - if (asJson) { - writeOut(JSON.stringify(payload, null, 2)); - } else { - writeOut(renderApiEnvelope(command, url, payload)); - } - return 0; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - writeErr(`요청 중 오류 발생: ${message}`); - return 1; - } -} - -async function requestAndPrintStoresWithKeywordFallback( - fetchImpl: FetchLike, - writeOut: WriteFn, - writeErr: WriteFn, - options: Record, - asJson: boolean, -): Promise { - const originalKeyword = options.keyword; - if (!originalKeyword) { - const url = toUrl('/api/daiso/stores'); - applyOptionsToQuery(url, options); - return await requestAndPrintResponse(fetchImpl, writeOut, writeErr, url, 'stores', asJson); - } - - const keywords = buildDaisoStoreKeywordVariants(originalKeyword); - const candidates = keywords.length > 0 ? keywords : [originalKeyword]; - - try { - let lastUrl = toUrl('/api/daiso/stores'); - let lastPayload: unknown = null; - - for (const keyword of candidates) { - const targetUrl = toUrl('/api/daiso/stores'); - applyOptionsToQuery(targetUrl, { ...options, keyword }); - lastUrl = targetUrl; - - const response = await fetchImpl(targetUrl.toString()); - if (!response.ok) { - const bodyText = await response.text(); - writeErr(`요청 실패: HTTP ${response.status}`); - if (bodyText) { - writeErr(bodyText); - } - return 1; - } - - const payload = (await response.json()) as unknown; - lastPayload = payload; - - if (toStoreCount(payload) > 0) { - if (keyword !== originalKeyword) { - writeOut(`입력 키워드 "${originalKeyword}" 대신 "${keyword}"로 매장을 찾았습니다.`); - } - if (asJson) { - writeOut(JSON.stringify(payload, null, 2)); - } else { - writeOut(renderApiEnvelope('stores', targetUrl, payload)); - } - return 0; - } - } - - if (asJson) { - writeOut(JSON.stringify(lastPayload, null, 2)); - } else { - writeOut(renderApiEnvelope('stores', lastUrl, lastPayload)); - } - writeOut('힌트: "안산 중앙역" 대신 "안산중앙" 또는 "고잔"으로 검색해보세요.'); - return 0; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - writeErr(`요청 중 오류 발생: ${message}`); - return 1; - } -} +import { printHelp, printCommandHelp } from './cliHelp.js'; +import { createDefaultDeps } from './cli/deps.js'; +import type { CliDeps } from './cli/types.js'; +import { DEFAULT_BASE_URL, DEFAULT_MCP_URL } from './cli/constants.js'; +import { + handleGet, + handleProducts, + handleProduct, + handleStores, + handleInventory, + handleDisplayLocation, +} from './cli/commands/daiso.js'; +import { + handleCuStores, + handleCuInventory, + handleEmart24Stores, + handleEmart24Products, + handleEmart24Inventory, + handleLotteMartStores, + handleLotteMartProducts, + handleGs25Stores, + handleGs25Products, + handleGs25Inventory, + handleSevenElevenProducts, + handleSevenElevenStores, + handleSevenElevenPopwords, + handleSevenElevenCatalog, + handleLottecinemaTheaters, + handleLottecinemaMovies, + handleLottecinemaSeats, +} from './cli/commands/convenience.js'; + +export type { CliDeps } from './cli/types.js'; +export type { InteractiveCliDeps } from './cliInteractive.js'; export async function runCli(argv: string[], deps?: Partial): Promise { const resolvedDeps = { @@ -341,582 +131,29 @@ export async function runCli(argv: string[], deps?: Partial): Promise } { + const positionals: string[] = []; + const options: Record = {}; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (!token.startsWith('--')) { + positionals.push(token); + continue; + } + + const withoutPrefix = token.slice(2); + const equalIndex = withoutPrefix.indexOf('='); + + if (equalIndex >= 0) { + const key = withoutPrefix.slice(0, equalIndex); + const value = withoutPrefix.slice(equalIndex + 1); + options[key] = value; + continue; + } + + const key = withoutPrefix; + const nextValue = args[index + 1]; + if (!nextValue || nextValue.startsWith('--')) { + options[key] = 'true'; + continue; + } + + options[key] = nextValue; + index += 1; + } + + return { positionals, options }; +} + +export function toUrl(pathOrUrl: string): URL { + if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) { + return new URL(pathOrUrl); + } + + const normalizedPath = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`; + return new URL(normalizedPath, DEFAULT_BASE_URL); +} + +export function applyOptionsToQuery(url: URL, options: Record): void { + for (const [key, value] of Object.entries(options)) { + url.searchParams.set(key, value); + } +} + +export function toQueryOptions(options: Record): Record { + const queryOptions: Record = {}; + for (const [key, value] of Object.entries(options)) { + if (key === 'help' || key === 'json') { + continue; + } + queryOptions[key] = value; + } + return queryOptions; +} diff --git a/src/cli/commands/convenience.ts b/src/cli/commands/convenience.ts new file mode 100644 index 0000000..e99f4db --- /dev/null +++ b/src/cli/commands/convenience.ts @@ -0,0 +1,335 @@ +/** + * 편의점 및 기타 서비스 CLI 명령 핸들러 + * (CU, 이마트24, 롯데마트, GS25, 세븐일레븐, 롯데시네마) + */ + +import { printCommandHelp } from '../../cliHelp.js'; +import type { CliDeps } from '../types.js'; +import { parseCliArgs, toUrl, applyOptionsToQuery, toQueryOptions } from '../args.js'; +import { requestAndPrintResponse } from '../http.js'; + +export async function handleCuStores(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('cu-stores', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (keyword) { + parsed.options.keyword = keyword; + } + + const targetUrl = toUrl('/api/cu/stores'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'cu-stores', parsed.options.json === 'true', + ); +} + +export async function handleCuInventory(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('cu-inventory', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (!keyword) { + deps.writeErr('cu-inventory 명령은 검색어가 필요합니다. 예: daiso cu-inventory 과자'); + return 1; + } + + const targetUrl = toUrl('/api/cu/inventory'); + targetUrl.searchParams.set('keyword', keyword); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'cu-inventory', parsed.options.json === 'true', + ); +} + +export async function handleEmart24Stores(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('emart24-stores', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (keyword) { + parsed.options.keyword = keyword; + } + + const targetUrl = toUrl('/api/emart24/stores'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'emart24-stores', parsed.options.json === 'true', + ); +} + +export async function handleEmart24Products(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('emart24-products', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (!keyword) { + deps.writeErr('emart24-products 명령은 검색어가 필요합니다. 예: daiso emart24-products 두바이'); + return 1; + } + + const targetUrl = toUrl('/api/emart24/products'); + targetUrl.searchParams.set('keyword', keyword); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'emart24-products', parsed.options.json === 'true', + ); +} + +export async function handleEmart24Inventory(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('emart24-inventory', deps.writeOut, deps.writeErr); + } + + const pluCd = parsed.positionals[0]; + const bizNoArr = parsed.options.bizNoArr; + if (!pluCd || !bizNoArr) { + deps.writeErr( + 'emart24-inventory 명령은 pluCd와 --bizNoArr가 필요합니다. 예: daiso emart24-inventory 8800244010504 --bizNoArr 28339,05015', + ); + return 1; + } + + const targetUrl = toUrl('/api/emart24/inventory'); + targetUrl.searchParams.set('pluCd', pluCd); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'emart24-inventory', parsed.options.json === 'true', + ); +} + +export async function handleLotteMartStores(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('lottemart-stores', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (keyword) { + parsed.options.keyword = keyword; + } + + const targetUrl = toUrl('/api/lottemart/stores'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'lottemart-stores', parsed.options.json === 'true', + ); +} + +export async function handleLotteMartProducts(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('lottemart-products', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (!keyword) { + deps.writeErr('lottemart-products 명령은 검색어가 필요합니다. 예: daiso lottemart-products 콜라 --storeName 강변점'); + return 1; + } + + if (!parsed.options.storeCode && !parsed.options.storeName) { + deps.writeErr('lottemart-products 명령은 --storeCode 또는 --storeName이 필요합니다. 예: daiso lottemart-products 콜라 --storeName 강변점'); + return 1; + } + + const targetUrl = toUrl('/api/lottemart/products'); + targetUrl.searchParams.set('keyword', keyword); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'lottemart-products', parsed.options.json === 'true', + ); +} + +export async function handleGs25Stores(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('gs25-stores', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (keyword) { + parsed.options.keyword = keyword; + } + + const targetUrl = toUrl('/api/gs25/stores'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'gs25-stores', parsed.options.json === 'true', + ); +} + +export async function handleGs25Products(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('gs25-products', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (!keyword) { + deps.writeErr('gs25-products 명령은 검색어가 필요합니다. 예: daiso gs25-products 오감자'); + return 1; + } + + const targetUrl = toUrl('/api/gs25/products'); + targetUrl.searchParams.set('keyword', keyword); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'gs25-products', parsed.options.json === 'true', + ); +} + +export async function handleGs25Inventory(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('gs25-inventory', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (!keyword) { + deps.writeErr('gs25-inventory 명령은 검색어가 필요합니다. 예: daiso gs25-inventory 오감자'); + return 1; + } + + const targetUrl = toUrl('/api/gs25/inventory'); + targetUrl.searchParams.set('keyword', keyword); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'gs25-inventory', parsed.options.json === 'true', + ); +} + +export async function handleSevenElevenProducts(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('seveneleven-products', deps.writeOut, deps.writeErr); + } + + const query = parsed.positionals[0]; + if (!query) { + deps.writeErr( + 'seveneleven-products 명령은 검색어가 필요합니다. 예: daiso seveneleven-products 삼각김밥', + ); + return 1; + } + + const targetUrl = toUrl('/api/seveneleven/products'); + targetUrl.searchParams.set('query', query); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'seveneleven-products', parsed.options.json === 'true', + ); +} + +export async function handleSevenElevenStores(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('seveneleven-stores', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (!keyword) { + deps.writeErr( + 'seveneleven-stores 명령은 검색어가 필요합니다. 예: daiso seveneleven-stores 안산 중앙역', + ); + return 1; + } + + const targetUrl = toUrl('/api/seveneleven/stores'); + targetUrl.searchParams.set('keyword', keyword); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'seveneleven-stores', parsed.options.json === 'true', + ); +} + +export async function handleSevenElevenPopwords(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('seveneleven-popwords', deps.writeOut, deps.writeErr); + } + + const targetUrl = toUrl('/api/seveneleven/popwords'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'seveneleven-popwords', parsed.options.json === 'true', + ); +} + +export async function handleSevenElevenCatalog(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('seveneleven-catalog', deps.writeOut, deps.writeErr); + } + + const targetUrl = toUrl('/api/seveneleven/catalog'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'seveneleven-catalog', parsed.options.json === 'true', + ); +} + +export async function handleLottecinemaTheaters(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('lottecinema-theaters', deps.writeOut, deps.writeErr); + } + + const targetUrl = toUrl('/api/lottecinema/theaters'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'lottecinema-theaters', parsed.options.json === 'true', + ); +} + +export async function handleLottecinemaMovies(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('lottecinema-movies', deps.writeOut, deps.writeErr); + } + + const targetUrl = toUrl('/api/lottecinema/movies'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'lottecinema-movies', parsed.options.json === 'true', + ); +} + +export async function handleLottecinemaSeats(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('lottecinema-seats', deps.writeOut, deps.writeErr); + } + + const targetUrl = toUrl('/api/lottecinema/seats'); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, deps.writeOut, deps.writeErr, + targetUrl, 'lottecinema-seats', parsed.options.json === 'true', + ); +} diff --git a/src/cli/commands/daiso.ts b/src/cli/commands/daiso.ts new file mode 100644 index 0000000..64c7e13 --- /dev/null +++ b/src/cli/commands/daiso.ts @@ -0,0 +1,165 @@ +/** + * 다이소 관련 CLI 명령 핸들러 + */ + +import { printCommandHelp } from '../../cliHelp.js'; +import type { CliDeps } from '../types.js'; +import { parseCliArgs, toUrl, applyOptionsToQuery, toQueryOptions } from '../args.js'; +import { requestAndPrintResponse, requestAndPrintStoresWithKeywordFallback } from '../http.js'; + +export async function handleGet(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('get', deps.writeOut, deps.writeErr); + } + + const targetPath = parsed.positionals[0]; + if (!targetPath) { + deps.writeErr('get 명령은 경로가 필요합니다. 예: daiso get /api/daiso/products --q 수납박스'); + return 1; + } + + const targetUrl = toUrl(targetPath); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, + deps.writeOut, + deps.writeErr, + targetUrl, + 'get', + parsed.options.json === 'true', + ); +} + +export async function handleProducts(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('products', deps.writeOut, deps.writeErr); + } + + const query = parsed.positionals[0]; + if (!query) { + deps.writeErr('products 명령은 검색어가 필요합니다. 예: daiso products 수납박스'); + return 1; + } + + const targetUrl = toUrl('/api/daiso/products'); + targetUrl.searchParams.set('q', query); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, + deps.writeOut, + deps.writeErr, + targetUrl, + 'products', + parsed.options.json === 'true', + ); +} + +export async function handleProduct(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('product', deps.writeOut, deps.writeErr); + } + + const productId = parsed.positionals[0]; + if (!productId) { + deps.writeErr('product 명령은 제품 ID가 필요합니다. 예: daiso product 1034604'); + return 1; + } + + const targetUrl = toUrl(`/api/daiso/products/${productId}`); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, + deps.writeOut, + deps.writeErr, + targetUrl, + 'product', + parsed.options.json === 'true', + ); +} + +export async function handleStores(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('stores', deps.writeOut, deps.writeErr); + } + + const keyword = parsed.positionals[0]; + if (keyword) { + parsed.options.keyword = keyword; + } + + if (!parsed.options.keyword && !parsed.options.sido) { + deps.writeErr( + 'stores 명령은 keyword 또는 --sido가 필요합니다. 예: daiso stores 강남역 / daiso stores --sido 서울', + ); + return 1; + } + + return await requestAndPrintStoresWithKeywordFallback( + deps.fetchImpl, + deps.writeOut, + deps.writeErr, + toQueryOptions(parsed.options), + parsed.options.json === 'true', + ); +} + +export async function handleInventory(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('inventory', deps.writeOut, deps.writeErr); + } + + const productId = parsed.positionals[0]; + if (!productId) { + deps.writeErr( + 'inventory 명령은 제품 ID가 필요합니다. 예: daiso inventory 1034604 --keyword 강남역', + ); + return 1; + } + + const targetUrl = toUrl('/api/daiso/inventory'); + targetUrl.searchParams.set('productId', productId); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, + deps.writeOut, + deps.writeErr, + targetUrl, + 'inventory', + parsed.options.json === 'true', + ); +} + +export async function handleDisplayLocation(options: string[], deps: CliDeps): Promise { + const parsed = parseCliArgs(options); + if (parsed.options.help === 'true') { + return printCommandHelp('display-location', deps.writeOut, deps.writeErr); + } + + const productId = parsed.positionals[0]; + const storeCode = parsed.positionals[1]; + + if (!productId || !storeCode) { + deps.writeErr( + 'display-location 명령은 productId와 storeCode가 필요합니다. 예: daiso display-location 1034604 04515', + ); + return 1; + } + + const targetUrl = toUrl('/api/daiso/display-location'); + targetUrl.searchParams.set('productId', productId); + targetUrl.searchParams.set('storeCode', storeCode); + applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options)); + return await requestAndPrintResponse( + deps.fetchImpl, + deps.writeOut, + deps.writeErr, + targetUrl, + 'display-location', + parsed.options.json === 'true', + ); +} diff --git a/src/cli/constants.ts b/src/cli/constants.ts new file mode 100644 index 0000000..676dc01 --- /dev/null +++ b/src/cli/constants.ts @@ -0,0 +1,6 @@ +/** + * CLI 공통 상수 + */ + +export const DEFAULT_BASE_URL = 'https://mcp.aka.page'; +export const DEFAULT_MCP_URL = `${DEFAULT_BASE_URL}/mcp`; diff --git a/src/cli/deps.ts b/src/cli/deps.ts new file mode 100644 index 0000000..c38c2de --- /dev/null +++ b/src/cli/deps.ts @@ -0,0 +1,57 @@ +/** + * CLI 기본 의존성 생성 + */ + +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { runInteractiveCli } from '../cliInteractive.js'; +import type { CliDeps } from './types.js'; + +function loadVersion(): string { + const cliPath = fileURLToPath(import.meta.url); + const packagePath = path.resolve(path.dirname(cliPath), '../../package.json'); + + if (!existsSync(packagePath)) { + return '0.0.0'; + } + + const raw = readFileSync(packagePath, 'utf8'); + const parsed = JSON.parse(raw) as { version?: string }; + return parsed.version ?? '0.0.0'; +} + +async function execCommand(command: string, args: string[]): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + shell: false, + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + resolve(code ?? 1); + }); + }); +} + +export function createDefaultDeps(): CliDeps { + return { + fetchImpl: fetch, + writeOut: (message: string) => { + process.stdout.write(`${message}\n`); + }, + writeErr: (message: string) => { + process.stderr.write(`${message}\n`); + }, + getVersion: loadVersion, + nowIso: () => new Date().toISOString(), + runCommand: execCommand, + isInteractiveTerminal: () => Boolean(process.stdin.isTTY && process.stdout.isTTY), + runInteractive: runInteractiveCli, + }; +} diff --git a/src/cli/http.ts b/src/cli/http.ts new file mode 100644 index 0000000..3e88407 --- /dev/null +++ b/src/cli/http.ts @@ -0,0 +1,120 @@ +/** + * HTTP 요청 및 응답 처리 유틸리티 + */ + +import { renderApiEnvelope } from '../cliRenderer.js'; +import { buildDaisoStoreKeywordVariants } from '../utils/daisoKeyword.js'; +import type { FetchLike, WriteFn } from './types.js'; +import { applyOptionsToQuery, toUrl } from './args.js'; + +export async function requestAndPrintResponse( + fetchImpl: FetchLike, + writeOut: WriteFn, + writeErr: WriteFn, + url: URL, + command: string, + asJson: boolean, +): Promise { + try { + const response = await fetchImpl(url.toString()); + + if (!response.ok) { + const bodyText = await response.text(); + writeErr(`요청 실패: HTTP ${response.status}`); + if (bodyText) { + writeErr(bodyText); + } + return 1; + } + + const payload = (await response.json()) as unknown; + if (asJson) { + writeOut(JSON.stringify(payload, null, 2)); + } else { + writeOut(renderApiEnvelope(command, url, payload)); + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeErr(`요청 중 오류 발생: ${message}`); + return 1; + } +} + +function toStoreCount(payload: unknown): number { + if (typeof payload !== 'object' || payload === null) { + return 0; + } + const record = payload as { success?: unknown; data?: unknown }; + if (record.success !== true || typeof record.data !== 'object' || record.data === null) { + return 0; + } + const stores = (record.data as { stores?: unknown }).stores; + return Array.isArray(stores) ? stores.length : 0; +} + +export async function requestAndPrintStoresWithKeywordFallback( + fetchImpl: FetchLike, + writeOut: WriteFn, + writeErr: WriteFn, + options: Record, + asJson: boolean, +): Promise { + const originalKeyword = options.keyword; + if (!originalKeyword) { + const url = toUrl('/api/daiso/stores'); + applyOptionsToQuery(url, options); + return await requestAndPrintResponse(fetchImpl, writeOut, writeErr, url, 'stores', asJson); + } + + const keywords = buildDaisoStoreKeywordVariants(originalKeyword); + const candidates = keywords.length > 0 ? keywords : [originalKeyword]; + + try { + let lastUrl = toUrl('/api/daiso/stores'); + let lastPayload: unknown = null; + + for (const keyword of candidates) { + const targetUrl = toUrl('/api/daiso/stores'); + applyOptionsToQuery(targetUrl, { ...options, keyword }); + lastUrl = targetUrl; + + const response = await fetchImpl(targetUrl.toString()); + if (!response.ok) { + const bodyText = await response.text(); + writeErr(`요청 실패: HTTP ${response.status}`); + if (bodyText) { + writeErr(bodyText); + } + return 1; + } + + const payload = (await response.json()) as unknown; + lastPayload = payload; + + if (toStoreCount(payload) > 0) { + if (keyword !== originalKeyword) { + writeOut(`입력 키워드 "${originalKeyword}" 대신 "${keyword}"로 매장을 찾았습니다.`); + } + if (asJson) { + writeOut(JSON.stringify(payload, null, 2)); + } else { + writeOut(renderApiEnvelope('stores', targetUrl, payload)); + } + return 0; + } + } + + if (asJson) { + writeOut(JSON.stringify(lastPayload, null, 2)); + } else { + writeOut(renderApiEnvelope('stores', lastUrl, lastPayload)); + } + writeOut('힌트: "안산 중앙역" 대신 "안산중앙" 또는 "고잔"으로 검색해보세요.'); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeErr(`요청 중 오류 발생: ${message}`); + return 1; + } +} diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..289a85a --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,20 @@ +/** + * CLI 공통 타입 정의 + */ + +import type { InteractiveCliDeps } from '../cliInteractive.js'; + +export type FetchLike = typeof fetch; + +export type WriteFn = (message: string) => void; + +export interface CliDeps { + fetchImpl: FetchLike; + writeOut: WriteFn; + writeErr: WriteFn; + getVersion: () => string; + nowIso: () => string; + runCommand: (command: string, args: string[]) => Promise; + isInteractiveTerminal: () => boolean; + runInteractive: (deps: InteractiveCliDeps) => Promise; +}