fix(wizard): fork 409 — auto-unique slug + editable name/slug in fork step

Bug: forking a template POSTed /v1/servers with slug = trySlug(template.title),
a fixed value. If the user had any server with that slug already (e.g. forking
the same template twice, or a name collision), the create returned 409
slug_taken — and the fork wizard skips Step 1, so there was no slug field to
fix it with. The user was stuck (8 repeated 409s in the report).

Fix:
- On fork setup, after the fork call, GET /v1/servers and auto-unique the
  default slug: echo-demo-template -> echo-demo-template-2 -> -3 ... against the
  user's existing slugs. Lookup failure is non-fatal (slug field is editable).
- Fork step 2 now renders editable Name + Slug fields in the fork banner
  ('must be unique in your workspace' hint) — the normal wizard has these in
  Step 1, which the fork flow skips, so they belong here.
- slug_taken build error now reads 'The slug "x" is already used by one of
  your servers — change the Slug field above' instead of the raw code.

Note: the SES lockdown-install.js and content.js 'query' errors in the report
are browser extensions, not the app.

Verified: forked echo-demo-template (whose base slug was already taken) — slug
auto-filled echo-demo-template-2, build succeeded, container live on :4111,
template fork_count incremented to 2.
This commit is contained in:
Marco Sadjadi 2026-05-20 17:23:24 +02:00
parent 414903f16d
commit c78420e0be

View File

@ -130,8 +130,27 @@ export default function NewServerPage() {
};
}>(`/v1/templates/${templateSlug}/fork`, { method: 'POST', body: '{}' });
if (cancelled) return;
// Auto-unique the slug against the user's existing servers so the
// default fork doesn't 409 (e.g. forking the same template twice).
let uniqueSlug = trySlug(res.template.title);
try {
const own = await apiFetch<{ servers: { slug: string }[] }>('/v1/servers');
const taken = new Set(own.servers.map((s) => s.slug));
if (taken.has(uniqueSlug)) {
const base = uniqueSlug;
let n = 2;
while (taken.has(`${base}-${n}`)) n++;
uniqueSlug = `${base}-${n}`;
}
} catch {
// If the lookup fails we still proceed; the slug field is editable
// and the build surfaces a clear slug_taken error.
}
if (cancelled) return;
setName(res.template.title);
setSlug(trySlug(res.template.title));
setSlug(uniqueSlug);
setPrompt(`Fork of "${res.template.title}" template.`);
setForkedTemplateId(res.templateId);
setForkedTemplateTitle(res.template.title);
@ -325,7 +344,12 @@ export default function NewServerPage() {
setStep('building');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: unknown } }).detail;
setError(detail?.error ?? (e as Error).message);
const code = detail?.error;
setError(
code === 'slug_taken'
? `The slug "${slug}" is already used by one of your servers — change the Slug field above.`
: (code ?? (e as Error).message),
);
}
}
@ -424,20 +448,45 @@ export default function NewServerPage() {
{step === 'confirm' && preview && editable && (
<div className="mt-7 space-y-6">
{forkedTemplateTitle && (
<div className="panel-subtle p-3 flex items-center justify-between">
<div className="panel-subtle p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="text-[12.5px]">
Forking <span className="mono font-semibold">{forkedTemplateTitle}</span> fill in
your own credentials below. The template author never sees them.
Forking <span className="mono font-semibold">{forkedTemplateTitle}</span> name
your copy and fill in your own credentials. The template author never sees them.
</div>
<a
href={`/templates/${templateSlug}`}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-fg-muted] underline hover:text-[--color-fg]"
className="shrink-0 text-[11.5px] text-[--color-fg-muted] underline hover:text-[--color-fg]"
>
Template
</a>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fork-name">Name</Label>
<Input
id="fork-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slug || slug === trySlug(name)) setSlug(trySlug(e.target.value));
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fork-slug" hint="must be unique in your workspace">
Slug
</Label>
<Input
id="fork-slug"
value={slug}
onChange={(e) => setSlug(trySlug(e.target.value))}
/>
</div>
</div>
</div>
)}
<div className="panel p-4">
<div className="flex items-baseline justify-between">