Skip to content

Commit

Permalink
Refactor create-app prompts with tests (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
tzyl authored Feb 7, 2024
1 parent 0c08899 commit 77e4313
Show file tree
Hide file tree
Showing 18 changed files with 841 additions and 216 deletions.
5 changes: 5 additions & 0 deletions packages/create-app/changelog/@unreleased/pr-32.v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type: improvement
improvement:
description: Refactor create-app prompts and add unit tests
links:
- https://github.com/palantir/osdk-ts/pull/32
226 changes: 10 additions & 216 deletions packages/create-app/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,16 @@ import {
generateEnvProduction,
} from "./generate/generateEnv.js";
import { generateNpmRc } from "./generate/generateNpmRc.js";
import { green, italic } from "./highlight.js";
import { green } from "./highlight.js";
import { promptApplicationUrl } from "./prompts/promptApplicationUrl.js";
import { promptClientId } from "./prompts/promptClientId.js";
import { promptFoundryUrl } from "./prompts/promptFoundryUrl.js";
import { promptOsdkPackage } from "./prompts/promptOsdkPackage.js";
import { promptOsdkRegistryUrl } from "./prompts/promptOsdkRegistryUrl.js";
import { promptOverwrite } from "./prompts/promptOverwrite.js";
import { promptProject } from "./prompts/promptProject.js";
import { promptTemplate } from "./prompts/promptTemplate.js";
import type { Template, TemplateContext } from "./templates.js";
import { TEMPLATES } from "./templates.js";

interface CliArgs {
project?: string;
Expand Down Expand Up @@ -95,7 +102,7 @@ export async function cli(args: string[] = process.argv) {

const parsed: CliArgs = base.parseSync();
const project: string = await promptProject(parsed);
const overwrite: boolean = await promptOverwrite(parsed, project);
const overwrite: boolean = await promptOverwrite({ ...parsed, project });
const template: Template = await promptTemplate(parsed);
const foundryUrl: string = await promptFoundryUrl(parsed);
const applicationUrl: string | undefined = await promptApplicationUrl(parsed);
Expand Down Expand Up @@ -194,216 +201,3 @@ export async function cli(args: string[] = process.argv) {
},
});
}

async function promptProject(parsed: CliArgs): Promise<string> {
let project = parsed.project;
while (project == null || !/^[a-zA-Z0-9-_]+$/.test(project)) {
if (project != null) {
consola.fail(
"Project name can only contain alphanumeric characters, hyphens and underscores",
);
}
project = await consola.prompt("Project name:", {
type: "text",
placeholder: "my-osdk-app",
default: "my-osdk-app",
});
}

return project;
}

async function promptOverwrite(
parsed: CliArgs,
project: string,
): Promise<boolean> {
if (!fs.existsSync(path.join(process.cwd(), project))) {
return true;
}

if (parsed.overwrite != null) {
return parsed.overwrite;
}

const result = (await consola.prompt(
`The directory ${
green(
project,
)
} already exists do you want to overwrite or ignore it?`,
{
type: "select",
options: [
{ label: "Remove existing files and continue", value: "overwrite" },
{ label: "Ignore files and continue", value: "ignore" },
{ label: "Cancel", value: "cancel" },
],
},
// Types for "select" are wrong the value is returned rather than the option object
// https://github.com/unjs/consola/pull/238
)) as unknown as "overwrite" | "ignore" | "cancel";

switch (result) {
case "overwrite":
return true;
case "ignore":
return false;
case "cancel":
consola.fail("Operation cancelled");
process.exit(0);
}
}

async function promptTemplate(parsed: CliArgs): Promise<Template> {
let template = TEMPLATES.find((t) => t.id === parsed.template);
if (template == null) {
const templateId = (await consola.prompt(
parsed.template != null
? `The provided template ${
green(
parsed.template,
)
} is invalid please select a framework:`
: "Select a framework:",
{
type: "select",
options: TEMPLATES.map((template) => ({
value: template.id,
label: template.label,
})),
// Types for "select" are wrong the value is returned rather than the option object
// https://github.com/unjs/consola/pull/238
},
)) as unknown as string;

template = TEMPLATES.find((t) => t.id === templateId);
if (template == null) {
throw new Error(`Template ${templateId} should be found`);
}
}

return template;
}

