From 5fdbf3e1a9e409a4920a7b4061cd86911bde8119 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Tue, 17 Feb 2026 14:39:18 +0100 Subject: [PATCH 1/4] fix: align all project creation paths to use slug-as-name with retry All programmatic project creation now matches reserveProjectSlug behavior: - name field always equals slug (no more slugToName humanization) - 409 slug conflicts retry up to 10x with random suffix appended - Removes createRemoteProject/slugToName from TUI utils (replaced by reserveProjectSlug) - Updates MCP create/clone tools to use createProjectWithRetry - Removes unused name/target_name input fields from MCP tool schemas Co-Authored-By: Claude Opus 4.6 --- cli/app/operations/project-creation.ts | 6 +-- cli/app/utils.ts | 28 ------------- cli/mcp/remote-file-tools.test.ts | 14 ++----- cli/mcp/remote-file-tools.ts | 54 +++++++++++++++++++------- 4 files changed, 45 insertions(+), 57 deletions(-) diff --git a/cli/app/operations/project-creation.ts b/cli/app/operations/project-creation.ts index b99d4c3269..574f2feee4 100644 --- a/cli/app/operations/project-creation.ts +++ b/cli/app/operations/project-creation.ts @@ -12,11 +12,11 @@ import { readToken } from "../../auth/token-store.ts"; import { fetchRemoteProjects } from "../../sync/index.ts"; import { copyDirectory, - createRemoteProject, generateRandomSlug, getLocalProjectsFromState, normalizeSlug, } from "../utils.ts"; +import { reserveProjectSlug } from "../../shared/reserve-slug.ts"; import { initCommand } from "../../commands/init/init-command.ts"; import type { InitTemplate } from "../../commands/init/types.ts"; @@ -45,7 +45,7 @@ export async function createProject( } const normalizedSlug = normalizeSlug(projectName); - const { slug } = await createRemoteProject(token, normalizedSlug); + const { slug } = await reserveProjectSlug(normalizedSlug, token); const projectPath = `${cwd()}/projects/${slug}`; await initCommand({ @@ -95,7 +95,7 @@ export async function createProjectFromExample( } const normalizedSlug = normalizeSlug(projectName); - const { slug } = await createRemoteProject(token, normalizedSlug); + const { slug } = await reserveProjectSlug(normalizedSlug, token); const projectPath = `${cwd()}/projects/${slug}`; await copyDirectory(example.path, projectPath); diff --git a/cli/app/utils.ts b/cli/app/utils.ts index d2b465296a..5f941807bf 100644 --- a/cli/app/utils.ts +++ b/cli/app/utils.ts @@ -6,8 +6,6 @@ import { cwd } from "veryfront/platform"; import { join } from "veryfront/platform/path"; -import { getEnvironmentConfig } from "veryfront/config"; -import { capitalizeSeparatedWords } from "veryfront/utils/case-utils"; import { readToken } from "../auth/token-store.ts"; import { pullCommand } from "../commands/pull/index.ts"; import { addLog, type AppState, type StateUpdater } from "./state.ts"; @@ -44,32 +42,6 @@ export function normalizeSlug(projectName: string): string { return projectName.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); } -export function slugToName(slug: string): string { - return capitalizeSeparatedWords(slug, "-", " "); -} - -export async function createRemoteProject( - token: string, - slug: string, -): Promise<{ slug: string }> { - const apiUrl = getEnvironmentConfig().apiUrl || "https://api.veryfront.com"; - const response = await fetch(`${apiUrl}/projects`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ slug, name: slugToName(slug) }), - }); - - if (response.ok) return (await response.json()) as { slug: string }; - - const error = await response.json().catch(() => ({})); - const msg = (error as { message?: string }).message || `HTTP ${response.status}`; - throw new Error(msg); -} - export function getLocalProjectsFromState( appState: AppState, ): Array<{ slug: string; path: string }> { diff --git a/cli/mcp/remote-file-tools.test.ts b/cli/mcp/remote-file-tools.test.ts index 6b26461e28..4c6dcd55d2 100644 --- a/cli/mcp/remote-file-tools.test.ts +++ b/cli/mcp/remote-file-tools.test.ts @@ -350,22 +350,18 @@ describe("cli/mcp/remote-file-tools", () => { assertEquals(vfRemoteCreateProject.name, "vf_remote_create_project"); }); - it("should require name and slug", () => { + it("should require slug", () => { const valid = vfRemoteCreateProject.inputSchema.safeParse({ - name: "My Project", slug: "my-project", }); assertEquals(valid.success, true); - const missingSlug = vfRemoteCreateProject.inputSchema.safeParse({ - name: "My Project", - }); + const missingSlug = vfRemoteCreateProject.inputSchema.safeParse({}); assertEquals(missingSlug.success, false); }); it("should accept optional template and is_public", () => { const result = vfRemoteCreateProject.inputSchema.safeParse({ - name: "My Project", slug: "my-project", template: "chat", is_public: true, @@ -379,17 +375,15 @@ describe("cli/mcp/remote-file-tools", () => { assertEquals(vfRemoteCloneProject.name, "vf_remote_clone_project"); }); - it("should require source_project, target_name, target_slug", () => { + it("should require source_project and target_slug", () => { const valid = vfRemoteCloneProject.inputSchema.safeParse({ source_project: "source-proj", - target_name: "Clone Project", target_slug: "clone-project", }); assertEquals(valid.success, true); const missingTarget = vfRemoteCloneProject.inputSchema.safeParse({ source_project: "source-proj", - target_name: "Clone Project", }); assertEquals(missingTarget.success, false); }); @@ -397,7 +391,6 @@ describe("cli/mcp/remote-file-tools", () => { it("should accept optional file_pattern", () => { const result = vfRemoteCloneProject.inputSchema.safeParse({ source_project: "source-proj", - target_name: "Clone Project", target_slug: "clone-project", file_pattern: "*.tsx", }); @@ -482,7 +475,6 @@ describe("cli/mcp/remote-file-tools", () => { it("should return error for create project without token", async () => { await assertExecuteError( vfRemoteCreateProject.execute({ - name: "Test", slug: "test", }), ); diff --git a/cli/mcp/remote-file-tools.ts b/cli/mcp/remote-file-tools.ts index ad3356067f..e742a0512f 100644 --- a/cli/mcp/remote-file-tools.ts +++ b/cli/mcp/remote-file-tools.ts @@ -13,6 +13,7 @@ import { z } from "zod"; import type { MCPTool } from "./tools.ts"; import { getEnvironmentConfig } from "veryfront/config"; import { withSpan } from "veryfront/observability/otlp-setup"; +import { randomSuffix } from "#cli/shared/slug"; import { DEFAULT_LOCAL_API_URL } from "#cli/shared/constants"; @@ -133,6 +134,37 @@ interface Project { created_at?: string; } +const MAX_SLUG_ATTEMPTS = 10; + +/** + * Create a project with slug conflict retry. + * On 409, appends a random suffix and retries (matches reserveProjectSlug behavior). + */ +async function createProjectWithRetry( + slug: string, + body: Record, +): Promise<{ ok: true; data: Project; slug: string } | { ok: false; error: string }> { + let currentSlug = slug; + + for (let attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { + const result = await apiRequest("POST", "/projects", { + body: { ...body, slug: currentSlug, name: currentSlug }, + }); + + if (result.ok && result.data) { + return { ok: true, data: result.data, slug: currentSlug }; + } + + if (result.status !== 409) { + return { ok: false, error: result.error ?? "Failed to create project" }; + } + + currentSlug = `${slug}-${randomSuffix()}`; + } + + return { ok: false, error: `Could not find available slug after ${MAX_SLUG_ATTEMPTS} attempts` }; +} + // ============================================================================ // Tool: vf_remote_list_files // ============================================================================ @@ -571,8 +603,7 @@ export const vfRemoteDeleteBranch: MCPTool { - const result = await apiRequest("POST", "/projects", { - body: { - name: input.name, - slug: input.slug, - template: input.template, - isPublic: input.is_public, - }, + const result = await createProjectWithRetry(input.slug, { + template: input.template, + isPublic: input.is_public, }); if (!result.ok) return { success: false, error: result.error }; @@ -610,9 +637,8 @@ export const vfRemoteCreateProject: MCPTool { - const createResult = await apiRequest("POST", "/projects", { - body: { name: input.target_name, slug: input.target_slug }, - }); + const createResult = await createProjectWithRetry(input.target_slug, {}); if (!createResult.ok) { return { success: false, error: `Failed to create project: ${createResult.error}` }; @@ -682,7 +706,7 @@ export const vfRemoteCloneProject: MCPTool( "PUT", - `/${input.target_slug}/files/${encodeFilePath(file.path)}`, + `/${createResult.slug}/files/${encodeFilePath(file.path)}`, { body: { content: getResult.data.content } }, ); From 93ab47ada517ba3bcdc36c0641386e1b8fe333c3 Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Tue, 17 Feb 2026 14:39:32 +0100 Subject: [PATCH 2/4] style: format long describe string Co-Authored-By: Claude Opus 4.6 --- cli/mcp/remote-file-tools.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/mcp/remote-file-tools.ts b/cli/mcp/remote-file-tools.ts index e742a0512f..8c484b8c44 100644 --- a/cli/mcp/remote-file-tools.ts +++ b/cli/mcp/remote-file-tools.ts @@ -603,7 +603,9 @@ export const vfRemoteDeleteBranch: MCPTool Date: Tue, 17 Feb 2026 14:53:26 +0100 Subject: [PATCH 3/4] fix: use humanized name from base slug (without random suffix) Project name is now derived from the base slug via slugToName (e.g. "brave-einstein" -> "Brave Einstein"), while the slug may have a random suffix appended on 409 conflict. This keeps the display name clean regardless of slug collision retries. Co-Authored-By: Claude Opus 4.6 --- cli/mcp/remote-file-tools.ts | 11 ++++++++++- cli/shared/reserve-slug.ts | 11 +++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/cli/mcp/remote-file-tools.ts b/cli/mcp/remote-file-tools.ts index 8c484b8c44..9028308ea4 100644 --- a/cli/mcp/remote-file-tools.ts +++ b/cli/mcp/remote-file-tools.ts @@ -136,19 +136,28 @@ interface Project { const MAX_SLUG_ATTEMPTS = 10; +function slugToName(slug: string): string { + return slug + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + /** * Create a project with slug conflict retry. * On 409, appends a random suffix and retries (matches reserveProjectSlug behavior). + * Name is derived from the base slug (without random suffix) for readability. */ async function createProjectWithRetry( slug: string, body: Record, ): Promise<{ ok: true; data: Project; slug: string } | { ok: false; error: string }> { + const name = slugToName(slug); let currentSlug = slug; for (let attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { const result = await apiRequest("POST", "/projects", { - body: { ...body, slug: currentSlug, name: currentSlug }, + body: { ...body, slug: currentSlug, name }, }); if (result.ok && result.data) { diff --git a/cli/shared/reserve-slug.ts b/cli/shared/reserve-slug.ts index 66c33d02b6..b381b11023 100644 --- a/cli/shared/reserve-slug.ts +++ b/cli/shared/reserve-slug.ts @@ -7,8 +7,13 @@ */ import { type EnvironmentConfig, getEnvironmentConfig } from "veryfront/config"; +import { capitalizeSeparatedWords } from "veryfront/utils/case-utils"; import { randomSuffix } from "#cli/shared/slug"; +function slugToName(slug: string): string { + return capitalizeSeparatedWords(slug, "-", " "); +} + export interface ReserveResult { slug: string; projectId: string; @@ -37,10 +42,11 @@ export async function reserveProjectSlug( token: string, env: EnvironmentConfig = getEnvironmentConfig(), ): Promise { + const name = slugToName(slug); let currentSlug = slug; for (let attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { - const result = await tryCreateProject(currentSlug, token, env); + const result = await tryCreateProject(currentSlug, name, token, env); if (result.success) { return { @@ -62,6 +68,7 @@ export async function reserveProjectSlug( async function tryCreateProject( slug: string, + name: string, token: string, env: EnvironmentConfig = getEnvironmentConfig(), ): Promise { @@ -73,7 +80,7 @@ async function tryCreateProject( "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify({ slug, name: slug }), + body: JSON.stringify({ slug, name }), }); if (response.ok) { From 173323c93b9428bb8d1beafac5ed1195a4293a3e Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Tue, 17 Feb 2026 15:22:32 +0100 Subject: [PATCH 4/4] feat: expand slug word lists to 22k+ combinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 155 adjectives × 143 nouns = 22,165 unique slug combinations. Co-Authored-By: Claude Opus 4.6 --- cli/app/data/slug-words.ts | 315 ++++++++++++++++++++++++++----------- 1 file changed, 225 insertions(+), 90 deletions(-) diff --git a/cli/app/data/slug-words.ts b/cli/app/data/slug-words.ts index 18959d34d3..dd634f4400 100644 --- a/cli/app/data/slug-words.ts +++ b/cli/app/data/slug-words.ts @@ -1,168 +1,303 @@ export const ADJECTIVES = [ - "amber", - "azure", - "coral", - "crimson", - "cyan", - "golden", - "indigo", - "ivory", - "jade", - "magenta", - "maroon", - "olive", - "onyx", - "opal", - "pearl", - "ruby", - "scarlet", - "silver", - "teal", - "topaz", - "turquoise", - "violet", + "agile", "alpine", + "amber", + "ancient", + "aqua", "arctic", + "arid", + "astral", "autumn", - "coastal", - "crystal", - "desert", - "floral", - "forest", - "frozen", - "lunar", - "misty", - "mossy", - "ocean", - "polar", - "rainy", - "snowy", - "solar", - "spring", - "stormy", - "sunny", - "tidal", - "tropic", - "windy", - "agile", + "azure", + "balmy", + "blazing", "bold", "brave", + "breezy", "bright", + "brisk", + "bronze", "calm", + "cedar", "clever", + "coastal", + "cobalt", + "copper", + "coral", "cosmic", + "crimson", + "crisp", + "crystal", + "cyan", "daring", + "deep", + "dewy", + "dreamy", + "dusky", + "dusty", "eager", + "earthy", + "ebony", + "emerald", "epic", + "faint", + "feral", "fierce", + "fleet", + "floral", + "fluid", + "forest", + "fresh", + "frozen", + "frosty", + "garnet", "gentle", + "glacial", + "gleaming", + "glowing", + "golden", + "graceful", "grand", + "hazy", + "hollow", + "hushed", + "icy", + "indigo", + "ivory", + "jade", "keen", "kind", + "lasting", + "lavender", + "leafy", + "light", + "lilac", "lively", + "lone", + "lucid", + "lunar", + "magic", + "magenta", + "marine", + "maroon", + "mellow", + "mighty", + "misty", + "mossy", "mystic", "nimble", "noble", + "obsidian", + "olive", + "onyx", + "opal", + "pale", + "pearl", + "placid", + "platinum", + "polar", + "prime", "proud", + "pure", "quiet", + "rainy", "rapid", + "rare", + "regal", + "rosy", + "ruby", + "rugged", + "russet", + "rustic", + "saffron", + "sage", + "sandy", + "sapphire", + "scarlet", "serene", + "shady", + "sharp", "silent", + "silver", + "slate", + "sleek", + "smooth", + "snowy", + "soft", + "solar", + "somber", + "stark", + "starry", "steady", + "stormy", + "strong", + "subtle", + "sunny", + "sunlit", "swift", + "tawny", + "teal", + "tidal", + "topaz", + "tropic", + "turquoise", + "vast", + "velvet", + "verdant", + "violet", "vivid", + "warm", "wild", + "windy", + "wintry", "wise", "witty", + "woody", "zen", ]; export const NOUNS = [ + "arc", + "arch", + "aurora", + "basin", "bay", + "beam", + "bend", + "blaze", + "bluff", + "bog", + "bolt", + "breeze", "brook", "canal", - "cascade", - "coast", - "creek", - "delta", - "falls", - "fjord", - "gulf", - "harbor", - "lagoon", - "lake", - "marsh", - "ocean", - "pond", - "rapids", - "reef", - "river", - "shore", - "spring", - "strait", - "stream", - "tide", - "wave", - "bluff", "canyon", + "cape", + "cascade", "cave", + "cavern", + "channel", "cliff", - "crater", - "desert", - "dune", - "field", - "glade", - "gorge", - "grove", - "hill", - "isle", - "mesa", - "oasis", - "pass", - "peak", - "plain", - "plateau", - "ridge", - "rock", - "slope", - "stone", - "summit", - "trail", - "valley", - "volcano", - "aurora", "cloud", + "coast", "comet", + "copse", + "corona", "cosmos", + "cove", + "crater", + "creek", + "crest", + "dale", "dawn", + "delta", + "desert", + "drift", + "dune", "dusk", "eclipse", + "edge", "ember", + "falls", + "fen", + "field", + "fjord", "flare", + "flame", + "ford", "frost", "galaxy", + "gap", + "glade", + "glen", "glow", + "gorge", + "grove", + "gulf", + "gust", + "harbor", + "haven", "haze", + "heath", + "hill", + "hollow", "horizon", + "inlet", + "isle", + "knoll", + "lagoon", + "lake", + "ledge", + "loch", + "marsh", + "meadow", + "mesa", "meteor", "mist", "moon", + "moor", + "mount", "nebula", + "north", "nova", + "oasis", + "ocean", "orbit", + "pass", + "peak", + "pine", + "plain", + "plateau", + "plume", + "pond", + "pool", "prism", "pulse", "quasar", + "rain", + "range", + "rapids", + "ravine", "ray", + "reef", + "ridge", + "rift", + "ripple", + "river", + "rock", + "sand", + "shade", "shadow", + "shoal", + "shore", "sky", + "slope", + "snow", "spark", + "spring", "star", + "stone", "storm", + "strait", + "stream", + "summit", "sun", + "surf", "thunder", + "tide", + "torch", + "trail", + "tundra", "twilight", + "vale", + "valley", "vapor", + "volcano", + "vortex", + "wake", + "wave", + "wisp", "wind", "zenith", + "zephyr", ];