diff --git a/README.md b/README.md index 061ab9f..f85b5e6 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ CGV mcp를 사용해서 강남 CGV 영화랑 시간표 확인해줘 > Claude Code CLI에서 MCP 서버 추가 ```bash -claude mcp add daiso-mcp https://mcp.aka.page --transport sse +claude mcp add daiso-mcp https://mcp.aka.page --transport http ```
diff --git a/package-lock.json b/package-lock.json index 9d790d6..6630988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,8 +31,8 @@ "playwright": "^1.58.2", "prettier": "^3.8.1", "tsx": "^4.20.6", - "typescript": "^5.7.2", - "typescript-eslint": "^8.57.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", "vitest": "^4.1.0", "wrangler": "^4.73.0" }, @@ -1953,20 +1953,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1976,9 +1976,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", + "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1992,16 +1992,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "engines": { @@ -2013,18 +2013,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.2", - "@typescript-eslint/types": "^8.57.2", + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "engines": { @@ -2035,18 +2035,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2057,9 +2057,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "dev": true, "license": "MIT", "engines": { @@ -2070,21 +2070,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2095,13 +2095,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "dev": true, "license": "MIT", "engines": { @@ -2113,21 +2113,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2137,20 +2137,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2161,17 +2161,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5956,9 +5956,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5970,16 +5970,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", - "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.2", - "@typescript-eslint/parser": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2" + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5990,7 +5990,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uc.micro": { diff --git a/package.json b/package.json index 2a3f687..3df3dc4 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "playwright": "^1.58.2", "prettier": "^3.8.1", "tsx": "^4.20.6", - "typescript": "^5.7.2", - "typescript-eslint": "^8.57.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", "vitest": "^4.1.0", "wrangler": "^4.73.0" }, 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; +} diff --git a/vitest.config.ts b/vitest.config.ts index ad4f32f..178b9a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,7 @@ export default defineConfig({ 'src/pages/openapi.ts', // 엔트리 재노출 파일 'src/bin.ts', // npm bin 진입점 파일 'src/cli.ts', // 실행 진입점 파일 + 'src/cli/**/*.ts', // CLI 오케스트레이션 분리 모듈 'src/cliInteractive.ts', // 인터랙티브 UI 오케스트레이션 파일 'src/cliRenderer.ts', // CLI 출력 렌더러 파일 ],