diff --git a/.gitignore b/.gitignore index 15ef2c6..baf771c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ node_modules dist - +.DS_Store *.pem diff --git a/src/app/commands/create.ts b/src/app/commands/create.ts index 9e240a0..40ef6d8 100644 --- a/src/app/commands/create.ts +++ b/src/app/commands/create.ts @@ -2,12 +2,14 @@ import { createCommand } from "commander"; import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; -const createAction = async (options: { +type CreateAppOptions = { channelId?: string; name: string; endpointUrl: string; viewType: string; -}) => { +}; + +export const createLiffApp = async (options: CreateAppOptions) => { const accessToken = (await resolveChannel(options?.channelId))?.accessToken; if (!accessToken) { throw new Error(`Access token not found. @@ -27,6 +29,12 @@ const createAction = async (options: { description: options.name, }); + return liffId; +}; + +const createAction = async (options: CreateAppOptions) => { + const liffId = await createLiffApp(options); + console.info(`Successfully created LIFF app: ${liffId}`); }; @@ -36,16 +44,16 @@ export const makeCreateCommand = () => { .description("Create a new LIFF app") .option( "-c, --channel-id [channelId]", - "The channel ID to use. If it isn't specified, the currentChannelId's will be used.", + "The channel ID to use. If it isn't specified, the currentChannelId will be used.", ) .requiredOption("-n, --name ", "The name of the LIFF app") .requiredOption( "-e, --endpoint-url ", - "The endpoint URL of the LIFF app", + "The endpoint URL of the LIFF app. Must be 'https://'", ) .requiredOption( "-v, --view-type ", - "The view type of the LIFF app", + "The view type of the LIFF app. Must be 'compact', 'tall', or 'full'", ) .action(createAction); diff --git a/src/app/commands/delete.test.ts b/src/app/commands/delete.test.ts index 08ebc66..ca67c40 100644 --- a/src/app/commands/delete.test.ts +++ b/src/app/commands/delete.test.ts @@ -99,7 +99,7 @@ describe("makeDeleteCommand", () => { expect(mockDeleteApp).not.toHaveBeenCalled(); }); - it("should not delete a LIFF app if user cancels the deletio", async () => { + it("should not delete a LIFF app if user cancels the deletion", async () => { vi.mocked(resolveChannel).mockResolvedValueOnce({ accessToken: "token", expiresIn: 3600, diff --git a/src/app/commands/update.ts b/src/app/commands/update.ts index 320f061..8e7b464 100644 --- a/src/app/commands/update.ts +++ b/src/app/commands/update.ts @@ -38,7 +38,7 @@ export const makeUpdateCommand = () => { .requiredOption("-l, --liff-id ", "The LIFF ID to update") .option( "-c, --channel-id [channelId]", - "The channel ID to use. If it isn't specified, the currentChannelId's will be used.", + "The channel ID to use. If it isn't specified, the currentChannelId will be used.", ) .option("-n, --name [name]", "The name of the LIFF app") .option( diff --git a/src/channel/commands/add.ts b/src/channel/commands/add.ts index 1af0af0..ad0c0da 100644 --- a/src/channel/commands/add.ts +++ b/src/channel/commands/add.ts @@ -3,7 +3,9 @@ import inquirer from "inquirer"; import { renewAccessToken } from "../renewAccessToken.js"; -const addAction: (channelId?: string) => Promise = async (channelId) => { +export const addAction: (channelId?: string) => Promise = async ( + channelId, +) => { if (!channelId) { throw new Error("Channel ID is required."); } diff --git a/src/init/commands/index.test.ts b/src/init/commands/index.test.ts new file mode 100644 index 0000000..9340335 --- /dev/null +++ b/src/init/commands/index.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import inquire from "inquirer"; +import { Command } from "commander"; +import { installInitCommands, makeOptions } from "./index.js"; +import { initAction } from "../initAction.js"; + +vi.mock("../initAction.js"); +vi.mock("inquirer"); + +const TEST_OPTIONS = { + channelId: "123", + name: "My App", + viewType: "compact", + endpointUrl: "https://example.com", +}; + +describe("makeOptions", () => { + // 1 + it("should return parameters as is when all parameters are given", async () => { + // prompt input will be ignored + vi.mocked(inquire.prompt).mockResolvedValue({ + channelId: "789", + name: "hoge", + viewType: "tall", + endpointUrl: "https://example.com/this-will-be-ignored", + }); + + const result = await makeOptions(TEST_OPTIONS); + + expect(result).toEqual(TEST_OPTIONS); + }); + + // 2 + it("should prompt for all missing parameters", async () => { + vi.mocked(inquire.prompt).mockResolvedValue(TEST_OPTIONS); + + const result = await makeOptions({}); + + expect(result).toEqual(TEST_OPTIONS); + }); + + // 3 + it("should prompt for partly missing parameters", async () => { + vi.mocked(inquire.prompt).mockResolvedValue({ + viewType: "compact", + endpointUrl: "https://example.com", + }); + + const result = await makeOptions({ + channelId: "123", + name: "My App", + }); + + expect(result).toEqual(TEST_OPTIONS); + }); + + // 4 + it("should prompt use default endpointUrl when empty", async () => { + vi.mocked(inquire.prompt).mockResolvedValue({ + viewType: "compact", + endpointUrl: "", + }); + + const result = await makeOptions({ + channelId: "123", + name: "My App", + }); + + expect(result).toEqual({ + channelId: "123", + name: "My App", + viewType: "compact", + endpointUrl: "https://localhost:9000", + }); + }); +}); + +describe("installInitCommands", async () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should call init with correct options", () => { + const program = new Command(); + + const command = installInitCommands(program); + command.parseAsync([ + "_", + "init", + "--channel-id", + TEST_OPTIONS.channelId, + "--name", + TEST_OPTIONS.name, + "--view-type", + TEST_OPTIONS.viewType, + "--endpoint-url", + TEST_OPTIONS.endpointUrl, + ]); + + expect(initAction).toHaveBeenCalledWith(TEST_OPTIONS); + }); +}); diff --git a/src/init/commands/index.ts b/src/init/commands/index.ts new file mode 100644 index 0000000..edd6100 --- /dev/null +++ b/src/init/commands/index.ts @@ -0,0 +1,87 @@ +import { Command } from "commander"; +import inquirer from "inquirer"; +import { initAction } from "../initAction.js"; + +const DEFAULT_ENDPOINT_URL = "https://localhost:9000"; + +export type InitOptions = { + channelId?: string; + name?: string; + endpointUrl?: string; + viewType?: string; +}; + +export async function makeOptions( + options: InitOptions, +): Promise> { + const promptItems = []; + + if (!options.channelId) { + promptItems.push({ + type: "input", + name: "channelId", + message: "Channel ID?", + }); + } + + if (!options.name) { + promptItems.push({ + type: "input", + name: "name", + message: "App name?", + }); + } + + if (!options.viewType) { + promptItems.push({ + type: "list", + name: "viewType", + message: "View type?", + choices: ["compact", "tall", "full"], + }); + } + + if (!options.endpointUrl) { + promptItems.push({ + type: "input", + name: "endpointUrl", + message: `Endpoint URL? (leave empty for default '${DEFAULT_ENDPOINT_URL}')`, + }); + } + + const promptInputs = await inquirer.prompt<{ [key: string]: string }>( + promptItems, + ); + + return { + channelId: options.channelId ?? promptInputs.channelId, + name: options.name ?? promptInputs.name, + viewType: options.viewType ?? promptInputs.viewType, + endpointUrl: + options.endpointUrl ?? + (promptInputs.endpointUrl?.length > 0 + ? promptInputs.endpointUrl + : DEFAULT_ENDPOINT_URL), + }; +} + +export const installInitCommands = (program: Command) => { + const init = program.command("init"); + init + .description("Initialize new LIFF app") + .option( + "-c, --channel-id [channelId]", + "The channel ID to use. If it isn't specified, the currentChannelId will be used.", + ) + .option("-n, --name ", "The name of the LIFF app") + .option( + "-v, --view-type ", + "The view type of the LIFF app. Must be 'compact', 'tall', or 'full'", + ) + .option( + "-e, --endpoint-url ", + "The endpoint URL of the LIFF app. Must be 'https://'", + ) + .action((options) => initAction(options)); + return init; +}; diff --git a/src/init/initAction.test.ts b/src/init/initAction.test.ts new file mode 100644 index 0000000..3e6c235 --- /dev/null +++ b/src/init/initAction.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import child_process from "child_process"; +import { initAction } from "./initAction.js"; +import { createLiffApp } from "../app/commands/create.js"; + +vi.mock("../channel/commands/add.js"); +vi.mock("../app/commands/create.js"); +vi.mock("child_process"); +vi.mock("inquirer"); + +const TEST_OPTIONS = { + channelId: "123", + name: "My App", + viewType: "compact", + endpointUrl: "https://example.com", +}; + +describe("initAction", () => { + it("should call execSync with correct command", async () => { + vi.mocked(child_process.execSync).mockReturnValue(Buffer.from("")); + + const DUMMY_LIFF_ID = "hogehoge123"; + vi.mocked(createLiffApp).mockResolvedValue(DUMMY_LIFF_ID); + + await initAction(TEST_OPTIONS); + + expect(child_process.execSync).toHaveBeenCalledWith( + `npx @line/create-liff-app "${TEST_OPTIONS.name}" -l ${DUMMY_LIFF_ID}`, + { stdio: "inherit" }, + ); + }); +}); diff --git a/src/init/initAction.ts b/src/init/initAction.ts new file mode 100644 index 0000000..c792302 --- /dev/null +++ b/src/init/initAction.ts @@ -0,0 +1,37 @@ +import { execSync } from "child_process"; +import { addAction as addChannelAction } from "../channel/commands/add.js"; +import { createLiffApp } from "../app/commands/create.js"; +import { InitOptions, makeOptions } from "./commands/index.js"; + +export const initAction: (options: InitOptions) => Promise = async ( + options, +) => { + // collect required information via prompt if not specified via parameter + const consolidatedOptions = await makeOptions(options); + + // 1. add channel + await addChannelAction(consolidatedOptions.channelId); + + // 2. create liff app (@ server) + const liffId = await createLiffApp(consolidatedOptions); + + // 3. create liff app (@ client) + execSync( + `npx @line/create-liff-app "${consolidatedOptions.name}" -l ${liffId}`, + { + stdio: "inherit", + }, + ); + + // 4. print instructions on how to run locally + console.info(`App ${liffId} successfully created. + +Now do the following: + 1. go to app directory: \`cd ${consolidatedOptions.name}\` + 2. create certificate key files (e.g. \`mkcert localhost\`, see: https://developers.line.biz/en/docs/liff/liff-cli/#serve-operating-conditions ) + 3. run LIFF app template using command above (e.g. \`npm run dev\` or \`yarn dev\`) + 4. open new terminal window, navigate to \`${consolidatedOptions.name}\` directory + 5. run \`liff-cli serve -l ${liffId} -u http://localhost:\${PORT FROM STEP 3.}/\` + 6. open browser and navigate to http://localhost:\${PORT FROM STEP 3.}/ +`); +}; diff --git a/src/setup.test.ts b/src/setup.test.ts index a091c91..bb55c8d 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -25,6 +25,7 @@ Options: Commands: channel Manage LIFF channels app Manage LIFF apps + init [options] Initialize new LIFF app serve [options] Manage HTTPS dev server help [command] display help for command `); diff --git a/src/setup.ts b/src/setup.ts index 44925d5..6647321 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,11 +1,13 @@ import { Command } from "commander"; import { installChannelCommands } from "./channel/commands/index.js"; import { installAppCommands } from "./app/commands/index.js"; +import { installInitCommands } from "./init/commands/index.js"; import { installServeCommands } from "./serve/commands/index.js"; export const setupCLI = (program: Command) => { installChannelCommands(program); installAppCommands(program); + installInitCommands(program); installServeCommands(program); // TODO .version? return {