From bc067c39be50d9fcfb793647580805ef6655fda5 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 29 Jan 2026 11:25:53 -0500 Subject: [PATCH 1/4] improve org name choosing --- src/cmd/lib/create.ts | 191 +++++++++++++++++++++++------------ src/cmd/lib/utils/parsing.ts | 73 +++++++------ 2 files changed, 166 insertions(+), 98 deletions(-) diff --git a/src/cmd/lib/create.ts b/src/cmd/lib/create.ts index 57e7897e..43abc9ff 100644 --- a/src/cmd/lib/create.ts +++ b/src/cmd/lib/create.ts @@ -1,12 +1,15 @@ import { Command } from "@cliffy/command"; import { basename } from "@std/path"; import VTClient, { assertSafeDirectory } from "~/vt/vt/VTClient.ts"; -import { getAllMemberOrgs } from "~/sdk.ts"; +import { getAllMemberOrgs, getCurrentUser } from "~/sdk.ts"; import { APIError } from "@valtown/sdk"; import { doWithSpinner, getClonePath } from "~/cmd/utils.ts"; import { ensureAddEditorFiles } from "~/cmd/lib/utils/messages.ts"; import { Confirm, Select } from "@cliffy/prompt"; import { DEFAULT_EDITOR_TEMPLATE } from "~/consts.ts"; +import { parseValUri } from "./utils/parsing.ts"; +import { levenshteinDistance } from "@std/text"; +import { colors } from "@cliffy/ansi/colors"; export const createCmd = new Command() .name("create") @@ -14,7 +17,7 @@ export const createCmd = new Command() .arguments(" [targetDir:string]") .option( "--org-name ", - 'Create the Val under an organization you are a member of, or "me" for your personal account', + "Create the Val under an organization you are a member of", ) .option("--public", "Create as public Val (default)", { conflicts: ["private", "unlisted"], @@ -27,22 +30,20 @@ export const createCmd = new Command() }) .option("--no-editor-files", "Skip creating editor configuration files") .option( - "--upload-if-exists", // useful for testing + "--upload-if-exists", "Upload existing files to the new Val if the directory is not empty", ) .option("-d, --description ", "Val description") .example( "Start fresh", - ` -vt create my-val + `vt create my-val cd ./my-val vt browse vt watch # syncs changes to Val town`, ) .example( "Work on an existing val", - ` -vt clone username/valName + `vt clone username/valName cd ./valName vim index.tsx vt push`, @@ -51,6 +52,18 @@ vt push`, "Upload existing files to a new Val", `vt create my-val ./folder/that/has/files/already`, ) + .example( + "Make a new Val in my own account", + ` +vt create @my-username/my-val +`, + ) + .example( + "Make a new Val in an org", + ` +vt create @my-org/my-val +`, + ) .example( "Check out a new branch", ` @@ -74,81 +87,81 @@ vt checkout main`, ) => { await doWithSpinner("Creating new Val...", async (spinner) => { const clonePath = getClonePath(targetDir, valName); + const memberOrgs = await getAllMemberOrgs(); + const user = await getCurrentUser(); + let myAccount = false; + + if (valName.includes("/")) { + const { ownerName, valName: extractedValName } = parseValUri(valName); + valName = extractedValName; + + if (ownerName === user.username) { // we are in "my account" mode + myAccount = true; + } else { // treat it as org name + orgName = ownerName; + } + } // 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 - // if they are a member of any orgs. If they are, then we offer for them - // to choose one or "me" interactively. - // - // We allow specifying "me" explicitly to mean personal account with the - // flag to avoid the prompt (which is useful in testing). - if (!orgName) { - const orgs = await getAllMemberOrgs(); - const orgNames = orgs.map((o) => o.username!); - const orgIds = orgs.map((o) => o.id!); - if (orgNames.length > 0) { - spinner.stop(); - const orgOrMe = await Select.prompt({ - search: true, - message: - "Would you like to create the new Val under an organization you are a member of, or your personal account?", - default: "Personal Account", - options: ["Personal Account", ...orgNames], - }); - if (orgOrMe !== "Personal Account") { - // Org usernames are unique, but not in time, so we can use it to grab the index - // (a little janky, but it's what cliffy gives us) - orgName = orgIds[orgNames.indexOf(orgOrMe)]; - } else { - orgName = "me"; // remap to magic "me" value - } - } - } else if (orgName !== "me") { - const orgs = await getAllMemberOrgs(); - const org = orgs.find((o) => o.username === orgName); - if (!org) { - const orgNames = orgs.map((o) => `"${o.username}`).join('", ') + '"'; - throw new Error( - `You are not a member of an organization with the name "${orgName}".\nYou are a member of: ${orgNames}`, - ); + spinner.stop(); + if (orgName === undefined && myAccount !== true) { + const orgNames = memberOrgs.map((o) => o.username!); + const orgIds = memberOrgs.map((o) => o.id!); + + const orgOrMe = await Select.prompt({ + search: true, + message: + "Would you like to create the new Val under an organization you are a member of, or your personal account?", + default: "Personal Account", + options: ["Personal Account", ...orgNames], + }); + + if (orgOrMe === "Personal Account") { + myAccount = true; + } else { + // Org usernames are unique, but not in time, so we can use it to grab the index + orgName = orgIds[orgNames.indexOf(orgOrMe)]; + myAccount = false; + + await assertInOrgAndGetId(orgName, memberOrgs); } - orgName = org.id!; } - if (orgName && orgName === "me") { - orgName = undefined; // remap to undefined for personal account, which is the API default + if (orgName !== undefined) { + await assertInOrgAndGetId(orgName, memberOrgs); } try { - try { - await assertSafeDirectory(clonePath); - } catch (e) { - if (e instanceof Error && e.message.includes("not empty")) { - if (!uploadIfExists) { - spinner.stop(); - const confirmContinue = await Confirm.prompt( - `The directory "${ - basename(clonePath) - }" already exists and is not empty. Do you want to continue?` + - " Existing files will be uploaded to the new Val.", - ); - - if (!confirmContinue) { - Deno.exit(0); - } + await assertSafeDirectory(clonePath); + } catch (e) { + if (e instanceof Error && e.message.includes("not empty")) { + if (!uploadIfExists) { + spinner.stop(); + const confirmContinue = await Confirm.prompt( + `The directory "${ + basename(clonePath) + }" already exists and is not empty. Do you want to continue?` + + " Existing files will be uploaded to the new Val.", + ); + + if (!confirmContinue) { + Deno.exit(0); } - } else { - throw e; } + } else { + throw e; } + } - const vt = await (orgName + const orgId = memberOrgs.find((o) => o.username === orgName)?.id; + + try { + const vt = await (myAccount ? VTClient.create({ rootPath: clonePath, valName, - orgId: orgName, privacy, description, skipSafeDirCheck: true, @@ -156,6 +169,7 @@ vt checkout main`, : VTClient.create({ rootPath: clonePath, valName, + orgId, privacy, description, skipSafeDirCheck: true, @@ -181,3 +195,50 @@ vt checkout main`, } }); }); + +async function assertInOrgAndGetId( + orgName: string, + memberOrgs: Awaited>, +): Promise { + const org = memberOrgs.find((o) => o.username === orgName); + + if (!org) { + const suggestions = memberOrgs + .map((o) => ({ + name: o.username!, + distance: levenshteinDistance(orgName, o.username!), + })) + .sort((a, b) => a.distance - b.distance); + + const closestMatch = suggestions[0]; + const orgNames = memberOrgs.map((o) => ` - ${o.username}`).join("\n"); + + console.log( + `You are not a member of an organization with the name "${orgName}".`, + ); + console.log(); + console.log(`You are a member of the following orgs:\n${orgNames}`); + console.log(); + + if (closestMatch) { + const maxDistance = Math.max(orgName.length, closestMatch.name.length); + const similarity = 1 - (closestMatch.distance / maxDistance); + + if (similarity >= 0.7) { + const confirmed = await Confirm.prompt( + `Did you mean "${colors.bold(closestMatch.name)}"?`, + ); + if (confirmed) { + const matchedOrg = memberOrgs.find((o) => + o.username === closestMatch.name + ); + return matchedOrg!.id!; + } + } + } + + throw new Error(`You weren't a member of the org '${orgName}'.`); + } + + return org.id!; +} diff --git a/src/cmd/lib/utils/parsing.ts b/src/cmd/lib/utils/parsing.ts index b83ce379..fccd73dd 100644 --- a/src/cmd/lib/utils/parsing.ts +++ b/src/cmd/lib/utils/parsing.ts @@ -6,44 +6,51 @@ import { VAL_TOWN_VAL_URL_REGEX } from "~/consts.ts"; * - valName (using currentUsername) * - Any val.town URL containing /x/username/valName * - * @param {string} valUri - The Val identifier to parse - * @param {string} currentUsername - Fallback username if not specified + * @param valUri - The Val identifier to parse + * @param currentUsername - Fallback username if not specified * @returns The extracted ownerName and valName * @throws Error on invalid format */ -export function parseValUri( - valUri: string, - currentUsername: string, -): { ownerName: string; valName: string } { - // Handle val.town URLs - if (valUri.includes("val.town/")) { - const match = valUri.match(VAL_TOWN_VAL_URL_REGEX); + export function parseValUri( + valUri: string, + ): { ownerName: string | undefined; valName: string }; + export function parseValUri( + valUri: string, + currentUsername: string, + ): { ownerName: string; valName: string }; + export function parseValUri( + valUri: string, + currentUsername?: string, + ): { ownerName: string | undefined; valName: string } { + // Handle val.town URLs + if (valUri.includes("val.town/")) { + const match = valUri.match(VAL_TOWN_VAL_URL_REGEX); - if (match) { - const [, ownerName, valName] = match; - return { ownerName, valName }; - } + if (match) { + const [, ownerName, valName] = match; + return { ownerName, valName }; + } - // If we get here, it's a val.town URL but not in the expected format - throw new Error("Invalid val.town URL format"); - } else { - // Handle non-URL formats - const parts = valUri.replace(/^@/, "").split("/"); + // If we get here, it's a val.town URL but not in the expected format + throw new Error("Invalid val.town URL format"); + } else { + // Handle non-URL formats + const parts = valUri.replace(/^@/, "").split("/"); - let ownerName: string; - let valName: string; + let ownerName: string | undefined; + let valName: string; - if (parts.length === 1) { - ownerName = currentUsername; - valName = parts[0]; - } else if (parts.length === 2) { - [ownerName, valName] = parts; - } else { - throw new Error( - "Invalid Val URI. Must be a URL or a URI (username/valName or @username/valName)", - ); - } + if (parts.length === 1) { + ownerName = currentUsername; + valName = parts[0]; + } else if (parts.length === 2) { + [ownerName, valName] = parts; + } else { + throw new Error( + "Invalid Val URI. Must be a URL or a URI (username/valName or @username/valName)", + ); + } - return { ownerName, valName }; - } -} + return { ownerName, valName }; + } + } From f6bfb21a0c2c75e302769267f75765fdca752d3c Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 29 Jan 2026 11:26:01 -0500 Subject: [PATCH 2/4] format code --- src/cmd/lib/utils/parsing.ts | 76 ++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/cmd/lib/utils/parsing.ts b/src/cmd/lib/utils/parsing.ts index fccd73dd..dadde99a 100644 --- a/src/cmd/lib/utils/parsing.ts +++ b/src/cmd/lib/utils/parsing.ts @@ -11,46 +11,46 @@ import { VAL_TOWN_VAL_URL_REGEX } from "~/consts.ts"; * @returns The extracted ownerName and valName * @throws Error on invalid format */ - export function parseValUri( - valUri: string, - ): { ownerName: string | undefined; valName: string }; - export function parseValUri( - valUri: string, - currentUsername: string, - ): { ownerName: string; valName: string }; - export function parseValUri( - valUri: string, - currentUsername?: string, - ): { ownerName: string | undefined; valName: string } { - // Handle val.town URLs - if (valUri.includes("val.town/")) { - const match = valUri.match(VAL_TOWN_VAL_URL_REGEX); +export function parseValUri( + valUri: string, +): { ownerName: string | undefined; valName: string }; +export function parseValUri( + valUri: string, + currentUsername: string, +): { ownerName: string; valName: string }; +export function parseValUri( + valUri: string, + currentUsername?: string, +): { ownerName: string | undefined; valName: string } { + // Handle val.town URLs + if (valUri.includes("val.town/")) { + const match = valUri.match(VAL_TOWN_VAL_URL_REGEX); - if (match) { - const [, ownerName, valName] = match; - return { ownerName, valName }; - } + if (match) { + const [, ownerName, valName] = match; + return { ownerName, valName }; + } - // If we get here, it's a val.town URL but not in the expected format - throw new Error("Invalid val.town URL format"); - } else { - // Handle non-URL formats - const parts = valUri.replace(/^@/, "").split("/"); + // If we get here, it's a val.town URL but not in the expected format + throw new Error("Invalid val.town URL format"); + } else { + // Handle non-URL formats + const parts = valUri.replace(/^@/, "").split("/"); - let ownerName: string | undefined; - let valName: string; + let ownerName: string | undefined; + let valName: string; - if (parts.length === 1) { - ownerName = currentUsername; - valName = parts[0]; - } else if (parts.length === 2) { - [ownerName, valName] = parts; - } else { - throw new Error( - "Invalid Val URI. Must be a URL or a URI (username/valName or @username/valName)", - ); - } + if (parts.length === 1) { + ownerName = currentUsername; + valName = parts[0]; + } else if (parts.length === 2) { + [ownerName, valName] = parts; + } else { + throw new Error( + "Invalid Val URI. Must be a URL or a URI (username/valName or @username/valName)", + ); + } - return { ownerName, valName }; - } - } + return { ownerName, valName }; + } +} From 191be9eeb760f1d94dfd2e055332448192e0243e Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 29 Jan 2026 11:29:08 -0500 Subject: [PATCH 3/4] add test --- src/cmd/tests/create_test.ts | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/cmd/tests/create_test.ts b/src/cmd/tests/create_test.ts index 05441ee3..cc207c2c 100644 --- a/src/cmd/tests/create_test.ts +++ b/src/cmd/tests/create_test.ts @@ -8,6 +8,7 @@ import { exists } from "@std/fs"; import { join } from "@std/path"; import type ValTown from "@valtown/sdk"; import { doWithTempDir } from "~/vt/lib/utils/misc.ts"; +import { doWithNewVal } from "~/vt/lib/tests/utils.ts"; import sdk, { branchNameToBranch, getCurrentUser, @@ -321,3 +322,42 @@ Deno.test({ sanitizeOps: false, sanitizeResources: false, }); + +Deno.test({ + name: "create Val in org using orgName/valName format", + permissions: "inherit", + async fn(t) { + await doWithTempDir(async (tmpDir) => { + await doWithNewVal(async ({ org }) => { + const newValName = randomValName(); + let newVal: ValTown.Val | null = null; + + await t.step( + "create a new val in org using orgName/valName", + async () => { + await runVtCommand( + ["create", `${org.handle}/${newValName}`], + tmpDir, + ); + + newVal = await sdk.alias.username.valName.retrieve( + org.handle, + newValName, + ); + + assertEquals(newVal.name, newValName); + assertEquals(newVal.author.username, org.handle); + }, + ); + + await t.step("make sure the Val is cloned", async () => { + assert( + await exists(join(tmpDir, newValName)), + "val was not cloned to target", + ); + }); + }, { inOrg: true }); + }); + }, + sanitizeResources: false, +}); From a5a36135b03a1f0e5b8a2e3494198a1255a866cc Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 29 Jan 2026 11:30:49 -0500 Subject: [PATCH 4/4] fix parsing --- src/cmd/lib/utils/parsing.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/cmd/lib/utils/parsing.ts b/src/cmd/lib/utils/parsing.ts index dadde99a..b289700a 100644 --- a/src/cmd/lib/utils/parsing.ts +++ b/src/cmd/lib/utils/parsing.ts @@ -3,21 +3,33 @@ import { VAL_TOWN_VAL_URL_REGEX } from "~/consts.ts"; /** * Parses a Val identifier from various formats: * - username/valName or @username/valName - * - valName (using currentUsername) + * - valName (using currentUsername if provided) * - Any val.town URL containing /x/username/valName * - * @param valUri - The Val identifier to parse - * @param currentUsername - Fallback username if not specified + * @param {string} valUri - The Val identifier to parse + * @param {string} currentUsername - Fallback username if not specified in valUri * @returns The extracted ownerName and valName * @throws Error on invalid format */ -export function parseValUri( - valUri: string, -): { ownerName: string | undefined; valName: string }; export function parseValUri( valUri: string, currentUsername: string, ): { ownerName: string; valName: string }; + +/** + * Parses a Val identifier from various formats: + * - username/valName or @username/valName + * - valName (ownerName will be undefined) + * - Any val.town URL containing /x/username/valName + * + * @param {string} valUri - The Val identifier to parse + * @returns The extracted ownerName (if present) and valName + * @throws Error on invalid format + */ +export function parseValUri( + valUri: string, +): { ownerName: string | undefined; valName: string }; + export function parseValUri( valUri: string, currentUsername?: string,