diff --git a/src/app/commands/create.test.ts b/src/app/commands/create.test.ts index 2a51cd3..7c2c30c 100644 --- a/src/app/commands/create.test.ts +++ b/src/app/commands/create.test.ts @@ -44,6 +44,11 @@ describe("makeCreateCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockAddApp).mockResolvedValueOnce({ liffId: "12345" }); diff --git a/src/app/commands/create.ts b/src/app/commands/create.ts index 9e240a0..7764320 100644 --- a/src/app/commands/create.ts +++ b/src/app/commands/create.ts @@ -1,6 +1,7 @@ import { createCommand } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const createAction = async (options: { channelId?: string; @@ -8,16 +9,16 @@ const createAction = async (options: { endpointUrl: string; viewType: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } - + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); const { liffId } = await client.addApp({ view: { diff --git a/src/app/commands/delete.test.ts b/src/app/commands/delete.test.ts index 08ebc66..bd97685 100644 --- a/src/app/commands/delete.test.ts +++ b/src/app/commands/delete.test.ts @@ -12,6 +12,7 @@ import inquire from "inquirer"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; import { makeDeleteCommand } from "./delete.js"; +import { getApiBaseUrl } from "../../channel/baseUrl.js"; vi.mock("inquirer"); @@ -24,6 +25,7 @@ vi.mock("../../api/liff.js", () => { }; }); vi.mock("../../channel/resolveChannel.js"); +vi.mock("../../channel/baseUrl.js"); describe("makeDeleteCommand", () => { let mockConsoleInfo: MockInstance< @@ -44,6 +46,7 @@ describe("makeDeleteCommand", () => { }); it("should delete a LIFF app successfully", async () => { + vi.mocked(getApiBaseUrl).mockResolvedValueOnce("https://api.line.me"); vi.mocked(resolveChannel).mockResolvedValueOnce({ accessToken: "token", expiresIn: 3600, diff --git a/src/app/commands/delete.ts b/src/app/commands/delete.ts index ee97eb0..111eed1 100644 --- a/src/app/commands/delete.ts +++ b/src/app/commands/delete.ts @@ -2,17 +2,19 @@ import { Command } from "commander"; import { resolveChannel } from "../../channel/resolveChannel.js"; import { LiffApiClient } from "../../api/liff.js"; import inquirer from "inquirer"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const deleteAction = async (options: { channelId?: string; liffId: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const { confirmDelete } = await inquirer.prompt<{ confirmDelete: boolean }>([ { @@ -25,9 +27,10 @@ const deleteAction = async (options: { if (!confirmDelete) return; const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); + console.info(`Deleting LIFF app...`); await client.deleteApp(options.liffId); diff --git a/src/app/commands/list.test.ts b/src/app/commands/list.test.ts index 7fde1bd..4a48eab 100644 --- a/src/app/commands/list.test.ts +++ b/src/app/commands/list.test.ts @@ -36,6 +36,11 @@ describe("makeListCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "valid_token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockFetchApps).mockResolvedValueOnce({ apps: [ diff --git a/src/app/commands/list.ts b/src/app/commands/list.ts index 8240965..637ac8c 100644 --- a/src/app/commands/list.ts +++ b/src/app/commands/list.ts @@ -1,19 +1,22 @@ import { Command } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const listAction = async (options: { channelId?: string }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); + const { apps } = await client.fetchApps(); console.info("LIFF apps:"); diff --git a/src/app/commands/update.test.ts b/src/app/commands/update.test.ts index 7bb4f6a..71970cd 100644 --- a/src/app/commands/update.test.ts +++ b/src/app/commands/update.test.ts @@ -45,6 +45,11 @@ describe("makeUpdateCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); diff --git a/src/app/commands/update.ts b/src/app/commands/update.ts index 320f061..05a5f61 100644 --- a/src/app/commands/update.ts +++ b/src/app/commands/update.ts @@ -1,6 +1,7 @@ import { createCommand } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; +import { getLiffBaseUrl } from "../../channel/baseUrl.js"; const updateAction = async (options: { liffId: string; @@ -9,16 +10,17 @@ const updateAction = async (options: { endpointUrl?: string; viewType?: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channelInfo = await resolveChannel(options?.channelId); + if (!channelInfo) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } + const liffBaseUrl = await getLiffBaseUrl(options?.channelId); const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); await client.updateApp(options.liffId, { view: { diff --git a/src/channel/baseUrl.test.ts b/src/channel/baseUrl.test.ts new file mode 100644 index 0000000..f9db9f7 --- /dev/null +++ b/src/channel/baseUrl.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getApiBaseUrl, getLiffBaseUrl, setApiBaseUrl, setLiffBaseUrl } from "./baseUrl.js"; +import { resolveChannel } from "./resolveChannel.js"; +import { getChannel, setChannel } from "./stores/channels.js"; + +vi.mock("./resolveChannel.js"); +vi.mock("./stores/channels.js"); + +describe("baseUrl", () => { + const mockChannelId = "123"; + const mockApiBaseUrl = "https://api.example.com"; + const mockLiffBaseUrl = "https://liff.example.com"; + const mockChannel = { + secret: "secret", + accessToken: "token", + expiresIn: 3600, + issuedAt: 1000, + baseUrl: { + api: mockApiBaseUrl, + liff: mockLiffBaseUrl, + }, + }; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getApiBaseUrl", () => { + it("should return the api base URL from channel info", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(mockChannel); + + const result = await getApiBaseUrl(mockChannelId); + + expect(resolveChannel).toHaveBeenCalledWith(mockChannelId); + expect(result).toBe(mockApiBaseUrl); + }); + + it("should use default API base URL if not set in channel", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce({ + ...mockChannel, + baseUrl: {} // No API base URL set + }); + + const result = await getApiBaseUrl(mockChannelId); + + expect(result).toBe("https://api.line.me"); // Default from BASE_URL_CONFIG + }); + + it("should throw an error if channel is not found", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(undefined); + + await expect(getApiBaseUrl(mockChannelId)).rejects.toThrow("Channel not found."); + }); + }); + + describe("getLiffBaseUrl", () => { + it("should return the liff base URL from channel info", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(mockChannel); + + const result = await getLiffBaseUrl(mockChannelId); + + expect(resolveChannel).toHaveBeenCalledWith(mockChannelId); + expect(result).toBe(mockLiffBaseUrl); + }); + + it("should use default LIFF base URL if not set in channel", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce({ + ...mockChannel, + baseUrl: {} // No LIFF base URL set + }); + + const result = await getLiffBaseUrl(mockChannelId); + + expect(result).toBe("https://liff.line.me"); // Default from BASE_URL_CONFIG + }); + + it("should throw an error if channel is not found", async () => { + vi.mocked(resolveChannel).mockResolvedValueOnce(undefined); + + await expect(getLiffBaseUrl(mockChannelId)).rejects.toThrow("Channel not found."); + }); + }); + + describe("setApiBaseUrl", () => { + it("should update the API base URL of a channel", () => { + const newApiBaseUrl = "https://new-api.example.com"; + vi.mocked(getChannel).mockReturnValueOnce(mockChannel); + + setApiBaseUrl(mockChannelId, newApiBaseUrl); + + expect(getChannel).toHaveBeenCalledWith(mockChannelId); + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...mockChannel, + baseUrl: { + ...mockChannel.baseUrl, + api: newApiBaseUrl, + }, + }); + }); + + it("should throw an error if channel is not found", () => { + vi.mocked(getChannel).mockReturnValueOnce(undefined); + + expect(() => setApiBaseUrl(mockChannelId, "https://api.example.com")).toThrow( + `Channel ${mockChannelId} is not added yet.` + ); + expect(setChannel).not.toHaveBeenCalled(); + }); + + it("should handle channel without baseUrl property", () => { + const channelWithoutBaseUrl = { + secret: "secret", + accessToken: "token", + expiresIn: 3600, + issuedAt: 1000, + }; + const newApiBaseUrl = "https://new-api.example.com"; + + vi.mocked(getChannel).mockReturnValueOnce(channelWithoutBaseUrl); + + setApiBaseUrl(mockChannelId, newApiBaseUrl); + + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...channelWithoutBaseUrl, + baseUrl: { + api: newApiBaseUrl, + }, + }); + }); + }); + + describe("setLiffBaseUrl", () => { + it("should update the LIFF base URL of a channel", () => { + const newLiffBaseUrl = "https://new-liff.example.com"; + vi.mocked(getChannel).mockReturnValueOnce(mockChannel); + + setLiffBaseUrl(mockChannelId, newLiffBaseUrl); + + expect(getChannel).toHaveBeenCalledWith(mockChannelId); + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...mockChannel, + baseUrl: { + ...mockChannel.baseUrl, + liff: newLiffBaseUrl, + }, + }); + }); + + it("should throw an error if channel is not found", () => { + vi.mocked(getChannel).mockReturnValueOnce(undefined); + + expect(() => setLiffBaseUrl(mockChannelId, "https://liff.example.com")).toThrow( + `Channel ${mockChannelId} is not added yet.` + ); + expect(setChannel).not.toHaveBeenCalled(); + }); + + it("should handle channel without baseUrl property", () => { + const channelWithoutBaseUrl = { + secret: "secret", + accessToken: "token", + expiresIn: 3600, + issuedAt: 1000, + }; + const newLiffBaseUrl = "https://new-liff.example.com"; + + vi.mocked(getChannel).mockReturnValueOnce(channelWithoutBaseUrl); + + setLiffBaseUrl(mockChannelId, newLiffBaseUrl); + + expect(setChannel).toHaveBeenCalledWith(mockChannelId, { + ...channelWithoutBaseUrl, + baseUrl: { + liff: newLiffBaseUrl, + }, + }); + }); + }); +}); \ No newline at end of file diff --git a/src/channel/baseUrl.ts b/src/channel/baseUrl.ts new file mode 100644 index 0000000..509c0ab --- /dev/null +++ b/src/channel/baseUrl.ts @@ -0,0 +1,52 @@ +import { BASE_URL_CONFIG } from "../config/constants.js"; +import { resolveChannel } from "./resolveChannel.js"; +import { getChannel, setChannel } from "./stores/channels.js"; + +export const getApiBaseUrl = async (channelId?: string): Promise => { + const channelInfo = await resolveChannel(channelId); + if (!channelInfo) { + throw new Error(`Channel not found.`); + } + return channelInfo.baseUrl?.api || BASE_URL_CONFIG.api.defaultBaseUrl; +}; + +export const getLiffBaseUrl = async (channelId?: string): Promise => { + const channelInfo = await resolveChannel(channelId); + if (!channelInfo) { + throw new Error(`Channel not found.`); + } + return channelInfo.baseUrl?.liff || BASE_URL_CONFIG.liff.defaultBaseUrl; +}; + +export const setApiBaseUrl = (channelId: string, apiBaseUrl: string): void => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not added yet.`); + } + + setChannel(channelId, { + ...channel, + baseUrl: { + ...channel.baseUrl, + api: apiBaseUrl, + }, + }); +}; + +export const setLiffBaseUrl = ( + channelId: string, + liffBaseUrl: string, +): void => { + const channel = getChannel(channelId); + if (!channel) { + throw new Error(`Channel ${channelId} is not added yet.`); + } + + setChannel(channelId, { + ...channel, + baseUrl: { + ...channel.baseUrl, + liff: liffBaseUrl, + }, + }); +}; diff --git a/src/channel/renewAccessToken.test.ts b/src/channel/renewAccessToken.test.ts index 2fdeee3..9920524 100644 --- a/src/channel/renewAccessToken.test.ts +++ b/src/channel/renewAccessToken.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { AuthApiClient } from "../api/auth.js"; import { upsertChannel } from "./stores/channels.js"; import { renewAccessToken } from "./renewAccessToken.js"; +import {getApiBaseUrl} from "./baseUrl.js" vi.mock("../api/auth.js", () => { const fn = vi.fn(); @@ -13,6 +14,7 @@ vi.mock("../api/auth.js", () => { }); vi.mock("./stores/channels.js"); +vi.mock("./baseUrl.js"); describe("renewAccessToken", () => { let mockFetchStatelessChannelAccessToken: AuthApiClient["fetchStatelessChannelAccessToken"]; @@ -36,6 +38,7 @@ describe("renewAccessToken", () => { const expiresIn = 3600; const issuedAt = Date.now(); + vi.mocked(getApiBaseUrl).mockResolvedValueOnce("https://api.line.me"); vi.mocked(mockFetchStatelessChannelAccessToken).mockResolvedValue({ token_type: "Bearer", access_token: accessToken, diff --git a/src/channel/renewAccessToken.ts b/src/channel/renewAccessToken.ts index b047cf6..e9f3243 100644 --- a/src/channel/renewAccessToken.ts +++ b/src/channel/renewAccessToken.ts @@ -1,4 +1,5 @@ import { AuthApiClient } from "../api/auth.js"; +import { getApiBaseUrl } from "./baseUrl.js"; import { ChannelInfo, upsertChannel } from "./stores/channels.js"; export const renewAccessToken = async ( @@ -6,8 +7,9 @@ export const renewAccessToken = async ( channelSecret: string, issuedAt: number, ): Promise => { + const apiBaseUrl = await getApiBaseUrl(channelId); const client = new AuthApiClient({ - baseUrl: "https://api.line.me", + baseUrl: apiBaseUrl, }); const res = await client.fetchStatelessChannelAccessToken({ channelId: channelId, diff --git a/src/channel/stores/channels.ts b/src/channel/stores/channels.ts index 5591a85..7fc55e4 100644 --- a/src/channel/stores/channels.ts +++ b/src/channel/stores/channels.ts @@ -1,6 +1,7 @@ import Conf from "conf"; import { promises as fs } from "fs"; + const packageJson: { name: string; version: string; @@ -41,6 +42,10 @@ export type ChannelInfo = { accessToken: string; expiresIn: number; issuedAt: number; + baseUrl?: { + api?: string; + liff?: string; + }; }; type ChannelConfig = { @@ -66,11 +71,14 @@ export const upsertChannel = ( issuedAt: number, ): ChannelInfo => { const channels = store.get("channels") || {}; + const existingChannel = channels[channelId]; channels[channelId] = { secret: channelSecret, accessToken, expiresIn, issuedAt, + // Preserve existing endpoint settings if they exist + ...(existingChannel?.baseUrl ? { baseUrl: existingChannel.baseUrl } : {}), }; store.set("channels", channels); @@ -85,6 +93,15 @@ export const getChannel = (channelId: string): ChannelInfo | undefined => { return channels[channelId]; }; +export const setChannel = ( + channelId: string, + channelInfo: ChannelInfo, +): void => { + const channels = store.get("channels") || {}; + channels[channelId] = channelInfo; + store.set("channels", channels); +}; + export const setCurrentChannel = (channelId: string): void => { store.set("currentChannelId", channelId); }; diff --git a/src/config/commands/get.test.ts b/src/config/commands/get.test.ts new file mode 100644 index 0000000..d5dce8f --- /dev/null +++ b/src/config/commands/get.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { makeGetCommand } from "./get.js"; +import { + getCurrentChannelId +} from "../../channel/stores/channels.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; + +vi.mock("../../channel/stores/channels.js"); +vi.mock("../../channel/baseUrl.js"); + +describe("makeGetCommand", () => { + beforeEach(() => { + vi.stubGlobal("console", { + ...console, + info: vi.fn(), + error: vi.fn(), + }); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit called with code ${code}`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.only("should get API base URL for the current channel", async () => { + const apiBaseUrl = "https://custom-api.example.com"; + + vi.mocked(getApiBaseUrl).mockResolvedValueOnce(apiBaseUrl); + + const command = makeGetCommand(); + await command.parseAsync(["_", "get", "api-base-url"]); + + expect(getApiBaseUrl).toHaveBeenCalledWith(undefined); + expect(console.info).toHaveBeenCalledWith(apiBaseUrl); + }); + + it("should get LIFF base URL for a specific channel", async () => { + const channelId = "456"; + const liffBaseUrl = "https://custom-liff.example.com"; + + vi.mocked(getLiffBaseUrl).mockResolvedValueOnce(liffBaseUrl); + + const command = makeGetCommand(); + await command.parseAsync([ + "_", + "get", + "liff-base-url", + "--channel-id", + channelId, + ]); + + expect(getCurrentChannelId).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).toHaveBeenCalledWith(channelId); + expect(console.info).toHaveBeenCalledWith(liffBaseUrl); + }); + + it("should error when no current channel is set and no channel ID provided", async () => { + vi.mocked(getCurrentChannelId).mockReturnValueOnce(undefined); + + const command = makeGetCommand(); + + await expect( + command.parseAsync(["_", "get", "api-base-url"]), + ).rejects.toThrow( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should error with invalid config key", async () => { + const command = makeGetCommand(); + + await expect( + command.parseAsync(["_", "get", "invalid-key"]), + ).rejects.toThrow("process.exit called with code 1"); + + expect(console.error).toHaveBeenCalledWith( + "Error: Unknown config key: invalid-key", + ); + expect(console.error).toHaveBeenCalledWith( + "Valid keys: api-base-url, liff-base-url", + ); + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should propagate errors from base URL getter functions", async () => { + const channelId = "123"; + const errorMessage = "Failed to get API base URL"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getApiBaseUrl).mockImplementationOnce(() => { + throw new Error(errorMessage); + }); + + const command = makeGetCommand(); + + await expect( + command.parseAsync(["_", "get", "api-base-url"]), + ).rejects.toThrow(errorMessage); + + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + }); +}); diff --git a/src/config/commands/get.ts b/src/config/commands/get.ts new file mode 100644 index 0000000..5820cea --- /dev/null +++ b/src/config/commands/get.ts @@ -0,0 +1,37 @@ +import { createCommand } from "commander"; +import { BASE_URL_CONFIG, VALID_CONFIG_KEYS } from "../constants.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; + +const getAction = async (key: string, { channelId }: { channelId?: string }) => { + switch (key) { + case BASE_URL_CONFIG.api.configKey: + console.info(await getApiBaseUrl(channelId)); + break; + case BASE_URL_CONFIG.liff.configKey: + console.info(await getLiffBaseUrl(channelId)); + break; + default: + throw new Error(`Unknown config key: ${key}`); + } +}; + +export const makeGetCommand = () => { + const get = createCommand("get"); + get + .description("Get a configuration value") + .argument("", `Configuration key (${VALID_CONFIG_KEYS.join(", ")})`) + .option( + "-c, --channel-id [channelId]", + "Channel ID to get config for (defaults to current channel)", + ) + .action(async (key, options) => { + if (!VALID_CONFIG_KEYS.includes(key)) { + console.error(`Error: Unknown config key: ${key}`); + console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`); + process.exit(1); + } + await getAction(key, options); + }); + + return get; +}; diff --git a/src/config/commands/index.test.ts b/src/config/commands/index.test.ts new file mode 100644 index 0000000..fd0aa0f --- /dev/null +++ b/src/config/commands/index.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { Command } from "commander"; + +import { installConfigCommands } from "./index.js"; +import { makeGetCommand } from "./get.js"; +import { makeListCommand } from "./list.js"; +import { makeSetCommand } from "./set.js"; + +vi.mock("./get.js"); +vi.mock("./list.js"); +vi.mock("./set.js"); + +describe("installConfigCommands", () => { + it("should add subcommand to the config command", () => { + vi.mocked(makeGetCommand).mockReturnValueOnce(new Command("get")); + vi.mocked(makeListCommand).mockReturnValueOnce(new Command("list")); + vi.mocked(makeSetCommand).mockReturnValueOnce(new Command("set")); + + const program = new Command(); + installConfigCommands(program); + + const config = program.commands.find((cmd) => cmd.name() === "config"); + expect(config).toBeDefined(); + expect(config?.description()).toBe("Manage LIFF CLI configuration"); + + const getCommand = config?.commands.find((cmd) => cmd.name() === "get"); + expect(getCommand).toBeDefined(); + expect(makeGetCommand).toHaveBeenCalled(); + + const listCommand = config?.commands.find((cmd) => cmd.name() === "list"); + expect(listCommand).toBeDefined(); + expect(makeListCommand).toHaveBeenCalled(); + + const setCommand = config?.commands.find((cmd) => cmd.name() === "set"); + expect(setCommand).toBeDefined(); + expect(makeSetCommand).toHaveBeenCalled(); + }); +}); diff --git a/src/config/commands/index.ts b/src/config/commands/index.ts new file mode 100644 index 0000000..814e1c1 --- /dev/null +++ b/src/config/commands/index.ts @@ -0,0 +1,14 @@ +import { Command } from "commander"; + +import { makeGetCommand } from "./get.js"; +import { makeListCommand } from "./list.js"; +import { makeSetCommand } from "./set.js"; + +export const installConfigCommands = (program: Command) => { + const config = program.command("config"); + config.description("Manage LIFF CLI configuration"); + + config.addCommand(makeGetCommand()); + config.addCommand(makeListCommand()); + config.addCommand(makeSetCommand()); +}; diff --git a/src/config/commands/list.test.ts b/src/config/commands/list.test.ts new file mode 100644 index 0000000..5416d60 --- /dev/null +++ b/src/config/commands/list.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { makeListCommand } from "./list.js"; +import { + getChannel, + getCurrentChannelId, +} from "../../channel/stores/channels.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; + +vi.mock("../../channel/stores/channels.js"); +vi.mock("../../channel/baseUrl.js"); + +describe("makeListCommand", () => { + beforeEach(() => { + vi.stubGlobal("console", { + ...console, + info: vi.fn(), + error: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should list all configuration values for the current channel", async () => { + const channelId = "123"; + const apiBaseUrl = "https://custom-api.example.com"; + const liffBaseUrl = "https://api.line.me"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getChannel).mockReturnValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + baseUrl: { + api: apiBaseUrl, + }, + }); + vi.mocked(getApiBaseUrl).mockResolvedValueOnce(apiBaseUrl); + vi.mocked(getLiffBaseUrl).mockResolvedValueOnce(liffBaseUrl); + + const command = makeListCommand(); + await command.parseAsync(["_", "list"]); + + expect(getCurrentChannelId).toHaveBeenCalled(); + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + expect(getLiffBaseUrl).toHaveBeenCalledWith(channelId); + + expect(console.info).toHaveBeenCalledTimes(3); + expect(console.info).toHaveBeenNthCalledWith( + 1, + `Configuration for channel ${channelId}:`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 2, + `api-base-url = ${apiBaseUrl} `, + ); + expect(console.info).toHaveBeenNthCalledWith( + 3, + `liff-base-url = ${liffBaseUrl} `, + ); + }); + + it("should list all configuration values for a specific channel", async () => { + const channelId = "456"; + const apiBaseUrl = "https://api.line.me"; + const liffBaseUrl = "https://custom-liff.example.com"; + + vi.mocked(getChannel).mockReturnValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + baseUrl: { + liff: liffBaseUrl, + }, + }); + vi.mocked(getApiBaseUrl).mockResolvedValueOnce(apiBaseUrl); + vi.mocked(getLiffBaseUrl).mockResolvedValueOnce(liffBaseUrl); + + const command = makeListCommand(); + await command.parseAsync(["_", "list", "--channel-id", channelId]); + + expect(getCurrentChannelId).not.toHaveBeenCalled(); + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + expect(getLiffBaseUrl).toHaveBeenCalledWith(channelId); + + expect(console.info).toHaveBeenCalledTimes(3); + expect(console.info).toHaveBeenNthCalledWith( + 1, + `Configuration for channel ${channelId}:`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 2, + `api-base-url = ${apiBaseUrl} (default)`, + ); + expect(console.info).toHaveBeenNthCalledWith( + 3, + `liff-base-url = ${liffBaseUrl} `, + ); + }); + + it("should error when no current channel is set and no channel ID provided", async () => { + vi.mocked(getCurrentChannelId).mockReturnValueOnce(undefined); + + const command = makeListCommand(); + + await expect(command.parseAsync(["_", "list"])).rejects.toThrow( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + + expect(getChannel).not.toHaveBeenCalled(); + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should error when channel does not exist", async () => { + const channelId = "nonexistent"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getChannel).mockReturnValueOnce(undefined); + + const command = makeListCommand(); + + await expect(command.parseAsync(["_", "list"])).rejects.toThrow( + `Channel ${channelId} is not found.`, + ); + + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).not.toHaveBeenCalled(); + expect(getLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should propagate errors from base URL getter functions", async () => { + const channelId = "123"; + const errorMessage = "Failed to get API base URL"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(getChannel).mockReturnValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + }); + vi.mocked(getApiBaseUrl).mockImplementationOnce(() => { + throw new Error(errorMessage); + }); + + const command = makeListCommand(); + + await expect(command.parseAsync(["_", "list"])).rejects.toThrow( + errorMessage, + ); + + expect(getChannel).toHaveBeenCalledWith(channelId); + expect(getApiBaseUrl).toHaveBeenCalledWith(channelId); + }); +}); diff --git a/src/config/commands/list.ts b/src/config/commands/list.ts new file mode 100644 index 0000000..83d7595 --- /dev/null +++ b/src/config/commands/list.ts @@ -0,0 +1,48 @@ +import { createCommand } from "commander"; +import { + getChannel, + getCurrentChannelId, +} from "../../channel/stores/channels.js"; +import { BASE_URL_CONFIG } from "../constants.js"; +import { getApiBaseUrl, getLiffBaseUrl } from "../../channel/baseUrl.js"; + +const listAction = async ({ channelId }: { channelId?: string }) => { + const resolvedChannelId = channelId || getCurrentChannelId(); + if (!resolvedChannelId) { + throw new Error( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + } + + const channel = getChannel(resolvedChannelId); + if (!channel) { + throw new Error(`Channel ${resolvedChannelId} is not found.`); + } + + console.info(`Configuration for channel ${resolvedChannelId}:`); + + const apiBaseUrl = await getApiBaseUrl(resolvedChannelId); + const liffBaseUrl = await getLiffBaseUrl(resolvedChannelId); + + // api-base-url = https://api.line.me (default) + console.info( + `${BASE_URL_CONFIG.api.configKey} = ${apiBaseUrl} ${apiBaseUrl === BASE_URL_CONFIG.api.defaultBaseUrl ? "(default)" : ""}`, + ); + // liff-base-url = https://liff.line.me (default) + console.info( + `${BASE_URL_CONFIG.liff.configKey} = ${liffBaseUrl} ${liffBaseUrl === BASE_URL_CONFIG.liff.defaultBaseUrl ? "(default)" : ""}`, + ); +}; + +export const makeListCommand = () => { + const list = createCommand("list"); + list + .description("List all configuration values") + .option( + "-c, --channel-id [channelId]", + "Channel ID to list config for (defaults to current channel)", + ) + .action(listAction); + + return list; +}; diff --git a/src/config/commands/set.test.ts b/src/config/commands/set.test.ts new file mode 100644 index 0000000..19a7262 --- /dev/null +++ b/src/config/commands/set.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { makeSetCommand } from "./set.js"; +import { + getCurrentChannelId, +} from "../../channel/stores/channels.js"; +import { setApiBaseUrl, setLiffBaseUrl } from "../../channel/baseUrl.js"; + + +vi.mock("../../channel/stores/channels.js"); +vi.mock("../../channel/baseUrl.js"); + +describe("makeSetCommand", () => { + beforeEach(() => { + vi.stubGlobal("console", { + ...console, + info: vi.fn(), + error: vi.fn(), + }); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit called with code ${code}`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should set API base URL for the current channel", async () => { + const channelId = "123"; + const apiBaseUrl = "https://custom-api.example.com"; + + vi.mocked(getCurrentChannelId).mockReturnValueOnce(channelId); + vi.mocked(setApiBaseUrl).mockImplementationOnce(() => {}); + + const command = makeSetCommand(); + await command.parseAsync(["_", "set", "api-base-url", apiBaseUrl]); + + expect(getCurrentChannelId).toHaveBeenCalled(); + expect(setApiBaseUrl).toHaveBeenCalledWith(channelId, apiBaseUrl); + expect(console.info).toHaveBeenCalledWith( + `Successfully set api-base-url to ${apiBaseUrl}`, + ); + }); + + it("should set LIFF base URL for a specific channel", async () => { + const channelId = "456"; + const liffBaseUrl = "https://custom-liff.example.com"; + + vi.mocked(getCurrentChannelId).mockImplementationOnce(() => undefined); + vi.mocked(setLiffBaseUrl).mockImplementationOnce(() => {}); + + const command = makeSetCommand(); + await command.parseAsync([ + "_", + "set", + "liff-base-url", + liffBaseUrl, + "--channel-id", + channelId, + ]); + + expect(setLiffBaseUrl).toHaveBeenCalledWith(channelId, liffBaseUrl); + expect(console.info).toHaveBeenCalledWith( + `Successfully set liff-base-url to ${liffBaseUrl}`, + ); + }); + + it("should error when no current channel is set and no channel ID provided", async () => { + vi.mocked(getCurrentChannelId).mockReturnValueOnce(undefined); + + const command = makeSetCommand(); + + await expect( + command.parseAsync(["_", "set", "api-base-url", "https://example.com"]), + ).rejects.toThrow( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + + expect(setApiBaseUrl).not.toHaveBeenCalled(); + expect(setLiffBaseUrl).not.toHaveBeenCalled(); + }); + + it("should error with invalid config key", async () => { + const command = makeSetCommand(); + + await expect( + command.parseAsync(["_", "set", "invalid-key", "value"]), + ).rejects.toThrow("process.exit called with code 1"); + + expect(console.error).toHaveBeenCalledWith( + "Error: Unknown config key: invalid-key", + ); + expect(console.error).toHaveBeenCalledWith( + "Valid keys: api-base-url, liff-base-url", + ); + expect(setApiBaseUrl).not.toHaveBeenCalled(); + expect(setLiffBaseUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/config/commands/set.ts b/src/config/commands/set.ts new file mode 100644 index 0000000..37ca852 --- /dev/null +++ b/src/config/commands/set.ts @@ -0,0 +1,54 @@ +import { createCommand } from "commander"; +import { getCurrentChannelId } from "../../channel/stores/channels.js"; +import { BASE_URL_CONFIG, VALID_CONFIG_KEYS } from "../constants.js"; +import { setApiBaseUrl, setLiffBaseUrl } from "../../channel/baseUrl.js"; + +type ConfigValue = { + key: string; + value: string; + channelId?: string; +}; + +const setAction = ({ key, value, channelId }: ConfigValue) => { + const resolvedChannelId = channelId || getCurrentChannelId(); + if (!resolvedChannelId) { + throw new Error( + "Channel ID is required. Either specify --channel-id or set the current channel.", + ); + } + + switch (key) { + case BASE_URL_CONFIG.api.configKey: + setApiBaseUrl(resolvedChannelId, value); + break; + case BASE_URL_CONFIG.liff.configKey: + setLiffBaseUrl(resolvedChannelId, value); + break; + default: + throw new Error(`Unknown config key: ${key}`); + } + + console.info(`Successfully set ${key} to ${value}`); +}; + +export const makeSetCommand = () => { + const set = createCommand("set"); + set + .description("Set a configuration value") + .argument("", `Configuration key (${VALID_CONFIG_KEYS.join(", ")})`) + .argument("", "Value to set") + .option( + "-c, --channel-id [channelId]", + "Channel ID to set config for (defaults to current channel)", + ) + .action((key, value, options) => { + if (!VALID_CONFIG_KEYS.includes(key)) { + console.error(`Error: Unknown config key: ${key}`); + console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`); + process.exit(1); + } + setAction({ key, value, channelId: options.channelId }); + }); + + return set; +}; diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..35e8d61 --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1,15 @@ +export const BASE_URL_CONFIG = { + api: { + defaultBaseUrl: "https://api.line.me", + configKey: "api-base-url", + }, + liff: { + defaultBaseUrl: "https://liff.line.me", + configKey: "liff-base-url", + }, +} as const; + +export const VALID_CONFIG_KEYS = [ + BASE_URL_CONFIG.api.configKey, + BASE_URL_CONFIG.liff.configKey, +] as const; diff --git a/src/serve/serveAction.test.ts b/src/serve/serveAction.test.ts index fd3d9d5..997c6f0 100644 --- a/src/serve/serveAction.test.ts +++ b/src/serve/serveAction.test.ts @@ -49,6 +49,11 @@ describe("serveAction", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + }).mockResolvedValueOnce({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); @@ -79,7 +84,12 @@ describe("serveAction", () => { localProxyPort: "9000", }; - vi.mocked(resolveChannel).mockResolvedValueOnce({ + vi.mocked(resolveChannel).mockResolvedValue({ + accessToken: "token", + expiresIn: 3600, + secret: "secret", + issuedAt: 1000, + }).mockResolvedValue({ accessToken: "token", expiresIn: 3600, secret: "secret", diff --git a/src/serve/serveAction.ts b/src/serve/serveAction.ts index a88732a..cd0d1b0 100644 --- a/src/serve/serveAction.ts +++ b/src/serve/serveAction.ts @@ -5,6 +5,7 @@ import { getCurrentChannelId } from "../channel/stores/channels.js"; import { ProxyInterface } from "./proxy/proxy-interface.js"; import resolveEndpointUrl from "./resolveEndpointUrl.js"; import pc from "picocolors"; +import { getLiffBaseUrl } from "../channel/baseUrl.js"; export const serveAction = async ( options: { @@ -17,9 +18,15 @@ export const serveAction = async ( }, proxy: ProxyInterface, ) => { - const accessToken = (await resolveChannel(getCurrentChannelId())) - ?.accessToken; - if (!accessToken) { + const currentChannelId = getCurrentChannelId(); + if (!currentChannelId) { + throw new Error(`Current channel not set. + Please set the current channel first. + `); + } + + const channelInfo = await resolveChannel(currentChannelId); + if (!channelInfo) { throw new Error(`Access token not found. Please set the current channel first. `); @@ -48,12 +55,13 @@ export const serveAction = async ( } const httpsUrl = await proxy.connect(endpointUrl); - const liffUrl = new URL("https://liff.line.me/"); + const liffBaseUrl = await getLiffBaseUrl(currentChannelId); + const liffUrl = new URL(liffBaseUrl); liffUrl.pathname = options.liffId; const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channelInfo.accessToken, + baseUrl: liffBaseUrl, }); await client.updateApp(options.liffId, { view: { url: httpsUrl.toString() }, diff --git a/src/setup.test.ts b/src/setup.test.ts index a091c91..4081bdf 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -26,6 +26,7 @@ Commands: channel Manage LIFF channels app Manage LIFF apps serve [options] Manage HTTPS dev server + config Manage LIFF CLI configuration help [command] display help for command `); }); diff --git a/src/setup.ts b/src/setup.ts index 44925d5..7462fa3 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -2,11 +2,13 @@ import { Command } from "commander"; import { installChannelCommands } from "./channel/commands/index.js"; import { installAppCommands } from "./app/commands/index.js"; import { installServeCommands } from "./serve/commands/index.js"; +import { installConfigCommands } from "./config/commands/index.js"; export const setupCLI = (program: Command) => { installChannelCommands(program); installAppCommands(program); installServeCommands(program); + installConfigCommands(program); // TODO .version? return { run: (argv = process.argv) => {