Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Provide init command #2

Merged
merged 12 commits into from
Feb 19, 2025
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules
dist

.DS_Store
*.pem
18 changes: 13 additions & 5 deletions src/app/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}`);
};

Expand All @@ -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 <name>", "The name of the LIFF app")
.requiredOption(
"-e, --endpoint-url <endpointUrl>",
"The endpoint URL of the LIFF app",
"The endpoint URL of the LIFF app. Must be 'https://'",
)
.requiredOption(
"-v, --view-type <viewType>",
"The view type of the LIFF app",
"The view type of the LIFF app. Must be 'compact', 'tall', or 'full'",
)
.action(createAction);

Expand Down
2 changes: 1 addition & 1 deletion src/app/commands/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/app/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const makeUpdateCommand = () => {
.requiredOption("-l, --liff-id <liffId>", "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(
Expand Down
4 changes: 3 additions & 1 deletion src/channel/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import inquirer from "inquirer";

import { renewAccessToken } from "../renewAccessToken.js";

const addAction: (channelId?: string) => Promise<void> = async (channelId) => {
export const addAction: (channelId?: string) => Promise<void> = async (
channelId,
) => {
if (!channelId) {
throw new Error("Channel ID is required.");
}
Expand Down
102 changes: 102 additions & 0 deletions src/init/commands/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
87 changes: 87 additions & 0 deletions src/init/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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<Required<InitOptions>> {
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 <name>", "The name of the LIFF app")
.option(
"-v, --view-type <viewType>",
"The view type of the LIFF app. Must be 'compact', 'tall', or 'full'",
)
.option(
"-e, --endpoint-url <endpointUrl>",
"The endpoint URL of the LIFF app. Must be 'https://'",
)
.action((options) => initAction(options));
return init;
};
32 changes: 32 additions & 0 deletions src/init/initAction.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
);
});
});
37 changes: 37 additions & 0 deletions src/init/initAction.ts
Original file line number Diff line number Diff line change
@@ -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<void> = 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.}/
`);
};
1 change: 1 addition & 0 deletions src/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`);
Expand Down
2 changes: 2 additions & 0 deletions src/setup.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down