diff --git a/src/cmd/lib/remix.ts b/src/cmd/lib/remix.ts index 66aeb210..fd1e86be 100644 --- a/src/cmd/lib/remix.ts +++ b/src/cmd/lib/remix.ts @@ -1,20 +1,18 @@ import { Command } from "@cliffy/command"; -import { join } from "@std/path"; -import VTClient from "~/vt/vt/VTClient.ts"; -import { getCurrentUser, valExists } from "~/sdk.ts"; -import { APIError } from "@valtown/sdk"; +import ValTown from "@valtown/sdk"; import { doWithSpinner } from "~/cmd/utils.ts"; -import { parseValUrl } from "~/cmd/parsing.ts"; +import { parseValUri } from "~/cmd/lib/utils/parsing.ts"; +import sdk, { getCurrentUser, valExists } from "~/sdk.ts"; import { randomIntegerBetween } from "@std/random"; -import { ensureAddEditorFiles } from "~/cmd/lib/utils/messages.ts"; -import { Confirm } from "@cliffy/prompt"; -import { DEFAULT_EDITOR_TEMPLATE } from "~/consts.ts"; +import { join } from "@std/path"; +import VTClient from "~/vt/vt/VTClient.ts"; +import { findVtRoot } from "~/vt/vt/utils.ts"; export const remixCmd = new Command() .name("remix") .description("Remix a Val") .arguments( - " [newValName:string] [targetDir:string]", + "[fromValUri:string] [newValName:string] [targetDir:string]", ) .option("--public", "Remix as public Val (default)", { conflicts: ["private", "unlisted"], @@ -30,13 +28,20 @@ export const remixCmd = new Command() .example( "Bootstrap a website", ` - vt remix std/reactHonoStarter myNewWebsite - cd ./myNewWebsite - vt browse - vt watch # syncs changes to Val town`, +vt remix std/reactHonoStarter myNewWebsite +cd ./myNewWebsite +vt browse +vt watch # syncs changes to val town`, + ) + .example( + "Remix current Val", + ` + vt remix + # Creates a remix of the current Val`, ) .action(async ( { + public: _public, private: isPrivate, unlisted, description, @@ -48,79 +53,135 @@ export const remixCmd = new Command() description?: string; editorFiles?: boolean; }, - fromValUri: string, + fromValUri?: string, newValName?: string, targetDir?: string, ) => { await doWithSpinner("Remixing Val...", async (spinner) => { - const user = await getCurrentUser(); + try { + const user = await getCurrentUser(); - const { - ownerName: sourceValUsername, - valName: sourceValName, - } = parseValUrl(fromValUri, user.username!); + const privacy = isPrivate + ? "private" + : unlisted + ? "unlisted" + : "public"; - // Determine Val name based on input or generate one if needed - let valName: string; - if (newValName) { - // Use explicitly provided name - valName = newValName; - } else if ( - !await valExists({ - valName: sourceValName, - username: user.username!, - }) - ) { - // Use source Val name if it doesn't already exist - valName = sourceValName; - } else { - // Generate a unique name with random suffix - valName = `${sourceValName}_remix_${ - randomIntegerBetween(10000, 99999) - }`; - } + if (fromValUri) { + const { + ownerName: sourceValUsername, + valName: sourceValName, + } = parseValUri(fromValUri, user.username!); - // Determine the target directory - let rootPath: string; - if (targetDir) { - // Use explicitly provided target directory - rootPath = join(Deno.cwd(), targetDir, valName); - } else { - // Default to current directory + Val name - rootPath = join(Deno.cwd(), valName); - } + const finalValName = newValName ?? + await generateUniqueProjectName(sourceValName); - // Determine privacy setting (defaults to public) - const privacy = isPrivate ? "private" : unlisted ? "unlisted" : "public"; + await remixSpecificProject({ + sourceValUsername, + sourceValName, + newValName: finalValName, + targetDir, + privacy, + description, + editorFiles, + }); - try { - // Use the remix function with updated signature - const vt = await VTClient.remix({ - rootPath, - srcValUsername: sourceValUsername, - srcValName: sourceValName, - dstValName: valName, - dstValPrivacy: privacy, - description, - }); + spinner.succeed( + `Remixed "@${sourceValUsername}/${sourceValName}" to ${privacy} Val "@${user.username}/${finalValName}"`, + ); + } else { + const newProjectName = await remixCurrentDirectory({ + privacy, + description, + editorFiles, + user, + }); - if (editorFiles) { - spinner.stop(); - const { editorTemplate } = await vt.getConfig().loadConfig(); - const confirmed = await Confirm.prompt( - ensureAddEditorFiles(editorTemplate ?? DEFAULT_EDITOR_TEMPLATE), + spinner.succeed( + `Remixed current Val to ${privacy} Val "@${user.username}/${newProjectName}"`, ); - if (confirmed) await vt.addEditorTemplate(); - console.log(); } - - spinner.succeed( - `Remixed "@${sourceValUsername}/${sourceValName}" to ${privacy} Val "@${user.username}/${valName}"`, - ); - } catch (error) { - if (error instanceof APIError && error.status === 409) { - throw new Error(`Val "${valName}" already exists`); - } else throw error; + } catch (e) { + if (e instanceof ValTown.APIError && e.status === 409) { + throw new Error(`Val name "${newValName}" already exists`); + } else throw e; } }); }); + +async function generateUniqueProjectName(baseName: string): Promise { + const user = await getCurrentUser(); + if ( + !await valExists({ + valName: baseName, + username: user.username!, + }) + ) { + return baseName; + } + + return `${baseName}_remix_${randomIntegerBetween(10000, 99999)}`; +} + +async function remixSpecificProject({ + sourceValUsername, + sourceValName, + newValName, + targetDir, + privacy, + description, + editorFiles, +}: { + sourceValUsername: string; + sourceValName: string; + newValName: string; + targetDir?: string; + privacy: "public" | "private" | "unlisted"; + description?: string; + editorFiles: boolean; +}) { + const rootPath = targetDir + ? join(Deno.cwd(), targetDir, newValName) + : join(Deno.cwd(), newValName); + + const vt = await VTClient.remix({ + rootPath, + srcValUsername: sourceValUsername, + srcValName: sourceValName, + dstValName: newValName, + dstValPrivacy: privacy, + description, + }); + + if (editorFiles) await vt.addEditorTemplate(); +} + +async function remixCurrentDirectory({ + user, + privacy, + description, + editorFiles, +}: { + privacy: "public" | "private" | "unlisted"; + description?: string; + editorFiles: boolean; + user: ValTown.User; +}): Promise { + const currentVt = VTClient.from(await findVtRoot(Deno.cwd())); + const vtState = await currentVt.getMeta().loadVtState(); + const valId = vtState.val.id; + const val = await sdk.vals.retrieve(valId); + + const newValName = await generateUniqueProjectName(val.name); + const newVt = await VTClient.create({ + username: user.username!, + rootPath: currentVt.rootPath, + valName: newValName, + privacy, + description, + skipSafeDirCheck: true, + }); + + if (editorFiles) await newVt.addEditorTemplate(); + return newValName; +} diff --git a/src/cmd/tests/remix_test.ts b/src/cmd/tests/remix_test.ts index 53e51e00..cffa77d0 100644 --- a/src/cmd/tests/remix_test.ts +++ b/src/cmd/tests/remix_test.ts @@ -2,14 +2,63 @@ import { doWithNewVal } from "~/vt/lib/tests/utils.ts"; import { join } from "@std/path"; import sdk, { getCurrentUser } from "~/sdk.ts"; import { runVtCommand } from "~/cmd/tests/utils.ts"; -import { assert, assertStringIncludes } from "@std/assert"; +import { assert, assertMatch, assertStringIncludes } from "@std/assert"; import { exists } from "@std/fs"; import { META_FOLDER_NAME } from "~/consts.ts"; import { doWithTempDir } from "~/vt/lib/utils/misc.ts"; Deno.test({ - name: "remix command basic functionality", - permissions: "inherit", + name: "remix command from current directory", + async fn(t) { + const user = await getCurrentUser(); + + await doWithTempDir(async (tmpDir) => { + await doWithNewVal(async ({ val }) => { + const fullPath = join(tmpDir, val.name); + + // Clone the source project to the temp dir + await runVtCommand([ + "clone", + `${user.username}/${val.name}`, + ], tmpDir); + + await t.step("remix from current directory", async () => { + const [output] = await runVtCommand(["remix"], fullPath); + + // Check that the output contains the expected pattern + assertMatch( + output, + new RegExp( + `Remixed current Val to public Val "@${user + .username!}/[\\w_]+"`, + ), + ); + + // Extract the actual remixed project name from the output + const remixPattern = new RegExp(`@${user.username}/([\\w_]+)`); + const match = output.match(remixPattern); + assert( + match && match[1], + "could not extract remixed Val name from output", + ); + + const actualRemixedProjectName = match[1]; + + // Clean up the remixed project + const { id } = await sdk.alias.username.valName.retrieve( + user.username!, + actualRemixedProjectName, + ); + await sdk.vals.delete(id); + }); + }); + }); + }, + sanitizeResources: false, +}); + +Deno.test({ + name: "remix a specific project uri", async fn(t) { const user = await getCurrentUser(); diff --git a/src/vt/vt/VTClient.ts b/src/vt/vt/VTClient.ts index 4c47cd4d..52ff5261 100644 --- a/src/vt/vt/VTClient.ts +++ b/src/vt/vt/VTClient.ts @@ -659,7 +659,9 @@ export async function assertSafeDirectory(rootPath: string) { // If the directory exists, that is only OK if it is empty if (await exists(rootPath) && !await dirIsEmpty(rootPath)) { throw new Error( - `"${relative(Deno.cwd(), rootPath)}" already exists and is not empty`, + `Directory "${ + relative(Deno.cwd(), rootPath) || "." + }" already exists and is not empty`, ); } }