diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index ce4f3af5..cd9d4314 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -19,6 +19,7 @@ import { PLACEHOLDER_D1_ID, productionVersionIds, repositoryWranglerConfigError, + resolveWranglerAssetsDirectory, runWranglerSync, shouldCaptureDeploymentTargets, } from "./wrangler-runner.mjs"; @@ -166,8 +167,9 @@ const envValue = (name) => { const isRemoteCommand = wranglerArgs.includes("deploy") || wranglerArgs.includes("--remote"); const isDeployCommand = wranglerArgs.includes("deploy"); -const isRemoteDevCommand = wranglerArgs.includes("dev") && wranglerArgs.includes("--remote"); -const isLocalDevCommand = wranglerArgs.includes("dev") && wranglerArgs.includes("--local"); +const isDevCommand = wranglerArgs.includes("dev"); +const isRemoteDevCommand = isDevCommand && wranglerArgs.includes("--remote"); +const isLocalDevCommand = isDevCommand && wranglerArgs.includes("--local"); // Any --local command rewrites .wrangler.generated.toml. Keep local-only vars // (especially auth-free access) so `d1 migrations apply --local` cannot strip // them and leave a later wrangler reload requiring login mid-session. @@ -442,6 +444,12 @@ if (isDeployCommand && Object.keys(authSecrets).length === 0 && useExistingAuthS writeWranglerNotice("info", "using the authentication Secret provisioned by Cloudflare"); } +if (isDevCommand) { + // Local dev serves the web app from Vite, so an empty assets directory is enough. + const assetsDirectory = resolveWranglerAssetsDirectory(config, dirname(configPath)); + if (assetsDirectory) mkdirSync(assetsDirectory, { recursive: true }); +} + if (isLocalDevCommand && !hasEnvFileArg) { writeFileSync(generatedLocalDevEnvPath, buildLocalDevEnvironmentFile()); finalWranglerArgs.push("--env-file", generatedLocalDevEnvPath); diff --git a/scripts/wrangler-runner.mjs b/scripts/wrangler-runner.mjs index e14a6e66..16df23fc 100644 --- a/scripts/wrangler-runner.mjs +++ b/scripts/wrangler-runner.mjs @@ -116,6 +116,13 @@ export const shouldCaptureDeploymentTargets = (env = process.env) => export const LOCAL_DEV_CREDENTIALS_ENCRYPTION_KEY = "edgeever-local-development-credentials-key-v1"; +// Wrangler refuses to start `dev` when the assets directory is missing and has no +// option to create it. Like Wrangler, resolve `[assets].directory` from the config's directory. +export const resolveWranglerAssetsDirectory = (config, configDirectory) => { + const match = config.match(/^\s*\[assets\][^[]*?^\s*directory\s*=\s*["']([^"']+)["']/m); + return match ? resolve(configDirectory, match[1]) : null; +}; + export const buildLocalDevEnvironmentFile = () => [ "# Local-only values. Remote instance secrets are intentionally excluded.", `EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY=${LOCAL_DEV_CREDENTIALS_ENCRYPTION_KEY}`, diff --git a/tests/wrangler-runner.test.ts b/tests/wrangler-runner.test.ts index 871d08fe..8f64bde2 100644 --- a/tests/wrangler-runner.test.ts +++ b/tests/wrangler-runner.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { globSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { resolve, sep } from "node:path"; import { buildLocalDevEnvironmentFile, @@ -12,6 +14,7 @@ import { normalizeD1MigrationSql, productionVersionIds, LOCAL_DEV_CREDENTIALS_ENCRYPTION_KEY, + resolveWranglerAssetsDirectory, resolveWranglerCliPath, resolveWranglerRuntimeExecutable, runWranglerSync, @@ -73,6 +76,62 @@ describe("cross-platform Wrangler runner", () => { expect(envFile).not.toContain("EDGE_EVER_AUTH_PASSWORD_HASH="); }); + test("resolves the configured assets directory so local dev can create it", () => { + const config = '[vars]\ndirectory = "wrong"\n\n[assets]\ndirectory = "public"\n'; + + expect(resolveWranglerAssetsDirectory(config, "/repo")).toBe(resolve("/repo", "public")); + expect(resolveWranglerAssetsDirectory('name = "x"\n', "/repo")).toBeNull(); + }); + + test("prepares missing dev assets before Wrangler starts and preserves existing files", () => { + const workingDirectory = mkdtempSync(resolve(tmpdir(), "edgeever-dev-assets-")); + const assetsDirectory = resolve(workingDirectory, "custom-assets", "dist"); + const configPath = resolve(workingDirectory, "custom.toml"); + const cliDirectory = resolve(workingDirectory, "node_modules", "wrangler", "bin"); + const env = Object.fromEntries( + ["PATH", "Path", "PATHEXT", "SystemRoot", "ComSpec", "TEMP", "TMP"] + .map((name) => [name, process.env[name]]) + .filter((entry): entry is [string, string] => Boolean(entry[1])), + ); + const run = (...args: string[]) => spawnSync( + "node", + [resolve(import.meta.dir, "../scripts/run-wrangler.mjs"), ...args], + { + cwd: workingDirectory, + encoding: "utf8", + env: { ...env, WRANGLER_CONFIG: configPath }, + }, + ); + + try { + mkdirSync(cliDirectory, { recursive: true }); + writeFileSync(configPath, 'name = "dev-assets-test"\n[assets]\ndirectory = "custom-assets/dist"\n'); + writeFileSync(resolve(cliDirectory, "wrangler.js"), [ + 'const { statSync } = require("node:fs");', + 'if (process.argv.includes("dev") && !statSync("custom-assets/dist").isDirectory()) process.exit(1);', + ].join("\n")); + + // Non-dev commands must not manufacture a missing build directory. + const nonDev = run("d1", "execute", "DB", "--local", "--command", "SELECT 1"); + expect(nonDev.error).toBeUndefined(); + expect(nonDev.status).toBe(0); + expect(existsSync(assetsDirectory)).toBe(false); + + const firstStart = run("dev", "--local"); + expect(firstStart.error).toBeUndefined(); + expect(firstStart.status).toBe(0); + expect(existsSync(assetsDirectory)).toBe(true); + + const existingAsset = resolve(assetsDirectory, "index.html"); + writeFileSync(existingAsset, "existing build must survive dev startup"); + const secondStart = run("dev", "--local"); + expect(secondStart.status).toBe(0); + expect(readFileSync(existingAsset, "utf8")).toBe("existing build must survive dev startup"); + } finally { + rmSync(workingDirectory, { recursive: true, force: true }); + } + }); + test("resolves an exact D1 database name from Wrangler JSON", () => { const databases = JSON.stringify([ { uuid: "11111111-1111-1111-1111-111111111111", name: "another-database" },