rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
531 lines (530 loc) • 24.5 kB
JavaScript
import debug from "debug";
import { execaCommand } from "execa";
import { existsSync, readFileSync } from "fs";
import { pathExists } from "fs-extra";
import * as fs from "fs/promises";
import { parse as parseJsonc } from "jsonc-parser";
import { setTimeout } from "node:timers/promises";
import { basename, dirname, join, resolve } from "path";
import { $ } from "../../lib/$.mjs";
import { extractLastJson, parseJson } from "../../lib/jsonUtils.mjs";
const log = debug("rwsdk:e2e:release");
/**
* Find wrangler cache by searching up the directory tree for node_modules/.cache/wrangler
*/
function findWranglerCache(startDir) {
let currentDir = resolve(startDir);
const root = resolve("/");
while (currentDir !== root) {
const cacheDir = join(currentDir, "node_modules/.cache/wrangler");
const accountCachePath = join(cacheDir, "wrangler-account.json");
if (existsSync(accountCachePath)) {
log("Found wrangler cache at: %s", accountCachePath);
return accountCachePath;
}
// Move up one directory
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// Reached filesystem root
break;
}
currentDir = parentDir;
}
log("No wrangler cache found in directory tree starting from: %s", startDir);
return null;
}
/**
* A mini expect-like utility for handling interactive CLI prompts and verifying output
* @param command The command to execute
* @param expectations Array of {expect, send} objects for interactive responses and verification
* @param options Additional options for command execution including working directory and environment
* @returns Promise that resolves when the command completes
*/
export async function $expect(command, expectations, options = {
reject: true,
}) {
return new Promise((resolve, reject) => {
// Spawn the process with pipes for interaction
const childProcess = execaCommand(command, {
cwd: options.cwd ?? process.cwd(),
stdio: "pipe",
reject: false, // Never reject so we can handle the error ourselves
env: options.env ?? process.env,
});
let stdout = "";
let stderr = "";
let buffer = "";
let lastMatchIndex = 0; // Track the index where the last match occurred
// Track patterns that have been matched
const matchHistory = new Map();
// Track current expectation index to process them in order
let currentExpectationIndex = 0;
// Initialize match count for each pattern
expectations.forEach(({ expect: expectPattern }) => {
matchHistory.set(expectPattern, 0);
});
// Collect stdout
childProcess.stdout?.on("data", (data) => {
const chunk = data.toString();
stdout += chunk;
buffer += chunk;
// Print to console
process.stdout.write(chunk);
// Only process expectations that haven't been fully matched yet
// and in the order they were provided
while (currentExpectationIndex < expectations.length) {
const { expect: expectPattern, send } = expectations[currentExpectationIndex];
const pattern = expectPattern instanceof RegExp
? expectPattern
: new RegExp(expectPattern, "m");
// Only search in the unmatched portion of the buffer
const searchBuffer = buffer.substring(lastMatchIndex);
const match = searchBuffer.match(pattern);
if (match) {
// Found a match
const patternStr = expectPattern.toString();
const matchCount = matchHistory.get(expectPattern) || 0;
// Update the lastMatchIndex to point after this match
// Calculate the absolute position in the full buffer
const matchStartPosition = lastMatchIndex + match.index;
const matchEndPosition = matchStartPosition + match[0].length;
lastMatchIndex = matchEndPosition;
if (send) {
childProcess.stdin?.write(send);
}
// Increment the match count for this pattern
matchHistory.set(expectPattern, matchCount + 1);
// Move to the next expectation
currentExpectationIndex++;
// If we've processed all expectations but need to wait for stdin response,
// delay closing stdin until the next data event
if (currentExpectationIndex >= expectations.length && send) {
childProcess.stdin?.end();
}
break; // Exit the while loop to process next chunk
}
else {
// Try to find the closest substring that might partially match
const patternString = pattern.toString();
const patternCore = patternString.substring(1, patternString.lastIndexOf("/") > 0
? patternString.lastIndexOf("/")
: patternString.length);
// Try partial matches to diagnose the issue
for (let i = 3; i < patternCore.length; i++) {
const partialPattern = patternCore.substring(0, i);
const partialRegex = new RegExp(partialPattern, "m");
const matches = partialRegex.test(searchBuffer);
// Once we find where the matching starts to fail, stop
if (!matches)
break;
}
// Break the while loop as this pattern doesn't match yet
break;
}
}
// If all expectations have been matched, we can close stdin if not already closed
if (currentExpectationIndex >= expectations.length &&
childProcess.stdin?.writable) {
childProcess.stdin.end();
}
});
// Collect stderr if needed
if (childProcess.stderr) {
childProcess.stderr.on("data", (data) => {
const chunk = data.toString();
stderr += chunk;
// Also write stderr to console
process.stderr.write(chunk);
});
}
// Handle process completion
childProcess.on("close", (code) => {
log("Process closed with code: %s", code);
// Check if any required patterns were not matched
const unmatchedPatterns = Array.from(matchHistory.entries())
.filter(([_, count]) => count === 0)
.map(([pattern, _]) => pattern.toString());
resolve({ stdout, stderr, code });
});
childProcess.on("error", (err) => {
log("Process error: %O", err);
if (options.reject) {
reject(new Error(`Failed to execute command: ${err.message}`));
}
else {
resolve({ stdout, stderr, code: null });
}
});
});
}
/**
* Ensures Cloudflare account ID is set in environment
* First checks wrangler cache, then environment variables, and finally guides the user
*/
export async function ensureCloudflareAccountId(cwd, projectDir) {
// Skip if already set
if (process.env.CLOUDFLARE_ACCOUNT_ID) {
log("CLOUDFLARE_ACCOUNT_ID is already set: %s", process.env.CLOUDFLARE_ACCOUNT_ID);
console.log(`Using existing CLOUDFLARE_ACCOUNT_ID: ${process.env.CLOUDFLARE_ACCOUNT_ID}`);
return;
}
console.log("CLOUDFLARE_ACCOUNT_ID not set, checking wrangler cache...");
try {
// Search up the directory tree for wrangler cache (supports monorepo setups)
projectDir = projectDir || cwd || process.cwd();
log("Looking for wrangler cache starting from: %s", projectDir);
const accountCachePath = findWranglerCache(projectDir);
if (accountCachePath) {
try {
const accountCache = JSON.parse(readFileSync(accountCachePath, "utf8"));
if (accountCache.account?.id) {
const accountId = accountCache.account.id;
process.env.CLOUDFLARE_ACCOUNT_ID = accountId;
log("Found CLOUDFLARE_ACCOUNT_ID in wrangler cache: %s", accountId);
console.log(`✅ Setting CLOUDFLARE_ACCOUNT_ID to ${accountId} (from wrangler cache)`);
console.log(` Cache location: ${accountCachePath}`);
return;
}
}
catch (parseError) {
log("Failed to parse wrangler account cache: %O", parseError);
// Continue to other methods if cache parsing fails
}
}
else {
console.log(`⚠️ No wrangler account cache found in directory tree starting from: ${projectDir}`);
}
// If we get here, we couldn't find the account ID in the cache
// Give clear guidance to the user
console.log("⚠️ Could not find Cloudflare account ID");
console.log("Please either:");
console.log(" 1. Run 'npx wrangler login' to authenticate with Cloudflare");
console.log(" 2. Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN environment variables");
// Try wrangler whoami as a final attempt
console.log("\nAttempting to get account info from wrangler...");
const result = await $({
cwd: projectDir,
stdio: "pipe",
}) `npx wrangler whoami`;
// First try regex pattern matching on the text output
if (result.stdout) {
const accountIdMatch = result.stdout.match(/Account ID: ([a-f0-9]{32})/);
if (accountIdMatch && accountIdMatch[1]) {
const accountId = accountIdMatch[1];
process.env.CLOUDFLARE_ACCOUNT_ID = accountId;
log("Extracted CLOUDFLARE_ACCOUNT_ID from whoami text: %s", accountId);
console.log(`✅ Setting CLOUDFLARE_ACCOUNT_ID to ${accountId} (from wrangler whoami)`);
return;
}
}
// Fallback: try to extract any JSON that might be in the output
const accountInfo = extractLastJson(result.stdout);
if (accountInfo && accountInfo.account && accountInfo.account.id) {
const accountId = accountInfo.account.id;
process.env.CLOUDFLARE_ACCOUNT_ID = accountId;
log("Extracted CLOUDFLARE_ACCOUNT_ID from whoami JSON: %s", accountId);
console.log(`✅ Setting CLOUDFLARE_ACCOUNT_ID to ${accountId} (from wrangler whoami)`);
return;
}
// If we get here, we've exhausted all options
throw new Error("Could not find Cloudflare account ID. Please login with 'npx wrangler login' or set CLOUDFLARE_ACCOUNT_ID manually.");
}
catch (error) {
log("Error during account ID detection: %O", error);
throw error;
}
}
/**
* Run the release command to deploy to Cloudflare
*/
export async function runRelease(cwd, projectDir, resourceUniqueKey) {
log("Running release command");
console.log("\n🚀 Deploying worker to Cloudflare...");
try {
// Make sure we have an account ID
await ensureCloudflareAccountId(cwd, projectDir);
// Extract worker name from directory name to ensure consistency
const dirName = cwd ? basename(cwd) : "unknown-worker";
// Ensure resource unique key is included in worker name for tracking
if (resourceUniqueKey && !dirName.includes(resourceUniqueKey)) {
log(`Worker name doesn't contain our unique key, this is unexpected: ${dirName}, key: ${resourceUniqueKey}`);
console.log(`⚠️ Worker name doesn't contain our unique key. This might cause cleanup issues.`);
}
// Ensure the worker name in wrangler.jsonc matches our unique name
if (cwd) {
try {
const wranglerPath = join(cwd, "wrangler.jsonc");
if (await pathExists(wranglerPath)) {
log("Updating wrangler.jsonc to use our unique worker name: %s", dirName);
// Read the wrangler config - handle both jsonc and json formats
const wranglerContent = await fs.readFile(wranglerPath, "utf-8");
// Use parseJsonc which handles comments and is more tolerant
let wranglerConfig;
try {
wranglerConfig = parseJsonc(wranglerContent);
}
catch (parseError) {
// Fallback to standard JSON if jsonc parsing fails
log("JSONC parsing failed, trying standard JSON: %O", parseError);
wranglerConfig = JSON.parse(wranglerContent);
}
// Update the name
if (wranglerConfig.name !== dirName) {
wranglerConfig.name = dirName;
await fs.writeFile(wranglerPath, JSON.stringify(wranglerConfig, null, 2));
log("Updated wrangler.jsonc with unique worker name: %s", dirName);
}
}
}
catch (error) {
log("Error updating wrangler.jsonc: %O", error);
console.error(`Warning: Could not update wrangler.jsonc: ${error}`);
}
}
// Run release command with our interactive $expect utility and retry logic
log("Running release command with interactive prompts and retries");
const MAX_RETRIES = 3;
let lastError = null;
let result = null;
for (let i = 0; i < MAX_RETRIES; i++) {
try {
console.log(`\n🚀 Deploying worker to Cloudflare (Attempt ${i + 1}/${MAX_RETRIES})...`);
result = await $expect("npm run release", [
{
// Make the pattern more flexible to account for potential whitespace differences
expect: /Do you want to proceed with deployment\?\s*\(y\/N\)/i,
send: "y\r",
},
], {
reject: false, // Add reject: false to prevent uncaught promise rejections
env: {
NODE_ENV: "production",
RWSDK_RENAME_WORKER: "1",
RWSDK_RENAME_DB: "1",
...process.env,
},
cwd,
});
// Check exit code to ensure command succeeded
if (result.code === 0) {
log(`Release command succeeded on attempt ${i + 1}`);
lastError = null; // Clear last error on success
break; // Exit the loop on success
}
else {
throw new Error(`Release command failed with exit code ${result.code}`);
}
}
catch (error) {
lastError = error;
log(`Attempt ${i + 1} failed: ${lastError.message}`);
if (i < MAX_RETRIES - 1) {
console.log(` Waiting 5 seconds before retrying...`);
await setTimeout(5000);
}
}
}
if (lastError || !result) {
log("ERROR: Release command failed after all retries.");
throw lastError || new Error("Release command failed after all retries.");
}
// Check exit code to ensure command succeeded
if (result.code !== 0) {
// Add more contextual information about the error
let errorMessage = `Release command failed with exit code ${result.code}`;
// Add stderr output to the error message if available
if (result.stderr && result.stderr.trim().length > 0) {
// Extract the most relevant part of the error message
const errorLines = result.stderr
.split("\n")
.filter((line) => line.includes("ERROR") ||
line.includes("error:") ||
line.includes("failed"))
.slice(0, 3) // Take just the first few error lines
.join("\n");
if (errorLines) {
errorMessage += `\nError details: ${errorLines}`;
}
}
log("ERROR: %s", errorMessage);
throw new Error(errorMessage);
}
const stdout = result.stdout;
// Extract deployment URL from output
log("Extracting deployment URL from output");
const urlMatch = stdout.match(/https:\/\/([a-zA-Z0-9-]+)\.redwoodjs\.workers\.dev/);
if (!urlMatch || !urlMatch[0]) {
log("ERROR: Could not extract deployment URL from release output");
// Log more details about the output for debugging
log("Release command stdout: %s", stdout);
if (result.stderr) {
log("Release command stderr: %s", result.stderr);
}
throw new Error("Could not extract deployment URL from release output");
}
const url = urlMatch[0];
const workerName = urlMatch[1];
log("Successfully deployed to %s (worker: %s)", url, workerName);
console.log(`✅ Successfully deployed to ${url}`);
return { url, workerName };
}
catch (error) {
log("ERROR: Failed to run release command: %O", error);
throw error;
}
}
/**
* Check if a resource name includes a specific resource unique key
* This is used to identify resources created during our tests
*/
export function isRelatedToTest(resourceName, resourceUniqueKey) {
return resourceName.includes(resourceUniqueKey);
}
/**
* Delete the worker using wrangler
*/
export async function deleteWorker(workerName, projectDir, resourceUniqueKey) {
console.log(`Cleaning up: Deleting worker ${workerName}...`);
// We are extra careful here to not delete workers that are not related to
// the current test run. We check if the worker name contains the resource
// unique key, and if the project directory also contains the resource unique
// key.
if (!isRelatedToTest(workerName, resourceUniqueKey)) {
console.warn(`⚠️ Worker name "${workerName}" does not contain resource unique key "${resourceUniqueKey}". Skipping delete.`);
return;
}
if (!isRelatedToTest(projectDir, resourceUniqueKey)) {
console.warn(`⚠️ Project dir "${projectDir}" does not contain resource unique key "${resourceUniqueKey}". Skipping delete.`);
return;
}
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
if (!accountId || !apiToken) {
console.error("❌ CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN env vars must be set to delete worker");
return;
}
const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts/${workerName}`;
console.log(`Running API call: DELETE ${url}`);
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Cloudflare API request failed with status ${response.status}: ${errorText}`);
}
const responseData = (await response.json());
if (!responseData.success) {
throw new Error(`Cloudflare API returned an error: ${JSON.stringify(responseData.errors)}`);
}
console.log(`✅ Successfully deleted worker "${workerName}"`);
}
catch (error) {
console.error(`❌ Failed to delete worker "${workerName}"`);
if (error instanceof Error) {
console.error(`Error message: ${error.message}`);
}
else {
console.error("An unknown error occurred:", error);
}
}
}
/**
* List D1 databases using wrangler
*/
export async function listD1Databases(cwd) {
log("Listing D1 databases");
try {
const result = await $({
cwd,
stdio: "pipe",
}) `npx wrangler d1 list --json`;
// Parse the JSON output to extract the last valid JSON
const data = parseJson(result.stdout, []);
if (Array.isArray(data)) {
log("Found %d D1 databases in parsed array", data.length);
return data;
}
else if (data.databases && Array.isArray(data.databases)) {
log("Found %d D1 databases in 'databases' property", data.databases.length);
return data.databases;
}
// If nothing worked, return an empty array
log("Could not parse JSON from output, returning empty array");
return [];
}
catch (error) {
log("Error listing D1 databases: %O", error);
console.error(`Failed to list D1 databases: ${error}`);
return [];
}
}
/**
* Delete a D1 database using wrangler
*/
export async function deleteD1Database(name, cwd, resourceUniqueKey) {
// Check wrangler.jsonc to see if a database is configured
const wranglerConfigPath = resolve(cwd, "wrangler.jsonc");
try {
const configContent = await fs.readFile(wranglerConfigPath, "utf-8");
const config = parseJsonc(configContent);
if (!config.d1_databases || config.d1_databases.length === 0) {
log("No D1 databases configured in wrangler.jsonc, skipping deletion.");
return;
}
}
catch (error) {
log(`Could not read or parse wrangler.jsonc at ${wranglerConfigPath}, proceeding with deletion attempt anyway.`, error);
}
console.log(`Cleaning up: Deleting D1 database ${name}...`);
try {
// First check if the database exists
const databases = await listD1Databases(cwd);
const exists = databases.some((db) => db.name === name);
if (!exists) {
log(`D1 database ${name} not found, skipping deletion`);
console.log(`⚠️ D1 database ${name} not found, skipping deletion`);
return;
}
// Extra safety check: if we have a resourceUniqueKey, verify this database is related to our test
if (resourceUniqueKey && !isRelatedToTest(name, resourceUniqueKey)) {
log(`D1 database ${name} does not contain unique key ${resourceUniqueKey}, not deleting for safety`);
console.log(`⚠️ D1 database ${name} does not seem to be created by this test, skipping deletion for safety`);
return;
}
// Use our $expect utility to handle any confirmation prompts
log("Running wrangler d1 delete command with interactive prompts");
await $expect(`npx wrangler d1 delete ${name}`, [
{
expect: "Are you sure you want to delete",
send: "y\r",
},
], {
cwd,
});
console.log(`✅ D1 database ${name} deleted successfully`);
}
catch (error) {
console.error(`Failed to delete D1 database ${name}: ${error}`);
// Retry with force flag if the first attempt failed
try {
console.log("Retrying with force flag...");
await $expect(`npx wrangler d1 delete ${name} --yes --force`, [
{
expect: "Are you sure you want to delete",
send: "y\r",
},
], {
cwd,
});
console.log(`✅ D1 database ${name} force deleted successfully`);
}
catch (retryError) {
console.error(`Failed to force delete D1 database ${name}: ${retryError}`);
}
}
}