async function promptFoundryUrl(parsed: CliArgs): Promise<string> {
let foundryUrl = parsed.foundryUrl;
while (foundryUrl == null || !foundryUrl.startsWith("https://")) {
if (foundryUrl != null) {
consola.fail("Please enter a valid Foundry URL");
}
foundryUrl = await consola.prompt(
`Enter the URL for your Foundry stack:\n${
italic(
"(Example https://example.palantirfoundry.com/)",
)
}`,
{ type: "text" },
);
}
return foundryUrl.replace(/\/$/, "");
}

async function promptApplicationUrl(
parsed: CliArgs,
): Promise<string | undefined> {
if (parsed.skipApplicationUrl) {
return undefined;
}

let applicationUrl = parsed.applicationUrl;
if (applicationUrl == null) {
const skip = (await consola.prompt(
`Do you know the URL your production application will be hosted on? This is required to create a production build of your application with the correct OAuth redirect URL.`,
{
type: "select",
options: [
{ label: "Yes, prefill it for me", value: "yes" },
{ label: "No, I will fill it in myself later", value: "no" },
],
},
// Types for "select" are wrong the value is returned rather than the option object
// https://github.com/unjs/consola/pull/238
)) as unknown as "yes" | "no";

if (skip === "no") {
return undefined;
}
}

while (applicationUrl == null || !/^https?:\/\//.test(applicationUrl)) {
if (applicationUrl != null) {
consola.fail("Please enter a valid application URL");
}
applicationUrl = await consola.prompt(
`Enter the URL your production application will be hosted on:\n${
italic(
"(Example https://myapp.example.palantirfoundry.com/)",
)
}`,
{ type: "text" },
);
}
return applicationUrl.replace(/\/$/, "");
}

async function promptClientId(parsed: CliArgs): Promise<string> {
let clientId = parsed.clientId;
while (clientId == null || !/^[0-9a-f]+$/.test(clientId)) {
if (clientId != null) {
consola.fail("Please enter a valid OAuth client ID");
}
clientId = await consola.prompt(
`Enter the OAuth client ID for your application from Developer Console:\n${
italic(
"(Example 2650385ab6c5e0df3b44aff776b00a42)",
)
}`,
{ type: "text" },
);
}
return clientId;
}

async function promptOsdkPackage(parsed: CliArgs): Promise<string> {
let osdkPackage = parsed.osdkPackage;
while (osdkPackage == null || !/^@[a-z0-9-]+\/sdk$/.test(osdkPackage)) {
if (osdkPackage != null) {
consola.fail("Please enter a valid OSDK package name");
}
osdkPackage = await consola.prompt(
`Enter the OSDK package name for your application from Developer Console:\n${
italic(
"(Example @my-app/sdk)",
)
}`,
{ type: "text" },
);
}
return osdkPackage;
}

async function promptOsdkRegistryUrl(parsed: CliArgs): Promise<string> {
let osdkRegistryUrl = parsed.osdkRegistryUrl;
while (
osdkRegistryUrl == null
|| !/^https:\/\/[^/]+\/artifacts\/api\/repositories\/ri\.artifacts\.[^/]+\/contents\/release\/npm\/?$/
.test(
osdkRegistryUrl,
)
) {
if (osdkRegistryUrl != null) {
consola.fail(
"Please enter a valid NPM registry URL to install your OSDK package",
);
}
osdkRegistryUrl = await consola.prompt(
`Enter the NPM registry URL to install your OSDK package from Developer Console:\n${
italic(
"(Example https://example.palantirfoundry.com/artifacts/api/repositories/ri.artifacts.main.repository.a4a7fe1c-486f-4226-b706-7b90005f527d/contents/release/npm)",
)
}`,
{ type: "text" },
);
}
return osdkRegistryUrl.replace(/\/$/, "");
}
76 changes: 76 additions & 0 deletions packages/create-app/src/prompts/promptApplicationUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright 2023 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { afterEach, expect, test, vi } from "vitest";
import { consola } from "../consola.js";
import { promptApplicationUrl } from "./promptApplicationUrl.js";

