From 3970fd3a06b0ab6d5b6c26281014219dd1261ecc Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 20 Mar 2024 19:06:56 +0000 Subject: [PATCH 01/11] Add specific error code --- packages/wrangler/src/pages/build-env.ts | 6 +++++- packages/wrangler/src/pages/errors.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/wrangler/src/pages/build-env.ts b/packages/wrangler/src/pages/build-env.ts index c359d5408c06..4bc5c35b276e 100644 --- a/packages/wrangler/src/pages/build-env.ts +++ b/packages/wrangler/src/pages/build-env.ts @@ -1,6 +1,7 @@ import { readConfig } from "../config"; import { FatalError } from "../errors"; import { logger } from "../logger"; +import { EXIT_CODE_NO_CONFIG_FOUND } from "./errors"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -19,7 +20,10 @@ export const Handler = async (args: PagesBuildEnvArgs) => { env: process.env.PAGES_ENVIRONMENT, }); if (!config.pages_build_output_dir) { - throw new FatalError("No Pages config file found"); + throw new FatalError( + "No Pages config file found", + EXIT_CODE_NO_CONFIG_FOUND + ); } // Ensure JSON variables are not included diff --git a/packages/wrangler/src/pages/errors.ts b/packages/wrangler/src/pages/errors.ts index c64d97477932..d6a187683017 100644 --- a/packages/wrangler/src/pages/errors.ts +++ b/packages/wrangler/src/pages/errors.ts @@ -20,6 +20,8 @@ export enum ApiErrorCodes { export const EXIT_CODE_FUNCTIONS_NO_ROUTES_ERROR = 156; export const EXIT_CODE_FUNCTIONS_NOTHING_TO_BUILD_ERROR = 157; +export const EXIT_CODE_NO_CONFIG_FOUND = 158; + /** * Pages error when building a script from the functions directory fails */ From 5278da5340c331391bf718d41897084bbcca88f0 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 21 Mar 2024 20:10:22 +0000 Subject: [PATCH 02/11] Add location --- packages/wrangler/src/pages/build-env.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/wrangler/src/pages/build-env.ts b/packages/wrangler/src/pages/build-env.ts index 4bc5c35b276e..c9e52a6e5995 100644 --- a/packages/wrangler/src/pages/build-env.ts +++ b/packages/wrangler/src/pages/build-env.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { readConfig } from "../config"; import { FatalError } from "../errors"; import { logger } from "../logger"; @@ -10,11 +11,17 @@ import type { export type PagesBuildEnvArgs = StrictYargsOptionsToInterface; export function Options(yargs: CommonYargsArgv) { - return yargs; + return yargs.positional("projectDir", { + type: "string", + description: "The location of the Pages project", + }); } export const Handler = async (args: PagesBuildEnvArgs) => { - const config = readConfig(undefined, { + if (!args.projectDir) { + throw new FatalError("No Pages project location specified"); + } + const config = readConfig(path.join(args.projectDir, "wrangler.toml"), { ...args, // eslint-disable-next-line turbo/no-undeclared-env-vars env: process.env.PAGES_ENVIRONMENT, From 4191958b020dc930164f691427944a4de37ceedc Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 21 Mar 2024 21:20:36 +0000 Subject: [PATCH 03/11] Add argument --- packages/wrangler/src/pages/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/pages/index.ts b/packages/wrangler/src/pages/index.ts index b4f70ce175b1..1c9d91628b98 100644 --- a/packages/wrangler/src/pages/index.ts +++ b/packages/wrangler/src/pages/index.ts @@ -44,7 +44,7 @@ export function pages(yargs: CommonYargsArgv) { Build.Handler ) .command( - "build-env", + "build-env [projectDir]", "Render a list of environment variables from the config file", BuildEnv.Options, BuildEnv.Handler From ac07261c8b6ec7a4b6892264d23369749624c0b7 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 21 Mar 2024 21:42:01 +0000 Subject: [PATCH 04/11] Support basic config download --- packages/wrangler/src/metrics/send-event.ts | 3 +- .../wrangler/src/pages/download-config.tsx | 177 ++++++++++++++++++ packages/wrangler/src/pages/index.ts | 9 + 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 packages/wrangler/src/pages/download-config.tsx diff --git a/packages/wrangler/src/metrics/send-event.ts b/packages/wrangler/src/metrics/send-event.ts index 60228322c2e5..867879ffc727 100644 --- a/packages/wrangler/src/metrics/send-event.ts +++ b/packages/wrangler/src/metrics/send-event.ts @@ -71,7 +71,8 @@ export type EventNames = | "list worker versions" | "view versioned deployment" | "view latest versioned deployment" - | "list versioned deployments"; + | "list versioned deployments" + | "pages download config"; /** * Send a metrics event, with no extra properties, to Cloudflare, if usage tracking is enabled. diff --git a/packages/wrangler/src/pages/download-config.tsx b/packages/wrangler/src/pages/download-config.tsx new file mode 100644 index 000000000000..c0acea0ce4e6 --- /dev/null +++ b/packages/wrangler/src/pages/download-config.tsx @@ -0,0 +1,177 @@ +import { writeFile } from "node:fs/promises"; +import TOML from "@iarna/toml"; +import chalk from "chalk"; +import { fetchResult } from "../cfetch"; +import { getConfigCache } from "../config-cache"; +import { FatalError } from "../errors"; +import { logger } from "../logger"; +import * as metrics from "../metrics"; +import { printWranglerBanner } from "../update-check"; +import { requireAuth } from "../user"; +import { PAGES_CONFIG_CACHE_FILENAME } from "./constants"; +import type { RawConfig } from "../config"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../yargs-types"; +import type { PagesConfigCache } from "./types"; +import type { Project } from "@cloudflare/types"; + +export function mapBindings( + project: Project["deployment_configs"]["production"] +): RawConfig { + const configObj = {} as RawConfig; + for (const [name, envVar] of Object.entries(project.env_vars ?? {}).filter( + ([_, val]) => val && val?.type == "plain_text" + )) { + if (envVar?.value) { + configObj.vars ??= {}; + configObj.vars[name] = envVar?.value; + } + } + + for (const [name, namespace] of Object.entries(project.kv_namespaces ?? {})) { + configObj.kv_namespaces ??= []; + configObj.kv_namespaces.push({ id: namespace.namespace_id, binding: name }); + } + + // TODO: support Durable Objects + // for (const [name, namespace] of Object.entries( + // project.durable_object_namespaces ?? {} + // )) { + // configObj.durable_objects ??= { bindings: [] }; + // configObj.durable_objects.bindings.push({ + // name: name, + // class_name: binding.class_name, + // script_name: binding.script_name, + // environment: binding.environment, + // }); + // } + + for (const [name, namespace] of Object.entries(project.d1_databases ?? {})) { + configObj.d1_databases ??= []; + configObj.d1_databases.push({ + database_id: namespace.id, + binding: name, + database_name: name, + }); + } + + for (const [name, bucket] of Object.entries(project.r2_buckets ?? {})) { + configObj.r2_buckets ??= []; + configObj.r2_buckets.push({ + bucket_name: bucket.name, + binding: name, + }); + } + // @ts-expect-error Types are wrong + for (const [name, { service, environment }] of Object.entries( + // @ts-expect-error Types are wrong + project.services ?? {} + )) { + configObj.services ??= []; + configObj.services.push({ + binding: name, + service, + environment, + }); + } + + for (const [name, queue] of Object.entries( + // @ts-expect-error Types are wrong + project.queue_producers ?? {} + )) { + configObj.queues ??= { producers: [] }; + // @ts-expect-error TS is silly + configObj.queues.producers.push({ + binding: name, + // @ts-expect-error Types are wrong + queue: queue.name, + }); + } + + // @ts-expect-error Types are wrong + for (const [name, { dataset }] of Object.entries( + // @ts-expect-error Types are wrong + project.analytics_engine_datasets ?? {} + )) { + configObj.analytics_engine_datasets ??= []; + configObj.analytics_engine_datasets.push({ + binding: name, + dataset, + }); + } + for (const [name] of Object.entries( + // @ts-expect-error Types are wrong + project.ai_bindings ?? {} + )) { + configObj.ai = { binding: name }; + } + return configObj; +} +async function writeWranglerToml(toml: RawConfig) { + const wranglerCommandUsed = ["wrangler", ...process.argv.slice(2)].join(" "); + + // Pages does not support custom wrangler.toml locations, so always write to ./wrangler.toml + await writeFile( + "wrangler.toml", + [ + `# Generated by Wrangler on ${new Date()}`, + `# by running \`${wranglerCommandUsed}\``, + TOML.stringify(toml as TOML.JsonMap), + ].join("\n") + ); +} + +async function downloadProject(accountId: string, projectName: string) { + const project = await fetchResult( + `/accounts/${accountId}/pages/projects/${projectName}` + ); + + return { + name: project.name, + pages_build_output_dir: project.build_config.destination_dir, + compatibility_date: + project.deployment_configs.production.compatibility_date ?? + new Date().toISOString().substring(0, 10), + compatibility_flags: + project.deployment_configs.production.compatibility_flags, + ...mapBindings(project.deployment_configs.production), + env: { + production: mapBindings(project.deployment_configs.production), + preview: mapBindings(project.deployment_configs.preview), + }, + }; +} + +type DownloadConfigArgs = StrictYargsOptionsToInterface; + +export function Options(yargs: CommonYargsArgv) { + return yargs.positional("projectName", { + type: "string", + description: "The Pages project to download", + }); +} + +export const Handler = async ({ projectName }: DownloadConfigArgs) => { + void metrics.sendMetricsEvent("pages download config"); + await printWranglerBanner(); + + const projectConfig = getConfigCache( + PAGES_CONFIG_CACHE_FILENAME + ); + const accountId = await requireAuth(projectConfig); + + projectName ??= projectConfig.project_name; + + if (!projectName) { + throw new FatalError("Must specify a project name.", 1); + } + const config = await downloadProject(accountId, projectName); + await writeWranglerToml(config); + logger.info( + chalk.green( + "Success! Your project settings have been downloaded to wrangler.toml" + ) + ); +}; diff --git a/packages/wrangler/src/pages/index.ts b/packages/wrangler/src/pages/index.ts index 1c9d91628b98..8a4a26ca1009 100644 --- a/packages/wrangler/src/pages/index.ts +++ b/packages/wrangler/src/pages/index.ts @@ -6,6 +6,7 @@ import * as Deploy from "./deploy"; import * as DeploymentTails from "./deployment-tails"; import * as Deployments from "./deployments"; import * as Dev from "./dev"; +import * as DownloadConfig from "./download-config"; import * as Functions from "./functions"; import * as Projects from "./projects"; import * as Upload from "./upload"; @@ -56,6 +57,14 @@ export function pages(yargs: CommonYargsArgv) { Functions.OptimizeRoutesHandler ) ) + .command("download", "⚡️ Download settings from your project", (args) => + args.command( + "config [projectName]", + "Experimental: Download your Pages project config as a wrangler.toml file", + DownloadConfig.Options, + DownloadConfig.Handler + ) + ) .command("project", "⚡️ Interact with your Pages projects", (args) => args .command( From ad9fe1ff8a0742bb121cc6b748587616be35b03d Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 21 Mar 2024 23:18:43 +0000 Subject: [PATCH 05/11] DO --- .../wrangler/src/pages/download-config.tsx | 120 ++++++++++++------ 1 file changed, 78 insertions(+), 42 deletions(-) diff --git a/packages/wrangler/src/pages/download-config.tsx b/packages/wrangler/src/pages/download-config.tsx index c0acea0ce4e6..abe3e790b708 100644 --- a/packages/wrangler/src/pages/download-config.tsx +++ b/packages/wrangler/src/pages/download-config.tsx @@ -9,7 +9,7 @@ import * as metrics from "../metrics"; import { printWranglerBanner } from "../update-check"; import { requireAuth } from "../user"; import { PAGES_CONFIG_CACHE_FILENAME } from "./constants"; -import type { RawConfig } from "../config"; +import type { RawEnvironment } from "../config"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -17,10 +17,54 @@ import type { import type { PagesConfigCache } from "./types"; import type { Project } from "@cloudflare/types"; -export function mapBindings( - project: Project["deployment_configs"]["production"] -): RawConfig { - const configObj = {} as RawConfig; +// TODO: fix the Project definition +type DeploymentConfig = Project["deployment_configs"]["production"]; +interface PagesDeploymentConfig extends DeploymentConfig { + services: Record< + string, + { + service: string; + environment?: string; + } + >; + queue_producers: Record< + string, + { + name: string; + } + >; + analytics_engine_datasets: Record< + string, + { + dataset: string; + } + >; + durable_object_namespaces: Record< + string, + { + namespace_id: string; + } + >; + ai_bindings: Record>; +} + +interface PagesProject extends Project { + deployment_configs: { + production: PagesDeploymentConfig; + preview: PagesDeploymentConfig; + }; +} + +async function toEnvironment( + project: PagesDeploymentConfig, + accountId: string +): Promise { + const configObj = {} as RawEnvironment; + configObj.compatibility_date = + project.compatibility_date ?? new Date().toISOString().substring(0, 10); + if (project.compatibility_flags?.length) + configObj.compatibility_flags = project.compatibility_flags; + for (const [name, envVar] of Object.entries(project.env_vars ?? {}).filter( ([_, val]) => val && val?.type == "plain_text" )) { @@ -35,18 +79,24 @@ export function mapBindings( configObj.kv_namespaces.push({ id: namespace.namespace_id, binding: name }); } - // TODO: support Durable Objects - // for (const [name, namespace] of Object.entries( - // project.durable_object_namespaces ?? {} - // )) { - // configObj.durable_objects ??= { bindings: [] }; - // configObj.durable_objects.bindings.push({ - // name: name, - // class_name: binding.class_name, - // script_name: binding.script_name, - // environment: binding.environment, - // }); - // } + for (const [name, { namespace_id }] of Object.entries( + project.durable_object_namespaces ?? {} + )) { + const namespace = await fetchResult<{ + script: string; + class: string; + environment?: string; + }>( + `/accounts/${accountId}/workers/durable_objects/namespaces/${namespace_id}` + ); + configObj.durable_objects ??= { bindings: [] }; + configObj.durable_objects.bindings.push({ + name: name, + class_name: namespace.class, + script_name: namespace.script, + environment: namespace.environment, + }); + } for (const [name, namespace] of Object.entries(project.d1_databases ?? {})) { configObj.d1_databases ??= []; @@ -64,9 +114,8 @@ export function mapBindings( binding: name, }); } - // @ts-expect-error Types are wrong + for (const [name, { service, environment }] of Object.entries( - // @ts-expect-error Types are wrong project.services ?? {} )) { configObj.services ??= []; @@ -77,22 +126,15 @@ export function mapBindings( }); } - for (const [name, queue] of Object.entries( - // @ts-expect-error Types are wrong - project.queue_producers ?? {} - )) { + for (const [name, queue] of Object.entries(project.queue_producers ?? {})) { configObj.queues ??= { producers: [] }; - // @ts-expect-error TS is silly - configObj.queues.producers.push({ + configObj.queues?.producers?.push({ binding: name, - // @ts-expect-error Types are wrong queue: queue.name, }); } - // @ts-expect-error Types are wrong for (const [name, { dataset }] of Object.entries( - // @ts-expect-error Types are wrong project.analytics_engine_datasets ?? {} )) { configObj.analytics_engine_datasets ??= []; @@ -101,15 +143,12 @@ export function mapBindings( dataset, }); } - for (const [name] of Object.entries( - // @ts-expect-error Types are wrong - project.ai_bindings ?? {} - )) { + for (const [name] of Object.entries(project.ai_bindings ?? {})) { configObj.ai = { binding: name }; } return configObj; } -async function writeWranglerToml(toml: RawConfig) { +async function writeWranglerToml(toml: RawEnvironment) { const wranglerCommandUsed = ["wrangler", ...process.argv.slice(2)].join(" "); // Pages does not support custom wrangler.toml locations, so always write to ./wrangler.toml @@ -124,22 +163,19 @@ async function writeWranglerToml(toml: RawConfig) { } async function downloadProject(accountId: string, projectName: string) { - const project = await fetchResult( + const project = await fetchResult( `/accounts/${accountId}/pages/projects/${projectName}` ); return { name: project.name, pages_build_output_dir: project.build_config.destination_dir, - compatibility_date: - project.deployment_configs.production.compatibility_date ?? - new Date().toISOString().substring(0, 10), - compatibility_flags: - project.deployment_configs.production.compatibility_flags, - ...mapBindings(project.deployment_configs.production), + ...(await toEnvironment(project.deployment_configs.preview, accountId)), env: { - production: mapBindings(project.deployment_configs.production), - preview: mapBindings(project.deployment_configs.preview), + production: await toEnvironment( + project.deployment_configs.production, + accountId + ), }, }; } From 5a091461c4bc7d75999d0e2e0c97388a297191c4 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 22 Mar 2024 02:27:59 +0000 Subject: [PATCH 06/11] fix tests --- .../__tests__/pages/pages-build-env.test.ts | 10 +- .../pages/pages-download-config.test.ts | 450 ++++++++++++++++++ .../src/__tests__/pages/pages.test.ts | 1 + packages/wrangler/src/pages/build-env.ts | 14 +- ...download-config.tsx => download-config.ts} | 41 +- 5 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 packages/wrangler/src/__tests__/pages/pages-download-config.test.ts rename packages/wrangler/src/pages/{download-config.tsx => download-config.ts} (87%) diff --git a/packages/wrangler/src/__tests__/pages/pages-build-env.test.ts b/packages/wrangler/src/__tests__/pages/pages-build-env.test.ts index c94f02963147..8a2a9bd03cf3 100644 --- a/packages/wrangler/src/__tests__/pages/pages-build-env.test.ts +++ b/packages/wrangler/src/__tests__/pages/pages-build-env.test.ts @@ -18,7 +18,7 @@ describe("pages-build-env", () => { pages_build_output_dir: "./dist", vars: {}, }); - await runWrangler("pages functions build-env"); + await runWrangler("pages functions build-env ."); expect(std).toMatchInlineSnapshot(` Object { "debug": "", @@ -32,7 +32,7 @@ describe("pages-build-env", () => { it("should fail with no config file", async () => { await expect( - runWrangler("pages functions build-env") + runWrangler("pages functions build-env .") ).rejects.toThrowErrorMatchingInlineSnapshot( `"No Pages config file found"` ); @@ -65,7 +65,7 @@ describe("pages-build-env", () => { }, }, }); - await runWrangler("pages functions build-env"); + await runWrangler("pages functions build-env ."); expect(std).toMatchInlineSnapshot(` Object { "debug": "", @@ -104,7 +104,7 @@ describe("pages-build-env", () => { }, }, }); - await runWrangler("pages functions build-env"); + await runWrangler("pages functions build-env ."); expect(std).toMatchInlineSnapshot(` Object { "debug": "", @@ -144,7 +144,7 @@ describe("pages-build-env", () => { }, }, }); - await runWrangler("pages functions build-env"); + await runWrangler("pages functions build-env ."); expect(std).toMatchInlineSnapshot(` Object { "debug": "", diff --git a/packages/wrangler/src/__tests__/pages/pages-download-config.test.ts b/packages/wrangler/src/__tests__/pages/pages-download-config.test.ts new file mode 100644 index 000000000000..2e0d6d157ed0 --- /dev/null +++ b/packages/wrangler/src/__tests__/pages/pages-download-config.test.ts @@ -0,0 +1,450 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ +import { randomUUID } from "crypto"; +import { readFile } from "fs/promises"; +import { rest } from "msw"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { msw } from "../helpers/msw"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; + +function mockSupportingDashRequests( + expectedAccountId: string, + expectedProjectName: string +) { + msw.use( + rest.get( + `*/accounts/:accountId/pages/projects/NOT_REAL`, + (req, res, ctx) => { + expect(req.params.accountId).toEqual(expectedAccountId); + + return res.once( + ctx.status(404), + ctx.json({ + success: false, + errors: [ + { + code: 8000007, + message: + "Project not found. The specified project name does not match any of your existing projects.", + }, + ], + result: null, + }) + ); + } + ), + rest.get( + `*/accounts/:accountId/pages/projects/:projectName`, + (req, res, ctx) => { + expect(req.params.accountId).toEqual(expectedAccountId); + expect(req.params.projectName).toEqual(expectedProjectName); + + return res.once( + ctx.status(200), + ctx.json({ + success: true, + errors: [], + result: { + id: randomUUID(), + name: expectedProjectName, + subdomain: `${expectedProjectName}.pages.dev`, + domains: [`${expectedProjectName}.pages.dev`], + source: { + type: "github", + config: { + owner: "workers-devprod", + repo_name: expectedProjectName, + production_branch: "main", + pr_comments_enabled: true, + deployments_enabled: true, + production_deployments_enabled: true, + preview_deployment_setting: "all", + preview_branch_includes: ["*"], + preview_branch_excludes: [], + path_includes: ["*"], + path_excludes: [], + }, + }, + build_config: { + build_command: 'node -e "console.log(process.env)"', + destination_dir: "dist-test", + root_dir: "", + web_analytics_tag: null, + web_analytics_token: null, + }, + deployment_configs: { + preview: { + env_vars: { + TEST_JSON_PREVIEW: { + type: "plain_text", + value: '{\njson: "value"\n}', + }, + TEST_PLAINTEXT_PREVIEW: { + type: "plain_text", + value: "PLAINTEXT", + }, + TEST_SECRET_PREVIEW: { + type: "secret_text", + value: "", + }, + TEST_SECRET_2_PREVIEW: { + type: "secret_text", + value: "", + }, + }, + kv_namespaces: { + KV_PREVIEW: { + namespace_id: "kv-id", + }, + KV_PREVIEW2: { + namespace_id: "kv-id", + }, + }, + durable_object_namespaces: { + DO_PREVIEW: { + namespace_id: "do-id", + }, + DO_PREVIEW2: { + namespace_id: "do-id", + }, + DO_PREVIEW3: { + class_name: "do-class", + service: "do-s", + environment: "do-e", + }, + }, + d1_databases: { + D1_PREVIEW: { + id: "d1-id", + }, + D1_PREVIEW2: { + id: "d1-id", + }, + }, + r2_buckets: { + R2_PREVIEW: { + name: "r2-name", + }, + R2_PREVIEW2: { + name: "r2-name", + }, + }, + services: { + SERVICE_PREVIEW: { + service: "service", + environment: "production", + }, + SERVICE_PREVIEW2: { + service: "service", + environment: "production", + }, + }, + queue_producers: { + QUEUE_PREVIEW: { + name: "q-id", + }, + QUEUE_PREVIEW2: { + name: "q-id", + }, + }, + analytics_engine_datasets: { + AE_PREVIEW: { + dataset: "data", + }, + AE_PREVIEW2: { + dataset: "data", + }, + }, + ai_bindings: { + AI_PREVIEW: {}, + }, + fail_open: true, + always_use_latest_compatibility_date: false, + compatibility_date: "2023-02-14", + compatibility_flags: [], + build_image_major_version: 2, + usage_model: "standard", + limits: { + cpu_ms: 500, + }, + placement: { + mode: "normal", + }, + }, + production: { + env_vars: { + TEST_JSON: { + type: "plain_text", + value: '{\njson: "value"\n}', + }, + TEST_PLAINTEXT: { + type: "plain_text", + value: "PLAINTEXT", + }, + TEST_SECRET: { + type: "secret_text", + value: "", + }, + TEST_SECRET_2: { + type: "secret_text", + value: "", + }, + }, + kv_namespaces: { + KV: { + namespace_id: "kv-id", + }, + }, + durable_object_namespaces: { + DO: { + namespace_id: "do-id", + }, + }, + d1_databases: { + D1: { + id: "d1-id", + }, + }, + r2_buckets: { + R2: { + name: "r2-name", + }, + }, + services: { + SERVICE: { + service: "service", + environment: "production", + }, + }, + queue_producers: { + QUEUE: { + name: "q-id", + }, + }, + analytics_engine_datasets: { + AE: { + dataset: "data", + }, + }, + ai_bindings: { + AI: {}, + }, + fail_open: true, + always_use_latest_compatibility_date: false, + compatibility_date: "2024-02-14", + compatibility_flags: [], + build_image_major_version: 2, + usage_model: "standard", + limits: { + cpu_ms: 50, + }, + placement: { + mode: "smart", + }, + }, + }, + }, + }) + ); + } + ), + rest.get( + `*/accounts/:accountId/workers/durable_objects/namespaces/:doId`, + (req, res, ctx) => { + expect(req.params.accountId).toEqual(expectedAccountId); + + return res( + ctx.status(200), + ctx.json({ + success: true, + errors: [], + result: { + script: `some-script-${req.params.doId}`, + class: `some-class-${req.params.doId}`, + environment: `some-environment-${req.params.doId}`, + }, + }) + ); + } + ) + ); +} +describe("pages-download-config", () => { + const std = mockConsoleMethods(); + runInTempDir(); + + mockApiToken(); + const MOCK_ACCOUNT_ID = "MOCK_ACCOUNT_ID"; + const MOCK_PROJECT_NAME = "MOCK_PROJECT_NAME"; + mockAccountId({ accountId: MOCK_ACCOUNT_ID }); + + beforeEach(() => { + mockSupportingDashRequests(MOCK_ACCOUNT_ID, MOCK_PROJECT_NAME); + }); + + it("should download full config correctly", async () => { + await runWrangler(`pages download config ${MOCK_PROJECT_NAME}`); + + await expect( + // Drop the Wrangler generation header + (await readFile("wrangler.toml", "utf8")).split("\n").slice(2).join("\n") + ).toMatchInlineSnapshot(` + "name = \\"MOCK_PROJECT_NAME\\" + pages_build_output_dir = \\"dist-test\\" + compatibility_date = \\"2023-02-14\\" + + [vars] + TEST_JSON_PREVIEW = \\"\\"\\" + { + json: \\"value\\" + }\\"\\"\\" + TEST_PLAINTEXT_PREVIEW = \\"PLAINTEXT\\" + + [[kv_namespaces]] + id = \\"kv-id\\" + binding = \\"KV_PREVIEW\\" + + [[kv_namespaces]] + id = \\"kv-id\\" + binding = \\"KV_PREVIEW2\\" + + [[durable_objects.bindings]] + name = \\"DO_PREVIEW\\" + class_name = \\"some-class-do-id\\" + script_name = \\"some-script-do-id\\" + environment = \\"some-environment-do-id\\" + + [[durable_objects.bindings]] + name = \\"DO_PREVIEW2\\" + class_name = \\"some-class-do-id\\" + script_name = \\"some-script-do-id\\" + environment = \\"some-environment-do-id\\" + + [[durable_objects.bindings]] + name = \\"DO_PREVIEW3\\" + class_name = \\"do-class\\" + script_name = \\"do-s\\" + environment = \\"do-e\\" + + [[d1_databases]] + database_id = \\"d1-id\\" + binding = \\"D1_PREVIEW\\" + database_name = \\"D1_PREVIEW\\" + + [[d1_databases]] + database_id = \\"d1-id\\" + binding = \\"D1_PREVIEW2\\" + database_name = \\"D1_PREVIEW2\\" + + [[r2_buckets]] + bucket_name = \\"r2-name\\" + binding = \\"R2_PREVIEW\\" + + [[r2_buckets]] + bucket_name = \\"r2-name\\" + binding = \\"R2_PREVIEW2\\" + + [[services]] + binding = \\"SERVICE_PREVIEW\\" + service = \\"service\\" + environment = \\"production\\" + + [[services]] + binding = \\"SERVICE_PREVIEW2\\" + service = \\"service\\" + environment = \\"production\\" + + [[queues.producers]] + binding = \\"QUEUE_PREVIEW\\" + queue = \\"q-id\\" + + [[queues.producers]] + binding = \\"QUEUE_PREVIEW2\\" + queue = \\"q-id\\" + + [[analytics_engine_datasets]] + binding = \\"AE_PREVIEW\\" + dataset = \\"data\\" + + [[analytics_engine_datasets]] + binding = \\"AE_PREVIEW2\\" + dataset = \\"data\\" + + [ai] + binding = \\"AI_PREVIEW\\" + + [env.production] + compatibility_date = \\"2024-02-14\\" + + [env.production.vars] + TEST_JSON = \\"\\"\\" + { + json: \\"value\\" + }\\"\\"\\" + TEST_PLAINTEXT = \\"PLAINTEXT\\" + + [[env.production.kv_namespaces]] + id = \\"kv-id\\" + binding = \\"KV\\" + + [[env.production.durable_objects.bindings]] + name = \\"DO\\" + class_name = \\"some-class-do-id\\" + script_name = \\"some-script-do-id\\" + environment = \\"some-environment-do-id\\" + + [[env.production.d1_databases]] + database_id = \\"d1-id\\" + binding = \\"D1\\" + database_name = \\"D1\\" + + [[env.production.r2_buckets]] + bucket_name = \\"r2-name\\" + binding = \\"R2\\" + + [[env.production.services]] + binding = \\"SERVICE\\" + service = \\"service\\" + environment = \\"production\\" + + [[env.production.queues.producers]] + binding = \\"QUEUE\\" + queue = \\"q-id\\" + + [[env.production.analytics_engine_datasets]] + binding = \\"AE\\" + dataset = \\"data\\" + + [env.production.ai] + binding = \\"AI\\" + " + `); + }); + it("should fail if not given a project name", async () => { + await expect( + runWrangler(`pages download config`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Must specify a project name."` + ); + }); + it("should fail if project does not exist", async () => { + await expect( + runWrangler(`pages download config NOT_REAL`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"A request to the Cloudflare API (/accounts/MOCK_ACCOUNT_ID/pages/projects/NOT_REAL) failed."` + ); + expect(std.out).toMatchInlineSnapshot(` + " + X [ERROR] A request to the Cloudflare API (/accounts/MOCK_ACCOUNT_ID/pages/projects/NOT_REAL) failed. + + Project not found. The specified project name does not match any of your existing projects. [code: + 8000007] + + If you think this is a bug, please open an issue at: + https://github.com/cloudflare/workers-sdk/issues/new/choose + + " + `); + }); +}); diff --git a/packages/wrangler/src/__tests__/pages/pages.test.ts b/packages/wrangler/src/__tests__/pages/pages.test.ts index d8e0f22e6f92..60062e9ff333 100644 --- a/packages/wrangler/src/__tests__/pages/pages.test.ts +++ b/packages/wrangler/src/__tests__/pages/pages.test.ts @@ -24,6 +24,7 @@ describe("pages", () => { Commands: wrangler pages dev [directory] [-- command..] 🧑‍💻 Develop your full-stack Pages application locally + wrangler pages download ⚡️ Download settings from your project wrangler pages project ⚡️ Interact with your Pages projects wrangler pages deployment 🚀 Interact with the deployments of a project wrangler pages deploy [directory] 🆙 Deploy a directory of static assets as a Pages deployment [aliases: publish] diff --git a/packages/wrangler/src/pages/build-env.ts b/packages/wrangler/src/pages/build-env.ts index c9e52a6e5995..9421926791a9 100644 --- a/packages/wrangler/src/pages/build-env.ts +++ b/packages/wrangler/src/pages/build-env.ts @@ -1,3 +1,4 @@ +import { stat } from "node:fs/promises"; import path from "node:path"; import { readConfig } from "../config"; import { FatalError } from "../errors"; @@ -21,11 +22,22 @@ export const Handler = async (args: PagesBuildEnvArgs) => { if (!args.projectDir) { throw new FatalError("No Pages project location specified"); } - const config = readConfig(path.join(args.projectDir, "wrangler.toml"), { + const configPath = path.resolve(args.projectDir, "wrangler.toml"); + // Fail early if the config file doesn't exist + try { + await stat(configPath); + } catch { + throw new FatalError( + "No Pages config file found", + EXIT_CODE_NO_CONFIG_FOUND + ); + } + const config = readConfig(path.resolve(args.projectDir, "wrangler.toml"), { ...args, // eslint-disable-next-line turbo/no-undeclared-env-vars env: process.env.PAGES_ENVIRONMENT, }); + // Fail if the config file exists but isn't valid for Pages if (!config.pages_build_output_dir) { throw new FatalError( "No Pages config file found", diff --git a/packages/wrangler/src/pages/download-config.tsx b/packages/wrangler/src/pages/download-config.ts similarity index 87% rename from packages/wrangler/src/pages/download-config.tsx rename to packages/wrangler/src/pages/download-config.ts index abe3e790b708..03213f253b99 100644 --- a/packages/wrangler/src/pages/download-config.tsx +++ b/packages/wrangler/src/pages/download-config.ts @@ -43,6 +43,9 @@ interface PagesDeploymentConfig extends DeploymentConfig { string, { namespace_id: string; + class_name: string; + service: string; + environment: string; } >; ai_bindings: Record>; @@ -79,23 +82,32 @@ async function toEnvironment( configObj.kv_namespaces.push({ id: namespace.namespace_id, binding: name }); } - for (const [name, { namespace_id }] of Object.entries( + for (const [name, ns] of Object.entries( project.durable_object_namespaces ?? {} )) { - const namespace = await fetchResult<{ - script: string; - class: string; - environment?: string; - }>( - `/accounts/${accountId}/workers/durable_objects/namespaces/${namespace_id}` - ); configObj.durable_objects ??= { bindings: [] }; - configObj.durable_objects.bindings.push({ - name: name, - class_name: namespace.class, - script_name: namespace.script, - environment: namespace.environment, - }); + if (ns.class_name && ns.class_name !== "") { + configObj.durable_objects.bindings.push({ + name: name, + class_name: ns.class_name, + script_name: ns.service, + environment: ns.environment, + }); + } else { + const namespace = await fetchResult<{ + script: string; + class: string; + environment?: string; + }>( + `/accounts/${accountId}/workers/durable_objects/namespaces/${ns.namespace_id}` + ); + configObj.durable_objects.bindings.push({ + name: name, + class_name: namespace.class, + script_name: namespace.script, + environment: namespace.environment, + }); + } } for (const [name, namespace] of Object.entries(project.d1_databases ?? {})) { @@ -166,6 +178,7 @@ async function downloadProject(accountId: string, projectName: string) { const project = await fetchResult( `/accounts/${accountId}/pages/projects/${projectName}` ); + console.log(JSON.stringify(project, null, 2)); return { name: project.name, From 8ef1a866a1845e2daf39599e4cab7e611a4fe4e8 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 22 Mar 2024 03:07:27 +0000 Subject: [PATCH 07/11] Add no-op build metadata arg --- packages/wrangler/src/pages/build.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/wrangler/src/pages/build.ts b/packages/wrangler/src/pages/build.ts index ac22ee691e15..f691a48cd7b6 100644 --- a/packages/wrangler/src/pages/build.ts +++ b/packages/wrangler/src/pages/build.ts @@ -45,6 +45,10 @@ export function Options(yargs: CommonYargsArgv) { type: "string", description: "The location for the output config file", }, + "build-metadata-path": { + type: "string", + description: "The location for the build metadata file", + }, "output-routes-path": { type: "string", description: "The location for the output _routes.json file", From 6bbb96cafa6cc741a30cee89726092ffec0da5f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Fri, 22 Mar 2024 03:08:44 +0000 Subject: [PATCH 08/11] Create little-baboons-reflect.md --- .changeset/little-baboons-reflect.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/little-baboons-reflect.md diff --git a/.changeset/little-baboons-reflect.md b/.changeset/little-baboons-reflect.md new file mode 100644 index 000000000000..cfa902a68a61 --- /dev/null +++ b/.changeset/little-baboons-reflect.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +feat: Add `wrangler pages download config` From 2561cd1b3c8938878feddc4358c11e6435a6597e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Fri, 22 Mar 2024 03:50:09 +0000 Subject: [PATCH 09/11] Create fresh-comics-carry.md --- .changeset/fresh-comics-carry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-comics-carry.md diff --git a/.changeset/fresh-comics-carry.md b/.changeset/fresh-comics-carry.md new file mode 100644 index 000000000000..76030e7abf66 --- /dev/null +++ b/.changeset/fresh-comics-carry.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +fix: Use specific error code to signal a wrangler.toml file not being found in build-env From 3e27b06ea5896c9a9d02015f518bcd07e2ffe480 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 22 Mar 2024 15:05:47 +0000 Subject: [PATCH 10/11] Address review comments --- .../pages/pages-download-config.test.ts | 2 +- packages/wrangler/src/metrics/send-event.ts | 2 +- packages/wrangler/src/pages/build-env.ts | 20 ++++++++++--------- .../wrangler/src/pages/download-config.ts | 13 ++++-------- packages/wrangler/src/pages/errors.ts | 1 + 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/wrangler/src/__tests__/pages/pages-download-config.test.ts b/packages/wrangler/src/__tests__/pages/pages-download-config.test.ts index 2e0d6d157ed0..1eab045c4af2 100644 --- a/packages/wrangler/src/__tests__/pages/pages-download-config.test.ts +++ b/packages/wrangler/src/__tests__/pages/pages-download-config.test.ts @@ -288,7 +288,7 @@ describe("pages-download-config", () => { await expect( // Drop the Wrangler generation header - (await readFile("wrangler.toml", "utf8")).split("\n").slice(2).join("\n") + (await readFile("wrangler.toml", "utf8")).split("\n").slice(1).join("\n") ).toMatchInlineSnapshot(` "name = \\"MOCK_PROJECT_NAME\\" pages_build_output_dir = \\"dist-test\\" diff --git a/packages/wrangler/src/metrics/send-event.ts b/packages/wrangler/src/metrics/send-event.ts index 867879ffc727..861579f97c50 100644 --- a/packages/wrangler/src/metrics/send-event.ts +++ b/packages/wrangler/src/metrics/send-event.ts @@ -72,7 +72,7 @@ export type EventNames = | "view versioned deployment" | "view latest versioned deployment" | "list versioned deployments" - | "pages download config"; + | "download pages config"; /** * Send a metrics event, with no extra properties, to Cloudflare, if usage tracking is enabled. diff --git a/packages/wrangler/src/pages/build-env.ts b/packages/wrangler/src/pages/build-env.ts index 9421926791a9..754d1d9a7042 100644 --- a/packages/wrangler/src/pages/build-env.ts +++ b/packages/wrangler/src/pages/build-env.ts @@ -1,9 +1,13 @@ -import { stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; import path from "node:path"; import { readConfig } from "../config"; +import { isPagesConfig } from "../config/validation"; import { FatalError } from "../errors"; import { logger } from "../logger"; -import { EXIT_CODE_NO_CONFIG_FOUND } from "./errors"; +import { + EXIT_CODE_INVALID_PAGES_CONFIG, + EXIT_CODE_NO_CONFIG_FOUND, +} from "./errors"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -24,24 +28,22 @@ export const Handler = async (args: PagesBuildEnvArgs) => { } const configPath = path.resolve(args.projectDir, "wrangler.toml"); // Fail early if the config file doesn't exist - try { - await stat(configPath); - } catch { + if (!existsSync(configPath)) { throw new FatalError( "No Pages config file found", EXIT_CODE_NO_CONFIG_FOUND ); } + const config = readConfig(path.resolve(args.projectDir, "wrangler.toml"), { ...args, // eslint-disable-next-line turbo/no-undeclared-env-vars env: process.env.PAGES_ENVIRONMENT, }); - // Fail if the config file exists but isn't valid for Pages - if (!config.pages_build_output_dir) { + if (!isPagesConfig(config)) { throw new FatalError( - "No Pages config file found", - EXIT_CODE_NO_CONFIG_FOUND + "Your wrangler.toml is not a valid Pages config file", + EXIT_CODE_INVALID_PAGES_CONFIG ); } diff --git a/packages/wrangler/src/pages/download-config.ts b/packages/wrangler/src/pages/download-config.ts index 03213f253b99..51f880b41919 100644 --- a/packages/wrangler/src/pages/download-config.ts +++ b/packages/wrangler/src/pages/download-config.ts @@ -68,10 +68,8 @@ async function toEnvironment( if (project.compatibility_flags?.length) configObj.compatibility_flags = project.compatibility_flags; - for (const [name, envVar] of Object.entries(project.env_vars ?? {}).filter( - ([_, val]) => val && val?.type == "plain_text" - )) { - if (envVar?.value) { + for (const [name, envVar] of Object.entries(project.env_vars ?? {})) { + if (envVar?.value && envVar?.type == "plain_text") { configObj.vars ??= {}; configObj.vars[name] = envVar?.value; } @@ -161,14 +159,11 @@ async function toEnvironment( return configObj; } async function writeWranglerToml(toml: RawEnvironment) { - const wranglerCommandUsed = ["wrangler", ...process.argv.slice(2)].join(" "); - // Pages does not support custom wrangler.toml locations, so always write to ./wrangler.toml await writeFile( "wrangler.toml", [ `# Generated by Wrangler on ${new Date()}`, - `# by running \`${wranglerCommandUsed}\``, TOML.stringify(toml as TOML.JsonMap), ].join("\n") ); @@ -178,7 +173,7 @@ async function downloadProject(accountId: string, projectName: string) { const project = await fetchResult( `/accounts/${accountId}/pages/projects/${projectName}` ); - console.log(JSON.stringify(project, null, 2)); + logger.debug(JSON.stringify(project, null, 2)); return { name: project.name, @@ -203,7 +198,7 @@ export function Options(yargs: CommonYargsArgv) { } export const Handler = async ({ projectName }: DownloadConfigArgs) => { - void metrics.sendMetricsEvent("pages download config"); + void metrics.sendMetricsEvent("download pages config"); await printWranglerBanner(); const projectConfig = getConfigCache( diff --git a/packages/wrangler/src/pages/errors.ts b/packages/wrangler/src/pages/errors.ts index d6a187683017..e179e2f226fe 100644 --- a/packages/wrangler/src/pages/errors.ts +++ b/packages/wrangler/src/pages/errors.ts @@ -21,6 +21,7 @@ export const EXIT_CODE_FUNCTIONS_NO_ROUTES_ERROR = 156; export const EXIT_CODE_FUNCTIONS_NOTHING_TO_BUILD_ERROR = 157; export const EXIT_CODE_NO_CONFIG_FOUND = 158; +export const EXIT_CODE_INVALID_PAGES_CONFIG = 159; /** * Pages error when building a script from the functions directory fails From b5deb07b5d607fbfb5684a26b46f1018a1c2c5a2 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 22 Mar 2024 15:07:47 +0000 Subject: [PATCH 11/11] Move download command --- .../wrangler/src/__tests__/pages/pages.test.ts | 2 +- packages/wrangler/src/pages/index.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/wrangler/src/__tests__/pages/pages.test.ts b/packages/wrangler/src/__tests__/pages/pages.test.ts index 60062e9ff333..26eb06b99b47 100644 --- a/packages/wrangler/src/__tests__/pages/pages.test.ts +++ b/packages/wrangler/src/__tests__/pages/pages.test.ts @@ -24,10 +24,10 @@ describe("pages", () => { Commands: wrangler pages dev [directory] [-- command..] 🧑‍💻 Develop your full-stack Pages application locally - wrangler pages download ⚡️ Download settings from your project wrangler pages project ⚡️ Interact with your Pages projects wrangler pages deployment 🚀 Interact with the deployments of a project wrangler pages deploy [directory] 🆙 Deploy a directory of static assets as a Pages deployment [aliases: publish] + wrangler pages download ⚡️ Download settings from your project Flags: -j, --experimental-json-config Experimental: Support wrangler.json [boolean] diff --git a/packages/wrangler/src/pages/index.ts b/packages/wrangler/src/pages/index.ts index 8a4a26ca1009..af8ad5fc2e9a 100644 --- a/packages/wrangler/src/pages/index.ts +++ b/packages/wrangler/src/pages/index.ts @@ -57,14 +57,6 @@ export function pages(yargs: CommonYargsArgv) { Functions.OptimizeRoutesHandler ) ) - .command("download", "⚡️ Download settings from your project", (args) => - args.command( - "config [projectName]", - "Experimental: Download your Pages project config as a wrangler.toml file", - DownloadConfig.Options, - DownloadConfig.Handler - ) - ) .command("project", "⚡️ Interact with your Pages projects", (args) => args .command( @@ -124,5 +116,13 @@ export function pages(yargs: CommonYargsArgv) { Deploy.Options, Deploy.Handler ) + .command("download", "⚡️ Download settings from your project", (args) => + args.command( + "config [projectName]", + "Experimental: Download your Pages project config as a wrangler.toml file", + DownloadConfig.Options, + DownloadConfig.Handler + ) + ) ); }