From 499a1d6392514867ad68f0369c3d9b970bfc6f8f Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Fri, 18 Apr 2025 02:41:20 -0400 Subject: [PATCH 1/8] Add default remix behavior --- src/cmd/lib/create.ts | 1 - src/cmd/lib/push.ts | 4 +- src/cmd/lib/remix.ts | 200 ++++++++++++++++++++++++++++-------------- src/vt/vt/VTClient.ts | 10 ++- 4 files changed, 143 insertions(+), 72 deletions(-) diff --git a/src/cmd/lib/create.ts b/src/cmd/lib/create.ts index 46eb1f3f..a308f2ac 100644 --- a/src/cmd/lib/create.ts +++ b/src/cmd/lib/create.ts @@ -62,7 +62,6 @@ vt checkout main`, await doWithSpinner("Creating new project...", async (spinner) => { const clonePath = getClonePath(targetDir, projectName); - // Determine privacy setting (defaults to public) const privacy = isPrivate ? "private" : unlisted ? "unlisted" : "public"; try { diff --git a/src/cmd/lib/push.ts b/src/cmd/lib/push.ts index a0e53072..3574076e 100644 --- a/src/cmd/lib/push.ts +++ b/src/cmd/lib/push.ts @@ -29,8 +29,8 @@ export const pushCmd = new Command() const projectToPush = await sdk.projects.retrieve(vtState.project.id); if (projectToPush.author.id !== user.id) { throw new Error( - "You are not the owner of this project, you cannot push." + - "\nTo make a PR, go to the website, fork the project, clone the fork, make changes, push them, and then PR on the website.", + "You are not the owner of this project, you cannot push.\n" + + "To remix this project so you can make changes, run `vt remix`.", ); } diff --git a/src/cmd/lib/remix.ts b/src/cmd/lib/remix.ts index d88aad47..afff48b2 100644 --- a/src/cmd/lib/remix.ts +++ b/src/cmd/lib/remix.ts @@ -1,17 +1,18 @@ import { Command } from "@cliffy/command"; import { join } from "@std/path"; import VTClient from "~/vt/vt/VTClient.ts"; -import { projectExists, user } from "~/sdk.ts"; +import sdk, { projectExists, user } from "~/sdk.ts"; import { APIError } from "@valtown/sdk"; import { doWithSpinner } from "~/cmd/utils.ts"; import { parseProjectUri } from "~/cmd/parsing.ts"; import { randomIntegerBetween } from "@std/random"; +import { findVtRoot } from "~/vt/vt/utils.ts"; export const remixCmd = new Command() .name("remix") .description("Remix a Val Town project") .arguments( - " [newProjectName:string] [targetDir:string]", + "[fromProjectUri:string] [newProjectName:string] [targetDir:string]", ) .option("--public", "Remix as public project (default)", { conflicts: ["private", "unlisted"], @@ -27,10 +28,16 @@ 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 project", + ` + vt remix + # Creates a remix of the current project`, ) .action(async ( { @@ -38,80 +45,141 @@ export const remixCmd = new Command() unlisted, description, editorFiles = true, + fromProjectUri, }: { + fromProjectUri?: string; public?: boolean; private?: boolean; unlisted?: boolean; description?: string; editorFiles?: boolean; }, - fromProjectUri: string, newProjectName?: string, targetDir?: string, ) => { await doWithSpinner("Remixing Val Town project...", async (spinner) => { - // Parse the project uri for the project we are remixing - const { - ownerName: sourceProjectUsername, - projectName: sourceProjectName, - } = parseProjectUri( - fromProjectUri, - user.username!, - ); - - // Determine project name based on input or generate one if needed - let projectName: string; - if (newProjectName) { - // Use explicitly provided name - projectName = newProjectName; - } else if ( - !await projectExists({ - projectName: sourceProjectName, - username: user.username!, - }) - ) { - // Use source project name if it doesn't already exist - projectName = sourceProjectName; - } else { - // Generate a unique name with random suffix - projectName = `${sourceProjectName}_remix_${ - randomIntegerBetween(10000, 99999) - }`; - } + try { + const privacy = isPrivate + ? "private" + : unlisted + ? "unlisted" + : "public"; - // Determine the target directory - let rootPath: string; - if (targetDir) { - // Use explicitly provided target directory - rootPath = join(Deno.cwd(), targetDir, projectName); - } else { - // Default to current directory + project name - rootPath = join(Deno.cwd(), projectName); - } + if (fromProjectUri) { + const { + ownerName: sourceProjectUsername, + projectName: sourceProjectName, + } = parseProjectUri(fromProjectUri, user.username!); - // Determine privacy setting (defaults to public) - const privacy = isPrivate ? "private" : unlisted ? "unlisted" : "public"; + const finalProjectName = newProjectName ?? + await generateUniqueProjectName(sourceProjectName); - try { - // Use the remix function with updated signature - const vt = await VTClient.remix({ - rootPath, - srcProjectUsername: sourceProjectUsername, - srcProjectName: sourceProjectName, - dstProjectName: projectName, - dstProjectPrivacy: privacy, - description, - }); - - if (editorFiles) await vt.addEditorFiles(); - - spinner.succeed( - `Remixed "@${sourceProjectUsername}/${sourceProjectName}" to ${privacy} project "@${user.username}/${projectName}"`, - ); - } catch (error) { - if (error instanceof APIError && error.status === 409) { - throw new Error(`Project name "${projectName}" already exists`); - } else throw error; + await remixSpecificProject({ + sourceProjectUsername, + sourceProjectName, + newProjectName: finalProjectName, + targetDir, + privacy, + description, + editorFiles, + }); + + spinner.succeed( + `Remixed "@${sourceProjectUsername}/${sourceProjectName}" to ${privacy} project "@${user.username}/${finalProjectName}"`, + ); + } else { + // If they do not provide the project uri of the project that they + // want to remix, it is assumed that they are trying to remix the + // current vt directory and that they expect that the current + // directory is already a vt directory + + const newProjectName = await remixCurrentDirectory({ + privacy, + description, + editorFiles, + }); + + spinner.succeed( + `Remixed current project to ${privacy} project "@${user.username}/${newProjectName}"`, + ); + } + } catch (e) { + if (e instanceof APIError && e.status === 409) { + throw new Error(`Project name "${newProjectName}" already exists`); + } else throw e; } }); }); + +async function generateUniqueProjectName(baseName: string): Promise { + if ( + !await projectExists({ + projectName: baseName, + username: user.username!, + }) + ) { + return baseName; + } + + return `${baseName}_remix_${randomIntegerBetween(10000, 99999)}`; +} + +async function remixSpecificProject(params: { + sourceProjectUsername: string; + sourceProjectName: string; + newProjectName: string; + targetDir?: string; + privacy: "public" | "private" | "unlisted"; + description?: string; + editorFiles: boolean; +}) { + const { + sourceProjectUsername, + sourceProjectName, + newProjectName, + targetDir, + privacy, + description, + editorFiles, + } = params; + + const rootPath = targetDir + ? join(Deno.cwd(), targetDir, newProjectName) + : join(Deno.cwd(), newProjectName); + + const vt = await VTClient.remix({ + rootPath, + srcProjectUsername: sourceProjectUsername, + srcProjectName: sourceProjectName, + dstProjectName: newProjectName, + dstProjectPrivacy: privacy, + description, + }); + + if (editorFiles) await vt.addEditorFiles(); +} + +async function remixCurrentDirectory(params: { + privacy: "public" | "private" | "unlisted"; + description?: string; + editorFiles: boolean; +}): Promise { + const { privacy, description, editorFiles } = params; + + const currentVt = VTClient.from(await findVtRoot(Deno.cwd())); + const vtState = await currentVt.getMeta().loadVtState(); + const projectId = vtState.project?.id || "project"; + const project = await sdk.projects.retrieve(projectId); + + const newProjectName = await generateUniqueProjectName(project.name); + const newVt = await VTClient.create({ + username: user.username!, + rootPath: currentVt.rootPath, + projectName: newProjectName, + privacy, + description, + }); + + if (editorFiles) await newVt.addEditorFiles(); + return newProjectName; +} diff --git a/src/vt/vt/VTClient.ts b/src/vt/vt/VTClient.ts index f65ea938..9c9c9a80 100644 --- a/src/vt/vt/VTClient.ts +++ b/src/vt/vt/VTClient.ts @@ -262,8 +262,6 @@ export default class VTClient { privacy: "public" | "private" | "unlisted"; description?: string; }): Promise { - await assertSafeDirectory(rootPath); - // First create the project const { newProjectId } = await create({ sourceDir: rootPath, @@ -281,6 +279,7 @@ export default class VTClient { username, projectName, rootPath, + assertEmtpyDir: false, }); } @@ -355,6 +354,7 @@ export default class VTClient { * @param {string} params.projectName - The name of the project to clone * @param {number} [params.version] - Optional specific version to clone, defaults to latest * @param {string} [params.branchName] - Optional branch name to clone, defaults to main + * @param {boolean} [params.assertEmtpyDir] - Whether to assert that the directory is empty * @returns {Promise} A new VTClient instance for the cloned project */ public static async clone({ @@ -363,14 +363,18 @@ export default class VTClient { projectName, version, branchName = DEFAULT_BRANCH_NAME, + assertEmtpyDir = true, }: { rootPath: string; username: string; projectName: string; version?: number; branchName?: string; + assertEmtpyDir?: boolean; }): Promise { - await assertSafeDirectory(rootPath); + if (assertEmtpyDir) { + await assertSafeDirectory(rootPath); + } const vt = await VTClient.init({ rootPath, From 35999e031a94ea41e018b803eb3d4fefe564d11a Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Fri, 18 Apr 2025 14:58:40 -0400 Subject: [PATCH 2/8] Make default remix command remix the current project --- src/cmd/lib/remix.ts | 3 +-- src/cmd/tests/remix_test.ts | 48 ++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/cmd/lib/remix.ts b/src/cmd/lib/remix.ts index afff48b2..598024f1 100644 --- a/src/cmd/lib/remix.ts +++ b/src/cmd/lib/remix.ts @@ -45,15 +45,14 @@ export const remixCmd = new Command() unlisted, description, editorFiles = true, - fromProjectUri, }: { - fromProjectUri?: string; public?: boolean; private?: boolean; unlisted?: boolean; description?: string; editorFiles?: boolean; }, + fromProjectUri?: string, newProjectName?: string, targetDir?: string, ) => { diff --git a/src/cmd/tests/remix_test.ts b/src/cmd/tests/remix_test.ts index 78f93764..15774337 100644 --- a/src/cmd/tests/remix_test.ts +++ b/src/cmd/tests/remix_test.ts @@ -8,7 +8,53 @@ import { exists } from "@std/fs"; import { META_FOLDER_NAME } from "~/consts.ts"; Deno.test({ - name: "remix command basic functionality", + name: "remix command from current directory", + async fn(t) { + await doWithTempDir(async (tmpDir) => { + await doWithNewProject(async ({ project }) => { + // Clone the source project to the temp dir + await runVtCommand([ + "clone", + `${user.username}/${project.name}`, + ], tmpDir); + + await t.step("remix from current directory", async () => { + const [output] = await runVtCommand( + ["remix"], + join(tmpDir, project.name), + ); + + // Check that the output contains the expected pattern + assertStringIncludes( + output, + `Remixed current project to public project "@${user.username}/`, + ); + + // 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 project name from output", + ); + + const actualRemixedProjectName = match[1]; + + // Clean up the remixed project + const { id } = await sdk.alias.username.projectName.retrieve( + user.username!, + actualRemixedProjectName, + ); + await sdk.projects.delete(id); + }); + }); + }); + }, + sanitizeResources: false, +}); + +Deno.test({ + name: "remix a specific project uri", async fn(t) { await doWithTempDir(async (tmpDir) => { await doWithNewProject(async ({ project }) => { From f00c820dff0661c3970348fc9055b4f453f423b9 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Tue, 22 Apr 2025 03:05:00 -0400 Subject: [PATCH 3/8] Don't run parallel on mac --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 993b8a70..278f0cdc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,7 +40,7 @@ jobs: - name: Run deno tests uses: nick-fields/retry@v3 with: - command: deno task test:cmd --parallel + command: deno task test:cmd max_attempts: 2 timeout_seconds: 2000 env: From 66e7e6ca8f195a8bfa438f97d5881f339ac34166 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Sun, 4 May 2025 15:37:21 -0400 Subject: [PATCH 4/8] Format code --- src/cmd/tests/remix_test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cmd/tests/remix_test.ts b/src/cmd/tests/remix_test.ts index 04f42cb6..cffa77d0 100644 --- a/src/cmd/tests/remix_test.ts +++ b/src/cmd/tests/remix_test.ts @@ -26,13 +26,13 @@ Deno.test({ const [output] = await runVtCommand(["remix"], fullPath); // Check that the output contains the expected pattern - assertMatch( + assertMatch( output, new RegExp( `Remixed current Val to public Val "@${user - .username!}/[\\w_]+"`, + .username!}/[\\w_]+"`, ), - ); + ); // Extract the actual remixed project name from the output const remixPattern = new RegExp(`@${user.username}/([\\w_]+)`); From 8cf509321d2bd2707a2a2c1262bcb2ac605d8f6f Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Sun, 18 Jan 2026 18:09:30 -0500 Subject: [PATCH 5/8] fix lint error --- src/cmd/lib/remix.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/lib/remix.ts b/src/cmd/lib/remix.ts index 5593c3f7..87ee5f5a 100644 --- a/src/cmd/lib/remix.ts +++ b/src/cmd/lib/remix.ts @@ -180,7 +180,7 @@ async function remixCurrentDirectory({ valName: newValName, privacy, description, - requireEmptyDir: false, + skipSafeDirCheck: true, }); if (editorFiles) await newVt.addEditorTemplate(); From 961834e928f0354aa4fd0dacf80a0ef49e0512b0 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Sun, 18 Jan 2026 18:24:14 -0500 Subject: [PATCH 6/8] add back comment --- src/cmd/lib/create.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cmd/lib/create.ts b/src/cmd/lib/create.ts index 36f2229d..df3599e7 100644 --- a/src/cmd/lib/create.ts +++ b/src/cmd/lib/create.ts @@ -76,6 +76,7 @@ vt checkout main`, const user = await getCurrentUser(); const clonePath = getClonePath(targetDir, valName); + // Determine privacy setting (defaults to public) const privacy = isPrivate ? "private" : unlisted ? "unlisted" : "public"; // If they don't specify an org, including not specifying "me," we check From 3f3c86c5183710a2072a4701b638230a4c534f24 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Mon, 26 Jan 2026 17:13:00 -0500 Subject: [PATCH 7/8] fix typo --- src/cmd/lib/remix.ts | 3 --- src/cmd/tests/checkout_test.ts | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cmd/lib/remix.ts b/src/cmd/lib/remix.ts index 87ee5f5a..10baeddd 100644 --- a/src/cmd/lib/remix.ts +++ b/src/cmd/lib/remix.ts @@ -160,7 +160,6 @@ async function remixCurrentDirectory({ privacy, description, editorFiles, - user, }: { privacy: "public" | "private" | "unlisted"; description?: string; @@ -173,9 +172,7 @@ async function remixCurrentDirectory({ const val = await sdk.vals.retrieve(valId); const newValName = await generateUniqueProjectName(val.name); - console.log("newValName", newValName); const newVt = await VTClient.create({ - username: user.username!, rootPath: currentVt.rootPath, valName: newValName, privacy, diff --git a/src/cmd/tests/checkout_test.ts b/src/cmd/tests/checkout_test.ts index f95d410d..3f831fb8 100644 --- a/src/cmd/tests/checkout_test.ts +++ b/src/cmd/tests/checkout_test.ts @@ -43,7 +43,7 @@ Deno.test({ ); }); - await t.step("clone the Val nd modify it", async () => { + await t.step("clone the Val and modify it", async () => { // Clone the Val (defaults to main branch) await runVtCommand( ["clone", val.name, "--no-editor-files"], From 9a8926930af1bbeb356f5595cc84a51188f98a4a Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Mon, 26 Jan 2026 17:20:09 -0500 Subject: [PATCH 8/8] fix type bug --- src/cmd/lib/remix.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cmd/lib/remix.ts b/src/cmd/lib/remix.ts index 10baeddd..fd1e86be 100644 --- a/src/cmd/lib/remix.ts +++ b/src/cmd/lib/remix.ts @@ -157,6 +157,7 @@ async function remixSpecificProject({ } async function remixCurrentDirectory({ + user, privacy, description, editorFiles, @@ -173,6 +174,7 @@ async function remixCurrentDirectory({ const newValName = await generateUniqueProjectName(val.name); const newVt = await VTClient.create({ + username: user.username!, rootPath: currentVt.rootPath, valName: newValName, privacy,