plans/invalid-names.md
Dyad currently treats an app's display name and filesystem folder name inconsistently. Some flows use the app name directly as a folder path, while app blueprint approval sanitizes the display name into a folder name before renaming.
This matters because macOS, Windows, and POSIX filesystems do not accept the same names. A name that works as display text can be invalid or dangerous as a folder name.
AppBlueprintDataSchema.appName accepts any string.app-blueprint:edit-field stores any appName string in memory.DyadAppBlueprintCard derives the target folder with sanitizeAppFolderName(effectiveAppName).sanitizeAppFolderName currently:
< > : " | ? * / \ with -untitled-app for empty output, ., or ..renameApp as appName.renameApp accepts any display appName.appPath, and only when the path changes.< > : " | ? * / \., or ...createApp uses params.name directly as both display name and folder path.copyApp uses newAppName directly as both display name and folder path.appName directly as the destination folder path.As display names, Dyad currently allows:
< > : " / \ | ? *CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9. or space, which Windows rejects as filenames., .., and path-looking names such as foo/bar, depending on the flow/ and NUL where they are not prevalidatedUse separate concepts:
The common path should stay simple: users type a nice app name, Dyad sanitizes it on their behalf, and Dyad creates a safe folder. New folder names should be lowercase slugs. User-entered and blueprint-generated display names should preserve readable/title-case formatting after sanitization, while folder names use lowercase slugs.
Existing apps should not be proactively migrated. Apply the new policy when creating, copying, importing, or renaming apps. One deliberate exception: app blueprint approval always normalizes the folder to the canonical slug, even for legacy folders (e.g. My App → my-app), so approval has one simple rule.
Add a shared app naming utility.
sanitizeAppDisplayName(name), slugifyAppFolderName(name), validateAppFolderName(folderName), and sanitizeAppFolderNameInput(folderName) — a case-preserving safety sanitizer (essentially today's sanitizeAppFolderName, extended with the reserved-name/trailing-period/length rules) for user-typed folder names that must not be slugified.slugifyAppFolderName should lowercase names and always produce a single filesystem-safe path segment (path separators are stripped, so the output can never contain / or \).validateAppFolderName checks filesystem safety only (invalid characters, control characters, reserved device names, trailing periods/spaces, ./.., length). It must NOT enforce lowercase-slug format — legacy folders like My Awesome App and user-chosen mixed-case folders remain valid.sanitizeAppDisplayName falls back to Untitled App when sanitization eats the whole string (mirroring the untitled-app folder fallback), so a blank or control-character-only generated name still yields a coherent name/folder pair.Define a cross-platform folder-name policy.
/ and \.< > : " | ? *.-.日本語アプリ remain meaningful, lowercased via toLowerCase().untitled-app for ., .., empty names, and names that sanitize to empty.CON.txt; append -app when needed.My App!, my app, and My-App all become my-app). In the derived-folder flows (create, copy, import-with-copy, blueprint approval), auto-suffix on conflict: my-app-2, my-app-3, … The suffix is applied AFTER the 80-character truncation (shortening the base if needed) so it is never truncated away. Conflict probing must cover both the database (name and resolved path) and the filesystem.Apply the policy at every filesystem-writing app flow.
createApp: derive a safe lowercase slug folder name from the submitted display name before checking path conflicts and creating files, auto-suffixing on collision. Store the sanitized display name so it mirrors the folder name as much as possible.copyApp: same as create. Note: copyApp today only checks the database for a display-name conflict and never checks whether the destination directory exists before copyDir — with slugs, two distinct display names can map to the same folder and silently merge one app's files into another's directory. Add an explicit destination-existence check (and auto-suffix) like create.renameApp: validate the provided appPath with the shared validator, and keep accepting arbitrary display appName. Validate ONLY when the path changes (keep the existing pathChanged guard) — a display-name-only rename passes the existing path back unchanged, and legacy paths must keep working without a de-facto migration. Do not slugify or lowercase a user-typed folder name; accept any folder that passes the safety validator.renameApp. Approval always normalizes the folder to the canonical slug — including legacy folders whose leaf differs only in case/format (My App → my-app). On a display-name conflict, auto-suffix the display name (Todo App → Todo App 2) and continue instead of rolling back to the rename dialog; the dialog remains only for unexpected rename failures. Manual create/copy keep the hard Conflict error for display-name collisions, since there the user typed the name and can adjust it.renameApp with an opt-in flag (e.g. autoResolveConflicts: true, used by blueprint approval) so probe-and-rename runs atomically under the existing withLock — a renderer-side probe followed by a rename is a race. The handler returns the final display name and path; the approval card persists the final name back into the blueprint via the existing edit-field path (as it already does for dialog overrides) so the blueprint, app row, and agent all agree on the name.renameApp and the preview handler) must exclude the app being renamed, so re-approving the same blueprint is a no-op rather than inflating Todo App 2 → Todo App 3 on each approval.renameApp path-conflict check is a case-sensitive string compare; fix it as part of this work.Decide UX for sanitization.
sanitizeAppFolderNameInput (case-preserving, no slugification) before submitting and keep the resulting field value visible.Food/Drink Planner do not block the flow — including display-name collisions in blueprint approval, which auto-suffix rather than block.Improve errors.
DyadError with DyadErrorKind.Validation for invalid folder names.DyadErrorKind.Conflict.Add focused tests.
., .., empty/whitespace-only namesCafé Planner → cafe-planner)日本語アプリ stays 日本語アプリ, not untitled-app)my-app taken → my-app-2), including suffixing near the 80-character limitMy Awesome App)Todo App → Todo App 2) instead of opening the rename dialog, and for normalizing a legacy folder to its slug on approval.untitled-app folder (suffixed on collision).applyAppTemplate) already had its own folder
slugification (allocateNewAppPath using shared/slugify.ts); it counted
the app's own folder as a collision, so a folder that already matched the
canonical slug got pointlessly re-suffixed (lumen-notes-2 →
lumen-notes-2-1). It now delegates to the shared
slugifyAppFolderName + resolveUniqueFolderName (with self-exclusion), so
blueprint approval and template apply agree on the folder and re-applying is
a no-op. slugifyAppPath in shared/slugify.ts remains for GitHub
repo / Vercel project name defaults (those must stay ASCII), and
slugifyAppFolderName adopted its camelCase/acronym splitting so folder
names and repo-name defaults stay consistent.validateAppFolderName enforces filesystem safety only, not slug format; rename validates only when the path changes.Todo App 2) instead of blocking; manual create/copy keep the hard Conflict error.sanitizeAppFolderNameInput, never slugified.renameApp (opt-in flag, under the app lock), excludes the app being renamed (re-approval is idempotent), and resolves the name suffix before deriving the folder slug from the final name.Untitled App, mirroring the untitled-app folder fallback.