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
191 changes: 126 additions & 65 deletions src/cmd/lib/create.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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")
.description("Create a new Val")
.arguments("<valName:string> [targetDir:string]")
.option(
"--org-name <org:string>",
'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"],
Expand All @@ -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 <desc:string>", "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`,
Expand All @@ -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",
`
Expand All @@ -74,88 +87,89 @@ 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,
})
: VTClient.create({
rootPath: clonePath,
valName,
orgId,
privacy,
description,
skipSafeDirCheck: true,
Expand All @@ -181,3 +195,50 @@ vt checkout main`,
}
});
});

async function assertInOrgAndGetId(
orgName: string,
memberOrgs: Awaited<ReturnType<typeof getAllMemberOrgs>>,
): Promise<string> {
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!;
}
27 changes: 23 additions & 4 deletions src/cmd/lib/utils/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,37 @@ 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 {string} valUri - The Val identifier to parse
* @param {string} currentUsername - Fallback username if not specified
* @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,
currentUsername: string,
): { ownerName: string; valName: 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,
): { ownerName: string | undefined; valName: string } {
// Handle val.town URLs
if (valUri.includes("val.town/")) {
const match = valUri.match(VAL_TOWN_VAL_URL_REGEX);
Expand All @@ -30,7 +49,7 @@ export function parseValUri(
// Handle non-URL formats
const parts = valUri.replace(/^@/, "").split("/");

let ownerName: string;
let ownerName: string | undefined;
let valName: string;

if (parts.length === 1) {
Expand Down
40 changes: 40 additions & 0 deletions src/cmd/tests/create_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Loading