diff --git a/.changeset/ten-rules-destroy.md b/.changeset/ten-rules-destroy.md new file mode 100644 index 000000000000..33564ba4ac9e --- /dev/null +++ b/.changeset/ten-rules-destroy.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +feat: Adds interactive prompts for the 'wrangler pages publish' and related commands. + +Additionally, those commands now read from `node_modules/.cache/wrangler/pages.json` to persist users' account IDs and project names. diff --git a/packages/wrangler/src/__tests__/config-cache.test.ts b/packages/wrangler/src/__tests__/config-cache.test.ts index 1a5830e9e960..7bdf09d1f1c9 100644 --- a/packages/wrangler/src/__tests__/config-cache.test.ts +++ b/packages/wrangler/src/__tests__/config-cache.test.ts @@ -1,31 +1,36 @@ import { getConfigCache, saveToConfigCache } from "../config-cache"; import { runInTempDir } from "./helpers/run-in-tmp"; +interface PagesConfigCache { + account_id: string; + pages_project_name: string; +} + describe("config cache", () => { runInTempDir(); const pagesConfigCacheFilename = "pages-config-cache.json"; it("should return an empty config if no file exists", () => { - expect(getConfigCache(pagesConfigCacheFilename)).toMatchInlineSnapshot( - `Object {}` - ); + expect( + getConfigCache(pagesConfigCacheFilename) + ).toMatchInlineSnapshot(`Object {}`); }); it("should read and write values without overriding old ones", () => { - saveToConfigCache(pagesConfigCacheFilename, { + saveToConfigCache(pagesConfigCacheFilename, { account_id: "some-account-id", pages_project_name: "foo", }); - expect(getConfigCache(pagesConfigCacheFilename).account_id).toEqual( - "some-account-id" - ); + expect( + getConfigCache(pagesConfigCacheFilename).account_id + ).toEqual("some-account-id"); - saveToConfigCache(pagesConfigCacheFilename, { + saveToConfigCache(pagesConfigCacheFilename, { pages_project_name: "bar", }); - expect(getConfigCache(pagesConfigCacheFilename).account_id).toEqual( - "some-account-id" - ); + expect( + getConfigCache(pagesConfigCacheFilename).account_id + ).toEqual("some-account-id"); }); }); diff --git a/packages/wrangler/src/__tests__/pages.test.ts b/packages/wrangler/src/__tests__/pages.test.ts index d16e1423177d..d8ff05656aa0 100644 --- a/packages/wrangler/src/__tests__/pages.test.ts +++ b/packages/wrangler/src/__tests__/pages.test.ts @@ -155,32 +155,7 @@ describe("pages", () => { unsetAllMocks(); }); - it("should create a project with a the default production branch", async () => { - setMockResponse( - "/accounts/:accountId/pages/projects", - ([_url, accountId], init) => { - expect(accountId).toEqual("some-account-id"); - expect(init.method).toEqual("POST"); - const body = JSON.parse(init.body as string); - expect(body).toEqual({ - name: "a-new-project", - production_branch: "production", - }); - return { - name: "a-new-project", - subdomain: "a-new-project.pages.dev", - production_branch: "production", - }; - } - ); - await runWrangler("pages project create a-new-project"); - expect(std.out).toMatchInlineSnapshot(` - "✨ Successfully created the 'a-new-project' project. It will be available at https://a-new-project.pages.dev/ once you create your first deployment. - To deploy a folder of assets, run 'wrangler pages publish [directory]'." - `); - }); - - it("should create a project with a the default production branch", async () => { + it("should create a project with a production branch", async () => { setMockResponse( "/accounts/:accountId/pages/projects", ([_url, accountId], init) => { @@ -250,7 +225,7 @@ describe("pages", () => { ]; const requests = mockListRequest(deployments); - await runWrangler("pages deployment list --project=images"); + await runWrangler("pages deployment list --project-name=images"); expect(requests.count).toBe(1); }); @@ -292,7 +267,7 @@ describe("pages", () => { --legacy-env Use legacy environments [boolean] Options: - --project The name of the project you want to list deployments for [string] + --project-name The name of the project you want to list deployments for [string] --branch The branch of the project you want to list deployments for [string] --commit-hash The branch of the project you want to list deployments for [string] --commit-message The branch of the project you want to list deployments for [string] @@ -316,7 +291,7 @@ describe("pages", () => { expect(logoPNGFile.name).toEqual("logo.png"); return { - id: "2082190357cfd3617ccfe04f340c6247d4b47484797840635feb491447bcd81c", + id: "2082190357cfd3617ccfe04f340c6247", }; } ); @@ -330,7 +305,7 @@ describe("pages", () => { const manifest = JSON.parse(body.get("manifest") as string); expect(manifest).toMatchInlineSnapshot(` Object { - "logo.png": "2082190357cfd3617ccfe04f340c6247d4b47484797840635feb491447bcd81c", + "logo.png": "2082190357cfd3617ccfe04f340c6247", } `); @@ -340,7 +315,7 @@ describe("pages", () => { } ); - await runWrangler("pages publish . --project=foo"); + await runWrangler("pages publish . --project-name=foo"); // TODO: Unmounting somehow loses this output diff --git a/packages/wrangler/src/config-cache.ts b/packages/wrangler/src/config-cache.ts index 33206608c0af..ec225a231a3c 100644 --- a/packages/wrangler/src/config-cache.ts +++ b/packages/wrangler/src/config-cache.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { dirname, join } from "path"; let cacheMessageShown = false; @@ -14,9 +14,7 @@ const showCacheMessage = () => { } }; -export const getConfigCache = >( - fileName: string -): Partial => { +export const getConfigCache = (fileName: string): Partial => { try { const configCacheLocation = join(cacheFolder, fileName); const configCache = JSON.parse(readFileSync(configCacheLocation, "utf-8")); @@ -27,7 +25,7 @@ export const getConfigCache = >( } }; -export const saveToConfigCache = >( +export const saveToConfigCache = ( fileName: string, newValues: Partial ) => { @@ -41,3 +39,7 @@ export const saveToConfigCache = >( ); showCacheMessage(); }; + +export const purgeConfigCaches = () => { + rmSync(cacheFolder, { recursive: true, force: true }); +}; diff --git a/packages/wrangler/src/dialogs.tsx b/packages/wrangler/src/dialogs.tsx index d7d7b2da57ea..e98870276860 100644 --- a/packages/wrangler/src/dialogs.tsx +++ b/packages/wrangler/src/dialogs.tsx @@ -43,12 +43,13 @@ export function confirm(text: string): Promise { type PromptProps = { text: string; + defaultValue?: string; type?: "text" | "password"; onSubmit: (text: string) => void; }; function Prompt(props: PromptProps) { - const [value, setValue] = useState(""); + const [value, setValue] = useState(props.defaultValue || ""); return ( @@ -65,11 +66,16 @@ function Prompt(props: PromptProps) { ); } -export async function prompt(text: string, type: "text" | "password" = "text") { +export async function prompt( + text: string, + type: "text" | "password" = "text", + defaultValue?: string +): Promise { return new Promise((resolve) => { const { unmount } = render( { unmount(); diff --git a/packages/wrangler/src/pages.tsx b/packages/wrangler/src/pages.tsx index 43cbee47019d..c06a5cb4d45f 100644 --- a/packages/wrangler/src/pages.tsx +++ b/packages/wrangler/src/pages.tsx @@ -10,6 +10,7 @@ import { URL } from "node:url"; import { hash } from "blake3-wasm"; import { watch } from "chokidar"; import { render, Text } from "ink"; +import SelectInput from "ink-select-input"; import Spinner from "ink-spinner"; import Table from "ink-table"; import { getType } from "mime"; @@ -22,7 +23,8 @@ import { buildWorker } from "../pages/functions/buildWorker"; import { generateConfigFromFileTree } from "../pages/functions/filepath-routing"; import { writeRoutesModule } from "../pages/functions/routes"; import { fetchResult } from "./cfetch"; -import { readConfig } from "./config"; +import { getConfigCache, saveToConfigCache } from "./config-cache"; +import { prompt } from "./dialogs"; import { FatalError } from "./errors"; import { logger } from "./logger"; import { getRequestContextCheckOptions } from "./miniflare-cli/request-context"; @@ -35,8 +37,6 @@ import type { BuildResult } from "esbuild"; import type { MiniflareOptions } from "miniflare"; import type { BuilderCallback, CommandModule } from "yargs"; -type ConfigPath = string | undefined; - export type Project = { name: string; subdomain: string; @@ -68,6 +68,13 @@ export type Deployment = { project_name: string; }; +interface PagesConfigCache { + account_id?: string; + project_name?: string; +} + +const PAGES_CONFIG_CACHE_FILENAME = "pages.json"; + // Defer importing miniflare until we really need it. This takes ~0.5s // and also modifies some `stream/web` and `undici` prototypes, so we // don't want to do this if pages commands aren't being called. @@ -776,7 +783,7 @@ async function buildFunctions({ interface CreateDeploymentArgs { directory: string; - project?: string; + projectName?: string; branch?: string; commitHash?: string; commitMessage?: string; @@ -796,7 +803,7 @@ const createDeployment: CommandModule< description: "The directory of Pages Functions", }) .options({ - project: { + "project-name": { type: "string", description: "The name of the project you want to list deployments for", @@ -824,11 +831,130 @@ const createDeployment: CommandModule< }) .epilogue(pagesBetaWarning); }, - handler: async (args) => { - const { directory, project } = args; - let { branch, commitHash, commitMessage, commitDirty } = args; + handler: async ({ + directory, + projectName, + branch, + commitHash, + commitMessage, + commitDirty, + }) => { + const config = getConfigCache( + PAGES_CONFIG_CACHE_FILENAME + ); + const isInteractive = process.stdin.isTTY; + const accountId = await requireAuth(config, isInteractive); + + projectName ??= config.project_name; + + if (!projectName && isInteractive) { + const existingOrNew = await new Promise<"new" | "existing">((resolve) => { + const { unmount } = render( + <> + + No project selected. Would you like to create one or use an + existing project? + + { + resolve(selected.value as "new" | "existing"); + unmount(); + }} + /> + + ); + }); + + switch (existingOrNew) { + case "existing": { + const projects = (await listProjects({ accountId })).filter( + (project) => !project.source + ); + projectName = await new Promise((resolve) => { + const { unmount } = render( + <> + Select a project: + ({ + key: project.name, + label: project.name, + value: project, + }))} + onSelect={async (selected) => { + resolve(selected.value.name); + unmount(); + }} + /> + + ); + }); + break; + } + case "new": { + projectName = await prompt("Enter the name of your new project:"); + + if (!projectName) { + throw new FatalError("Must specify a project name.", 1); + } + + let isGitDir = true; + try { + execSync(`git rev-parse --is-inside-work-tree`, { + stdio: "ignore", + }); + } catch (err) { + isGitDir = false; + } + + const productionBranch = await prompt( + "Enter the production branch name:", + "text", + isGitDir + ? execSync(`git branch | grep "* "`) + .toString() + .replace("* ", "") + .trim() + : "production" + ); + + if (!productionBranch) { + throw new FatalError("Must specify a production branch.", 1); + } + + await fetchResult(`/accounts/${accountId}/pages/projects`, { + method: "POST", + body: JSON.stringify({ + name: projectName, + production_branch: productionBranch, + }), + }); + + saveToConfigCache(PAGES_CONFIG_CACHE_FILENAME, { + account_id: accountId, + project_name: projectName, + }); + + logger.log(`✨ Successfully created the '${projectName}' project.`); + break; + } + } + } - // TODO: Project picker #821 + if (!projectName) { + throw new FatalError("Must specify a project name.", 1); + } // We infer git info by default is not passed in @@ -850,7 +976,7 @@ const createDeployment: CommandModule< ); if (!branch) { - branch = execSync(`git branch | grep " * "`) + branch = execSync(`git branch | grep "* "`) .toString() .replace("* ", "") .trim(); @@ -868,7 +994,7 @@ const createDeployment: CommandModule< } catch (err) {} if (isGitDirty && !commitDirty) { - console.warn( + logger.warn( `Warning: Your working directory is a git repo and has uncommitted changes\nTo silense this warning, pass in --commit-dirty=true` ); } @@ -878,9 +1004,6 @@ const createDeployment: CommandModule< } } - const config = readConfig(args.config as ConfigPath, args); - const accountId = await requireAuth(config); - type File = { content: Buffer; metadata: Metadata; @@ -950,7 +1073,7 @@ const createDeployment: CommandModule< content: fileContent, metadata: { sizeInBytes: filestat.size, - hash: hash(content).toString("hex"), + hash: hash(content).toString("hex").slice(0, 32), }, }); } @@ -988,7 +1111,7 @@ const createDeployment: CommandModule< // TODO: Consider a retry const promise = fetchResult<{ id: string }>( - `/accounts/${accountId}/pages/projects/${project}/file`, + `/accounts/${accountId}/pages/projects/${projectName}/file`, { method: "POST", body: form, @@ -998,7 +1121,7 @@ const createDeployment: CommandModule< rerender(); if (response.id != file.metadata.hash) { throw new Error( - `Looks like there was an issue uploading that ${name}. Try again perhaps?` + `Looks like there was an issue uploading '${name}'. Try again perhaps?` ); } }); @@ -1012,7 +1135,7 @@ const createDeployment: CommandModule< const uploadMs = Date.now() - start; - console.log( + logger.log( `✨ Success! Uploaded ${fileMap.size} files ${formatTime(uploadMs)}\n` ); @@ -1093,14 +1216,19 @@ const createDeployment: CommandModule< } const deploymentResponse = await fetchResult( - `/accounts/${accountId}/pages/projects/${project}/deployment`, + `/accounts/${accountId}/pages/projects/${projectName}/deployment`, { method: "POST", body: formData, } ); - console.log( + saveToConfigCache(PAGES_CONFIG_CACHE_FILENAME, { + account_id: accountId, + project_name: projectName, + }); + + logger.log( `✨ Deployment complete! Take a peek over at ${deploymentResponse.url}` ); }, @@ -1473,9 +1601,12 @@ export const pages: BuilderCallback = (yargs) => { "list", "List your Cloudflare Pages projects", (yargs) => yargs.epilogue(pagesBetaWarning), - async (args) => { - const config = readConfig(args.config as ConfigPath, args); - const accountId = await requireAuth(config); + async () => { + const config = getConfigCache( + PAGES_CONFIG_CACHE_FILENAME + ); + const isInteractive = process.stdin.isTTY; + const accountId = await requireAuth(config, isInteractive); const projects: Array = await listProjects({ accountId }); @@ -1489,15 +1620,20 @@ export const pages: BuilderCallback = (yargs) => { : timeagoFormat(project.created_on), }; }); + + saveToConfigCache(PAGES_CONFIG_CACHE_FILENAME, { + account_id: accountId, + }); + render(
); } ) .command( - "create [name]", + "create [project-name]", "Create a new Cloudflare Pages project", (yargs) => yargs - .positional("name", { + .positional("project-name", { type: "string", demandOption: true, description: "The name of your Pages project", @@ -1505,35 +1641,70 @@ export const pages: BuilderCallback = (yargs) => { .options({ "production-branch": { type: "string", - // TODO: Should we default to the current git branch? - default: "production", description: "The name of the production branch of your project", }, }) .epilogue(pagesBetaWarning), - async (args) => { - const { "production-branch": productionBranch, name } = args; + async ({ productionBranch, projectName }) => { + const config = getConfigCache( + PAGES_CONFIG_CACHE_FILENAME + ); + const isInteractive = process.stdin.isTTY; + const accountId = await requireAuth(config, isInteractive); - if (!name) { + if (!projectName && isInteractive) { + projectName = await prompt("Enter the name of your new project:"); + } + + if (!projectName) { throw new FatalError("Must specify a project name.", 1); } - const config = readConfig(args.config as ConfigPath, args); - const accountId = await requireAuth(config); + if (!productionBranch && isInteractive) { + let isGitDir = true; + try { + execSync(`git rev-parse --is-inside-work-tree`, { + stdio: "ignore", + }); + } catch (err) { + isGitDir = false; + } + + productionBranch = await prompt( + "Enter the production branch name:", + "text", + isGitDir + ? execSync(`git branch | grep "* "`) + .toString() + .replace("* ", "") + .trim() + : "production" + ); + } + + if (!productionBranch) { + throw new FatalError("Must specify a production branch.", 1); + } const { subdomain } = await fetchResult( `/accounts/${accountId}/pages/projects`, { method: "POST", body: JSON.stringify({ - name, + name: projectName, production_branch: productionBranch, }), } ); + + saveToConfigCache(PAGES_CONFIG_CACHE_FILENAME, { + account_id: accountId, + project_name: projectName, + }); + logger.log( - `✨ Successfully created the '${name}' project. It will be available at https://${subdomain}/ once you create your first deployment.` + `✨ Successfully created the '${projectName}' project. It will be available at https://${subdomain}/ once you create your first deployment.` ); logger.log( `To deploy a folder of assets, run 'wrangler pages publish [directory]'.` @@ -1553,20 +1724,50 @@ export const pages: BuilderCallback = (yargs) => { (yargs) => yargs .options({ - project: { + "project-name": { type: "string", - demandOption: true, description: "The name of the project you would like to list deployments for", }, }) .epilogue(pagesBetaWarning), - async (args) => { - const config = readConfig(args.config as ConfigPath, args); - const accountId = await requireAuth(config); + async ({ projectName }) => { + const config = getConfigCache( + PAGES_CONFIG_CACHE_FILENAME + ); + const isInteractive = process.stdin.isTTY; + const accountId = await requireAuth(config, isInteractive); + + projectName ??= config.project_name; + + if (!projectName && isInteractive) { + const projects = await listProjects({ accountId }); + projectName = await new Promise((resolve) => { + const { unmount } = render( + <> + Select a project: + ({ + key: project.name, + label: project.name, + value: project, + }))} + onSelect={async (selected) => { + resolve(selected.value.name); + unmount(); + }} + /> + + ); + }); + } + + if (!projectName) { + throw new FatalError("Must specify a project name.", 1); + } const deployments: Array = await fetchResult( - `/accounts/${accountId}/pages/projects/${args.project}/deployments` + `/accounts/${accountId}/pages/projects/${projectName}/deployments` ); const titleCase = (word: string) => @@ -1595,6 +1796,11 @@ export const pages: BuilderCallback = (yargs) => { Build: `https://dash.cloudflare.com/${accountId}/pages/view/${deployment.project_name}/${deployment.id}`, }; }); + + saveToConfigCache(PAGES_CONFIG_CACHE_FILENAME, { + account_id: accountId, + }); + render(
); } ) diff --git a/packages/wrangler/src/user.tsx b/packages/wrangler/src/user.tsx index 297b18dbe69a..72aff6f9dcd6 100644 --- a/packages/wrangler/src/user.tsx +++ b/packages/wrangler/src/user.tsx @@ -220,11 +220,11 @@ import Table from "ink-table"; import React from "react"; import { fetch } from "undici"; import { getCloudflareApiBaseUrl } from "./cfetch"; +import { purgeConfigCaches } from "./config-cache"; import { getEnvironmentVariableFactory } from "./environment-variables"; import { logger } from "./logger"; import openInBrowser from "./open-in-browser"; import { parseTOML, readFileSync } from "./parse"; -import type { Config } from "./config"; import type { Item as SelectInputItem } from "ink-select-input/build/SelectInput"; import type { ParsedUrlQuery } from "node:querystring"; import type { Response } from "undici"; @@ -979,6 +979,8 @@ export async function login(props?: LoginProps): Promise { }); logger.log(`Successfully logged in.`); + purgeConfigCaches(); + return; } } @@ -1156,7 +1158,7 @@ export function ChooseAccount(props: { * Ensure that a user is logged in, and a valid account_id is available. */ export async function requireAuth( - config: Config, + config: { account_id?: string }, isInteractive = true ): Promise { const loggedIn = await loginOrRefreshIfRequired(isInteractive);