Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 135 additions & 74 deletions src/cmd/lib/remix.ts
Original file line number Diff line number Diff line change
@@ -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(
"<fromValUri:string> [newValName:string] [targetDir:string]",
"[fromValUri:string] [newValName:string] [targetDir:string]",
)
.option("--public", "Remix as public Val (default)", {
conflicts: ["private", "unlisted"],
Expand All @@ -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,
Expand All @@ -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<string> {
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<string> {
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;
}
55 changes: 52 additions & 3 deletions src/cmd/tests/remix_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
4 changes: 3 additions & 1 deletion src/vt/vt/VTClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
}
}