vi.mock("../consola.js");

afterEach(() => {
vi.restoreAllMocks();
});

const valid = "https://app.com";

test("it accepts valid application url from prompt", async () => {
vi.mocked(consola).prompt.mockResolvedValueOnce("yes");
vi.mocked(consola).prompt.mockResolvedValueOnce(valid);
expect(await promptApplicationUrl({})).toEqual(valid);
expect(vi.mocked(consola).prompt).toHaveBeenCalledTimes(2);
});

test("it prompts again if answered value is invalid", async () => {
vi.mocked(consola).prompt.mockResolvedValueOnce("yes");
vi.mocked(consola).prompt.mockResolvedValueOnce("invalid");
vi.mocked(consola).prompt.mockResolvedValueOnce("ftp://abc.com");
vi.mocked(consola).prompt.mockResolvedValueOnce(valid);
expect(await promptApplicationUrl({})).toEqual(valid);
expect(vi.mocked(consola).prompt).toHaveBeenCalledTimes(4);
});

test("it accepts valid initial value without prompt", async () => {
expect(await promptApplicationUrl({ applicationUrl: valid })).toEqual(valid);
expect(vi.mocked(consola).prompt).not.toHaveBeenCalled();
});

test("it prompts if initial value is invalid", async () => {
vi.mocked(consola).prompt.mockResolvedValueOnce(valid);
expect(await promptApplicationUrl({ applicationUrl: "invalid" })).toEqual(
valid,
);
expect(vi.mocked(consola).prompt).toHaveBeenCalledTimes(1);
});

test("it strips trailing slash from url", async () => {
vi.mocked(consola).prompt.mockResolvedValueOnce("yes");
vi.mocked(consola).prompt.mockResolvedValueOnce(valid + "/");
expect(await promptApplicationUrl({})).toEqual(valid);
expect(vi.mocked(consola).prompt).toHaveBeenCalledTimes(2);
});

test("it skips prompting application url if told to fill in later", async () => {
vi.mocked(consola).prompt.mockResolvedValueOnce("no");
expect(await promptApplicationUrl({})).toEqual(undefined);
expect(vi.mocked(consola).prompt).toHaveBeenCalledTimes(1);
});

test("it skips prompting completely if told to", async () => {
expect(await promptApplicationUrl({ skipApplicationUrl: true })).toEqual(
undefined,
);
expect(vi.mocked(consola).prompt).not.toHaveBeenCalled();
});
63 changes: 63 additions & 0 deletions packages/create-app/src/prompts/promptApplicationUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2023 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { consola } from "../consola.js";
import { italic } from "../highlight.js";

export async function promptApplicationUrl(
{ skipApplicationUrl, applicationUrl }: {
skipApplicationUrl?: boolean;
applicationUrl?: string;
},
): Promise<string | undefined> {
if (skipApplicationUrl) {
return undefined;
}

if (applicationUrl == null) {
const skip = (await consola.prompt(
`Do you know the URL your production application will be hosted on? This is required to create a production build of your application with the correct OAuth redirect URL.`,
{
type: "select",
options: [
{ label: "Yes, prefill it for me", value: "yes" },
{ label: "No, I will fill it in myself later", value: "no" },
],
},
// Types for "select" are wrong the value is returned rather than the option object
// https://github.com/unjs/consola/pull/238
)) as unknown as "yes" | "no";

if (skip === "no") {
return undefined;
}
}

while (applicationUrl == null || !/^https?:\/\//.test(applicationUrl)) {
if (applicationUrl != null) {
consola.fail("Please enter a valid application URL");
}
applicationUrl = await consola.prompt(
`Enter the URL your production application will be hosted on:\n${
italic(
"(Example https://myapp.example.palantirfoundry.com/)",
)
}`,
{ type: "text" },
);
}
return applicationUrl.replace(/\/$/, "");
}
Loading

0 comments on commit 77e4313

Please sign in to comment.