From bab78fa1a7150584ef511561eccd358f3e22334d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kerekes=20D=C3=A1niel?= Date: Fri, 20 Feb 2026 20:05:59 +0100 Subject: [PATCH 01/16] Add Ctrl+O parent dir and Ctrl+Y copy path keybindings to project selector --- src/commands/select.ts | 19 ++- src/ui/project-search.ts | 339 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 src/ui/project-search.ts diff --git a/src/commands/select.ts b/src/commands/select.ts index 7860a5d..ea2efdd 100644 --- a/src/commands/select.ts +++ b/src/commands/select.ts @@ -1,4 +1,5 @@ -import { search } from "@inquirer/prompts"; +import projectSearch from "../ui/project-search"; +import { dirname } from "node:path"; import { loadCache, loadHistory, @@ -61,7 +62,7 @@ async function _select(shellOutput: ShellOutput): Promise { const history = await loadHistory(); const allProjects = cache.projects; - const selectedPath = await search({ + const result = await projectSearch({ message: "Select a project", source: async (input) => { const results = filterAndRank(allProjects, input ?? "", history); @@ -70,19 +71,21 @@ async function _select(shellOutput: ShellOutput): Promise { pageSize: 15, }); - // Validate the path still exists - if (!(await pathExists(selectedPath))) { + const targetPath = result.parentDir ? dirname(result.path) : result.path; + + // Validate the target path still exists + if (!(await pathExists(targetPath))) { console.error( - `\nProject path does not exist: ${selectedPath}\nThe drive may not be mounted. Run 'wd scan' to refresh.` + `\nPath does not exist: ${targetPath}\nThe drive may not be mounted. Run 'wd scan' to refresh.` ); process.exit(1); } - // Record visit - const updatedHistory = recordVisit(history, selectedPath); + // Record visit against the project path (not the parent dir) + const updatedHistory = recordVisit(history, result.path); await saveHistory(updatedHistory); // Output cd command - shellOutput.cd(selectedPath); + shellOutput.cd(targetPath); await shellOutput.flush(); } diff --git a/src/ui/project-search.ts b/src/ui/project-search.ts new file mode 100644 index 0000000..90494c6 --- /dev/null +++ b/src/ui/project-search.ts @@ -0,0 +1,339 @@ +/** + * Custom search prompt extending @inquirer/search with two additional keybindings: + * Ctrl+O — resolve with parentDir: true (navigate to parent directory) + * Ctrl+Y — copy selected path to clipboard, stay in prompt + */ +import { + createPrompt, + useState, + useKeypress, + usePrefix, + usePagination, + useEffect, + useMemo, + isDownKey, + isEnterKey, + isTabKey, + isUpKey, + Separator, + makeTheme, + type KeypressEvent, +} from "@inquirer/core"; +import colors from "yoctocolors-cjs"; +import figures from "@inquirer/figures"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ProjectSearchResult { + path: string; + parentDir: boolean; +} + +type Choice = { + value: string; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; +}; + +type ChoiceOrSeparator = string | Separator | Choice; + +interface ProjectSearchConfig { + message: string; + source: ( + term: string | undefined, + opt: { signal: AbortSignal } + ) => readonly ChoiceOrSeparator[] | Promise; + pageSize?: number; + validate?: ( + value: string + ) => boolean | string | Promise; +} + +// ─── Internal normalised choice ─────────────────────────────────────────────── + +type NormalizedChoice = { + value: string; + name: string; + short: string; + disabled: boolean | string; + description?: string; +}; + +// ─── Theme ─────────────────────────────────────────────────────────────────── + +const searchTheme = { + icon: { cursor: figures.pointer }, + style: { + disabled: (text: string) => colors.dim(`- ${text}`), + searchTerm: (text: string) => colors.cyan(text), + description: (text: string) => colors.cyan(text), + keysHelpTip: (keys: [key: string, action: string][]) => + keys + .map(([key, action]) => `${colors.bold(key)} ${colors.dim(action)}`) + .join(colors.dim(" \u2022 ")), + }, + helpMode: "always" as const, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function isSelectable( + item: NormalizedChoice | Separator +): item is NormalizedChoice { + return !Separator.isSeparator(item) && !(item as NormalizedChoice).disabled; +} + +function normalizeChoices( + choices: readonly ChoiceOrSeparator[] +): (NormalizedChoice | Separator)[] { + return choices.map((choice) => { + if (Separator.isSeparator(choice)) return choice; + if (typeof choice === "string") { + return { value: choice, name: choice, short: choice, disabled: false }; + } + const name = choice.name ?? String(choice.value); + const normalized: NormalizedChoice = { + value: choice.value, + name, + short: choice.short ?? name, + disabled: choice.disabled ?? false, + }; + if (choice.description) normalized.description = choice.description; + return normalized; + }); +} + +async function copyToClipboard(text: string): Promise { + const proc = Bun.spawn(["pbcopy"], { stdin: "pipe" }); + proc.stdin.write(text); + proc.stdin.end(); + await proc.exited; +} + +// ─── Prompt ─────────────────────────────────────────────────────────────────── + +export default createPrompt( + (config, done) => { + const { pageSize = 7, validate = () => true } = config; + const theme = makeTheme(searchTheme, undefined); + + const [status, setStatus] = useState< + "loading" | "idle" | "done" + >("loading"); + const [searchTerm, setSearchTerm] = useState(""); + const [searchResults, setSearchResults] = useState< + (NormalizedChoice | Separator)[] + >([]); + const [searchError, setSearchError] = useState( + undefined + ); + const [copiedFeedback, setCopiedFeedback] = useState(false); + + const prefix = usePrefix({ status, theme }); + + const bounds = useMemo(() => { + const first = searchResults.findIndex(isSelectable); + const last = searchResults.findLastIndex(isSelectable); + return { first, last }; + }, [searchResults]); + + const [active = bounds.first, setActive] = useState( + undefined + ); + + useEffect(() => { + const controller = new AbortController(); + setStatus("loading"); + setSearchError(undefined); + + const fetchResults = async () => { + try { + const results = await config.source(searchTerm || undefined, { + signal: controller.signal, + }); + if (!controller.signal.aborted) { + setActive(undefined); + setSearchError(undefined); + setSearchResults(normalizeChoices(results)); + setStatus("idle"); + } + } catch (error) { + if (!controller.signal.aborted && error instanceof Error) { + setSearchError(error.message); + } + } + }; + + void fetchResults(); + return () => { + controller.abort(); + }; + }, [searchTerm]); + + const selectedChoice = + active !== undefined + ? (searchResults[active] as NormalizedChoice | undefined) + : undefined; + + useKeypress(async (key: KeypressEvent, rl) => { + // Ctrl+O: navigate to parent directory + if (key.ctrl && key.name === "o") { + if (selectedChoice && isSelectable(selectedChoice)) { + setStatus("done"); + done({ path: selectedChoice.value, parentDir: true }); + } + return; + } + + // Ctrl+Y: copy selected path to clipboard, stay in prompt + if (key.ctrl && key.name === "y") { + if (selectedChoice && isSelectable(selectedChoice)) { + void copyToClipboard(selectedChoice.value).then(() => { + setCopiedFeedback(true); + setTimeout(() => setCopiedFeedback(false), 1500); + }); + } + return; + } + + // Enter: select current item + if (isEnterKey(key)) { + if (selectedChoice && isSelectable(selectedChoice)) { + setStatus("loading"); + const isValid = await validate(selectedChoice.value); + setStatus("idle"); + if (isValid === true) { + setStatus("done"); + done({ path: selectedChoice.value, parentDir: false }); + } else if (selectedChoice.name === searchTerm) { + setSearchError( + typeof isValid === "string" + ? isValid + : "You must provide a valid value" + ); + } else { + rl.write(selectedChoice.name); + setSearchTerm(selectedChoice.name); + } + } else { + rl.write(searchTerm); + } + return; + } + + // Tab: autocomplete with selected item's name + if (isTabKey(key) && selectedChoice && isSelectable(selectedChoice)) { + rl.clearLine(0); + rl.write(selectedChoice.name); + setSearchTerm(selectedChoice.name); + return; + } + + // Up/Down: navigate results + if (status !== "loading" && (isUpKey(key) || isDownKey(key))) { + rl.clearLine(0); + if ( + (isUpKey(key) && active !== bounds.first) || + (isDownKey(key) && active !== bounds.last) + ) { + const offset = isUpKey(key) ? -1 : 1; + let next = active ?? 0; + do { + next = (next + offset + searchResults.length) % searchResults.length; + } while (!isSelectable(searchResults[next]!)); + setActive(next); + } + return; + } + + // Any other key: update search term + setSearchTerm(rl.line); + }); + + // ─── Render ──────────────────────────────────────────────────────────────── + + const message = theme.style.message(config.message, status); + + const helpLine = theme.style.keysHelpTip([ + ["↑↓", "navigate"], + ["⏎", "select"], + ["ctrl+o", "parent dir"], + ["ctrl+y", "copy path"], + ]); + + if (status === "done" && selectedChoice) { + return [prefix, message, theme.style.answer(selectedChoice.short)] + .filter(Boolean) + .join(" ") + .trimEnd(); + } + + const searchStr = theme.style.searchTerm(searchTerm); + const header = [prefix, message, searchStr] + .filter(Boolean) + .join(" ") + .trimEnd(); + + const page = usePagination({ + items: searchResults, + active: active ?? bounds.first, + renderItem({ + item, + isActive, + }: { + item: NormalizedChoice | Separator; + isActive: boolean; + }) { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + if (item.disabled) { + const disabledLabel = + typeof item.disabled === "string" ? item.disabled : "(disabled)"; + return theme.style.disabled(`${item.name} ${disabledLabel}`); + } + const color = isActive ? theme.style.highlight : (x: string) => x; + const cursor = isActive ? theme.icon.cursor : ` `; + return color(`${cursor} ${item.name}`); + }, + pageSize, + loop: false, + }); + + let errorOrPage: string; + if (searchError) { + errorOrPage = theme.style.error(searchError); + } else if ( + searchResults.length === 0 && + searchTerm !== "" && + status === "idle" + ) { + errorOrPage = theme.style.error("No results found"); + } else { + errorOrPage = page; + } + + // Description area: show "Copied!" feedback or normal description + let descriptionLine: string; + if (copiedFeedback) { + descriptionLine = colors.green("✓ Copied to clipboard"); + } else { + const desc = + selectedChoice && isSelectable(selectedChoice) + ? selectedChoice.description + : undefined; + descriptionLine = desc ? theme.style.description(desc) : ""; + } + + const body = [errorOrPage, " ", descriptionLine, helpLine] + .filter(Boolean) + .join("\n") + .trimEnd(); + + return [header, body]; + } +); + +export { Separator }; From 13f30c28024f274f5ef9992b5da79ce306e68399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kerekes=20D=C3=A1niel?= Date: Sun, 22 Feb 2026 01:45:03 +0100 Subject: [PATCH 02/16] Implement Project Constructor --- shell/wd.zsh | 1 + src/commands/new.ts | 570 +++++++++++++++++++++++ src/commands/scan.ts | 7 + src/commands/setup.ts | 33 +- src/config/migrations.ts | 410 ++++++++++++++++ src/config/paths.ts | 7 + src/config/schema.ts | 104 ++++- src/core/__tests__/arg-parser.test.ts | 307 ++++++++++++ src/core/__tests__/interpolation.test.ts | 127 +++++ src/core/arg-parser.ts | 192 ++++++++ src/core/executor.ts | 172 +++++++ src/core/interpolation.ts | 62 +++ src/core/template-cache.ts | 34 ++ src/core/templates.ts | 205 ++++++++ src/index.ts | 37 +- src/ui/directory-search.ts | 346 ++++++++++++++ 16 files changed, 2602 insertions(+), 12 deletions(-) create mode 100644 src/commands/new.ts create mode 100644 src/config/migrations.ts create mode 100644 src/core/__tests__/arg-parser.test.ts create mode 100644 src/core/__tests__/interpolation.test.ts create mode 100644 src/core/arg-parser.ts create mode 100644 src/core/executor.ts create mode 100644 src/core/interpolation.ts create mode 100644 src/core/template-cache.ts create mode 100644 src/core/templates.ts create mode 100644 src/ui/directory-search.ts diff --git a/shell/wd.zsh b/shell/wd.zsh index 5088f1d..a1374fc 100644 --- a/shell/wd.zsh +++ b/shell/wd.zsh @@ -37,6 +37,7 @@ _wd_complete() { local subcommands=( 'setup:Configure base directories' 'scan:Rescan project directories' + 'new:Create a new project from template' 'open:Open a workspace' 'recent:Show recently accessed projects' 'ws:Manage workspaces' diff --git a/src/commands/new.ts b/src/commands/new.ts new file mode 100644 index 0000000..2f893bd --- /dev/null +++ b/src/commands/new.ts @@ -0,0 +1,570 @@ +/** + * `wd new` — Project Constructor wizard. + * + * Interactive wizard for creating new projects from templates. + * Supports pre-filled values via CLI flags (--template, --pm, --dir, etc.) + * and dynamic flags for additional parameters (--base-color zinc, etc.) + */ +import { input, select, search, checkbox, confirm } from "@inquirer/prompts"; +import { join } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { loadTemplates, type CollisionError } from "../core/templates"; +import { interpolate, validatePlaceholders } from "../core/interpolation"; +import { executeCommand, printCommandError } from "../core/executor"; +import { parseNewArgs, extractNewArgv } from "../core/arg-parser"; +import { requireConfig } from "../config/manager"; +import { runPendingMigrations } from "../config/migrations"; +import type { ShellOutput } from "../utils/shell"; +import type { + Template, + Variant, + PackageManager, + AdditionalParameter, +} from "../config/schema"; +import { + bold, + cyan, + green, + yellow, + red, + gray, + clearScreen, + printHeader, + Spinner, +} from "../ui/format"; +import { gracefulRun } from "../utils/prompt-wrapper"; +import { pathExists } from "../utils/fs"; +import directorySearch from "../ui/directory-search"; + +// ─── Snapshot of a wizard round's collected values ─────────────────────────── + +interface WizardState { + appName: string; + template: Template; + variant: Variant; + pm: PackageManager; + additionalParams: Map; + targetDir: string; +} + +// ─── Public entry point ─────────────────────────────────────────────────────── + +export async function newProject(shellOutput: ShellOutput): Promise { + await gracefulRun(() => _newProject(shellOutput)); +} + +// ─── Main wizard ────────────────────────────────────────────────────────────── + +async function _newProject(shellOutput: ShellOutput): Promise { + // ── One-time setup (before wizard loop) ─────────────────────────────────── + + const cliArgs = parseNewArgs(extractNewArgv(process.argv)); + + if (cliArgs.unknownFlags.length > 0) { + for (const flag of cliArgs.unknownFlags) { + console.log(`${yellow("!")} Unknown flag ignored: ${gray(flag)}`); + } + } + + const config = await runPendingMigrations(); + const gistUrl = config?.projectConstructor?.templates?.gistUrl ?? ""; + + const spinner = new Spinner("Loading templates…"); + spinner.start(); + + const { templates, collisions, cacheStale } = await loadTemplates({ + gistUrl, + forceRefresh: cliArgs.raw, + }); + + spinner.stop(); + + if (cacheStale) { + console.log( + `${yellow("!")} Template cache is stale (offline?). Using cached templates.`, + ); + } + + if (collisions.length > 0) { + console.error(`\n${red("✗")} Template ID collision detected:\n`); + for (const col of collisions as CollisionError[]) { + console.error( + ` ID: ${bold(col.id)}\n` + + ` Gist template: ${col.gistName}\n` + + ` Custom template: ${col.customName}\n`, + ); + } + console.error( + `\n${yellow("Tip:")} Rename the custom template's ${bold("id")} field to a unique value.\n`, + ); + process.exit(1); + } + + const visibleTemplates = templates.filter((t) => !t.hidden); + + if (visibleTemplates.length === 0 && templates.length === 0) { + console.log( + `${yellow("!")} No templates found.\n\n` + + `Add custom templates to: ${gray("~/.config/wd/templates/*.json")}\n` + + `Or configure a template gist URL in config.\n`, + ); + return; + } + + const cliArgsFull = parseNewArgs(extractNewArgv(process.argv), templates); + + // ── Wizard loop ─────────────────────────────────────────────────────────── + // `prev` is null on the first run, set to the previous round's values on edit. + + let prev: WizardState | null = null; + + while (true) { + clearScreen(); + printHeader(); + console.log(`${bold("Project Constructor")}\n`); + + const isEdit = prev !== null; + + // ── Step 1: Project name ──────────────────────────────────────────────── + + let appName: string; + if (cliArgsFull.appName && !isEdit) { + appName = cliArgsFull.appName; + console.log(`${green("✓")} Project name: ${bold(appName)}`); + } else { + appName = await input({ + message: "Project name:", + default: prev?.appName ?? cliArgsFull.appName, + validate: (v) => { + if (!v.trim()) return "Name cannot be empty"; + if (!/^[a-z0-9\-_]+$/i.test(v.trim())) + return "Use only letters, numbers, hyphens, underscores"; + return true; + }, + }); + } + appName = appName.trim(); + + // ── Step 2: Template ──────────────────────────────────────────────────── + + let selectedTemplate: Template; + if (cliArgsFull.template && !isEdit) { + const found = findTemplate(visibleTemplates, cliArgsFull.template); + if (!found) { + console.error( + `${red("✗")} Template not found: ${bold(cliArgsFull.template)}\n` + + `Available: ${visibleTemplates.map((t) => t.id).join(", ")}`, + ); + process.exit(1); + } + selectedTemplate = found; + console.log(`${green("✓")} Template: ${bold(selectedTemplate.name)}`); + } else if (isEdit) { + // Edit mode: select with previous value pre-highlighted + selectedTemplate = await select