diff --git a/README.md b/README.md index 43488f5..3bdeb1e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # wd — Workspace Director -A fast macOS CLI tool for navigating between projects and launching development workspaces. Built with TypeScript and Bun. +A fast macOS CLI tool for navigating between projects, creating new apps from templates, and launching development workspaces. Built with TypeScript and Bun. ## The problem When you have dozens (or hundreds) of projects spread across multiple directories, getting to the right one is friction. You `cd` into the wrong folder, you forget the exact directory name, you open four terminal tabs manually every time you start working on a project. -`wd` solves the navigation part, and gives you a workspace system so a single command can drop you into the right directory, start your Docker services, and open all the terminal tabs you need — each with the right command already running. +`wd` solves the navigation part, adds a workspace system so a single command can drop you into the right directory and start your usual setup, and includes a Project Constructor for creating new projects from templates. ## Features @@ -14,11 +14,14 @@ When you have dozens (or hundreds) of projects spread across multiple directorie - **Frecency ranking** — projects you use most recently and frequently appear first - **Configurable scan roots** — point it at any directories, it finds all your projects automatically - **Project type detection** — recognizes Next.js, NestJS, Angular, Flutter, Swift, Rust, Tauri, and more +- **Project Constructor** — create new projects from built-in or local custom templates with interactive or flag-driven flows - **Workspace profiles** — group related projects (e.g. frontend + backend) and attach Docker containers +- **Workspace browser** — inspect, open, edit, duplicate, or delete workspaces from an interactive `wd ws` menu - **Terminal tab opening** — each workspace project can specify tabs to open with commands (`bun dev`, `claude`, etc.) - **Docker integration** — start named containers or docker-compose services when opening a workspace - **Docker port conflict resolution** — if a container fails due to a port conflict, wd detects which container is blocking and offers to stop it -- **Custom project types** — define your own detection rules for frameworks not built-in +- **Interactive configuration menu** — manage scan roots, custom types, preferences, ignore rules, and template source settings with `wd config` +- **Custom project types and templates** — extend detection rules and add your own local templates - **Shell integration** — actually changes your working directory (not just prints a path) ## Requirements @@ -71,12 +74,12 @@ Run first-time setup: wd setup ``` -Restart your shell or `source ~/.zshrc`. +`wd setup` creates `~/.config/wd/`, copies the shell wrapper to `~/.config/wd/wd.zsh`, offers an initial scan, and initializes the local templates directory with an example hidden template. Restart your shell or `source ~/.zshrc` afterwards. ## Quick start ```sh -# Configure which directories to scan +# Configure scan roots and install the shell wrapper wd setup # Navigate to a project @@ -85,16 +88,25 @@ wd # See recently visited projects wd recent -# Create a workspace (group of related projects + terminal tabs + Docker) +# Create a new project from a template +wd new my-app -t nextjs + +# Manage scan roots, preferences, custom types, and template source settings +wd config + +# Create a workspace wd ws new -# Edit an existing workspace -wd ws edit my-workspace +# Browse workspaces interactively +wd ws + +# Duplicate an existing workspace +wd ws duplicate my-workspace -# Open a workspace (cd + start Docker + open tabs) +# Open a workspace (cd + Docker + tabs) wd open my-workspace -# Rescan after adding new projects +# Rescan after adding or moving projects wd scan ``` @@ -106,13 +118,15 @@ Because a child process cannot change your shell's working directory, `wd` uses When opening a workspace, `wd` uses AppleScript (via `osascript`) to open additional terminal tabs. It detects your terminal from `$TERM_PROGRAM` and uses the appropriate API: native AppleScript for iTerm2 and Terminal.app, keystroke simulation for Ghostty and Warp. +`wd new` loads templates from a default remote source, merges them with local templates from `~/.config/wd/templates/`, caches remote templates locally, and then runs the selected template command with your chosen options. + ## Project structure -``` +```text src/ commands/ — one file per CLI command config/ — schema (Zod), file manager, path constants - core/ — scanner, detector, fuzzy search, frecency, docker, terminal + core/ — scanner, detector, fuzzy search, frecency, docker, templates, terminal ui/ — ANSI colors, spinner, formatters utils/ — shell output protocol, fs helpers, prompt wrapper shell/ @@ -121,14 +135,16 @@ shell/ ## Configuration -All config lives in `~/.config/wd/`: +All config lives in `~/.config/wd/`. Most day-to-day settings can be managed from `wd config`. -| File | Purpose | -| -------------- | ---------------------------------------------- | -| `config.json` | Scan roots, preferences, custom types | -| `cache.json` | Cached project list (auto-refreshed every 24h) | -| `history.json` | Visit history for frecency ranking | -| `workspaces/` | Workspace definition files | +| File | Purpose | +| ----------------------------- | -------------------------------------------------------------- | +| `config.json` | Scan roots, preferences, custom types, and template source URL | +| `cache.json` | Cached project list (auto-refreshed every 24h) | +| `history.json` | Visit history for frecency ranking | +| `workspaces/` | Workspace definition files | +| `templates/` | Local custom templates and template cache | +| `templates/template-cache.json` | Cached remote templates used by `wd new` | ## License diff --git a/USAGE.md b/USAGE.md index 4503c4a..3d5f5ee 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,6 +1,6 @@ # wd Usage Guide -A practical walkthrough of everything `wd` can do, from first-time setup to advanced configuration. +A practical walkthrough of everything `wd` can do, from first-time setup to project creation, workspace automation, and advanced configuration. --- @@ -10,12 +10,16 @@ A practical walkthrough of everything `wd` can do, from first-time setup to adva - [Navigating to a project](#navigating-to-a-project) - [Recently visited projects](#recently-visited-projects) - [Rescanning projects](#rescanning-projects) +- [Creating a project](#creating-a-project) - [Workspaces](#workspaces) + - [Interactive workspace management](#interactive-workspace-management) - [Creating a workspace](#creating-a-workspace) - [Opening a workspace](#opening-a-workspace) - [Editing a workspace](#editing-a-workspace) + - [Duplicating a workspace](#duplicating-a-workspace) - [Listing workspaces](#listing-workspaces) - [Deleting a workspace](#deleting-a-workspace) +- [Managing configuration](#managing-configuration) - [Custom project types](#custom-project-types) - [Config file reference](#config-file-reference) - [Workspace file reference](#workspace-file-reference) @@ -27,9 +31,9 @@ A practical walkthrough of everything `wd` can do, from first-time setup to adva ## First-time setup -Run `wd setup` to configure the tool. The terminal clears and shows the `wd` header, then walks you through adding directories to scan. +Run `wd setup` to configure the tool. The terminal clears and shows the `wd` header, then walks you through adding or removing directories to scan. -``` +```text $ wd setup db d8b db d8888b. @@ -45,28 +49,36 @@ What would you like to do? Done ``` +If you already have configured scan roots, setup lists them first and also lets you remove entries before saving. + For each scan root, you provide: -- **Path** — the directory to scan (e.g. `/Volumes/MyDrive/Developer/Work`) -- **Label** — a short name shown in the project list (e.g. `Work`) -- **Category** — used for grouping in output (e.g. `work`) -- **Max depth** — how many directory levels deep to search for projects (default: 3) +- **Path** — the directory to scan (for example `/Volumes/MyDrive/Developer/Work`) +- **Label** — a short name shown in the project list (for example `Work`) +- **Category** — a grouping label stored in config +- **Max depth** — how many directory levels deep to search for projects (default: `3`) **Choosing max depth:** -If your projects are directly inside the scan root (e.g. `Work/my-project/`), use depth `2`. If they are one level deeper (e.g. `Personal/Ongoing/my-project/`), use `3`. +- If your projects are directly inside the scan root (for example `Work/my-project/`), use depth `2`. +- If they are one level deeper (for example `Personal/Ongoing/my-project/`), use `3`. -Once you finish adding roots, `wd` will scan immediately and show you how many projects were found. +After saving the config, setup: -At the end of setup, you will see instructions for shell integration. If you have not already added it, add this line to your `~/.zshrc`: +- tries to copy the shell integration script to `~/.config/wd/wd.zsh` +- offers to run an initial project scan immediately +- creates `~/.config/wd/templates/` +- creates a hidden example template in that directory if one does not exist yet + +At the end, add this line to your `~/.zshrc` if it is not already there: ```sh source ~/.config/wd/wd.zsh ``` -Then restart your shell or run `source ~/.zshrc`. After that, the `wd` command will work with proper directory changing. +Then restart your shell or run `source ~/.zshrc`. -> **Note on shell integration:** Without sourcing `wd.zsh`, the `wd-bin` binary still works but `cd` will not take effect in your current shell. This is a fundamental shell limitation — no child process can change the parent shell's directory. The `wd` function wraps the binary and handles this. +> **Note on shell integration:** Without sourcing `wd.zsh`, the `wd-bin` binary still works but `cd` will not take effect in your current shell. This is a shell limitation: a child process cannot change the parent shell's directory. The `wd` function wraps the binary and applies the `cd` command for you. --- @@ -76,11 +88,11 @@ Then restart your shell or run `source ~/.zshrc`. After that, the `wd` command w wd ``` -This opens an interactive fuzzy search over all your projects. Start typing to filter; the list narrows as you type. Use arrow keys to move, Enter to select. +This opens an interactive fuzzy search over all your projects. Start typing to filter; the list narrows as you type. Use arrow keys to move and Enter to select. -**What you see in the list:** +**What you see in the list by default:** -``` +```text my-app-web Next.js Work / my-app my-app-api NestJS Work / my-app blog-site Next.js Personal / Ongoing @@ -88,19 +100,25 @@ This opens an interactive fuzzy search over all your projects. Start typing to f landing-page Next.js Work ``` -Each row shows: project name, detected type, and which scan root and parent directory it lives in. +Each row can show: + +- project name +- detected type badge +- scan root label and parent directory + +The type badge and category column can be turned on or off later with `wd config`. **Fuzzy matching rules:** -- All characters of your query must appear in the project name in order, but do not have to be consecutive -- `maw` matches `my-app-web` (m...a...w) -- Matches at word boundaries (after `-` or `/`) score higher than matches in the middle of a word +- All characters of your query must appear in the project name in order, but do not have to be consecutive. +- `maw` matches `my-app-web` (`m...a...w`). +- Matches at word boundaries score higher than matches in the middle of a word. **Frecency ranking:** -When you type nothing, projects are sorted by frecency (see [How frecency works](#how-frecency-works)). Projects you have visited recently and frequently appear at the top. On first use, the list is alphabetical. +When you type nothing, projects are sorted by frecency. Projects you have visited recently and frequently appear at the top. On first use, the list is alphabetical. -After selecting a project, `wd` changes your shell's working directory to that project. Your prompt will update to reflect the new location. +After selecting a project, `wd` changes your shell's working directory to that project and records the visit in frecency history. --- @@ -110,11 +128,11 @@ After selecting a project, `wd` changes your shell's working directory to that p wd recent ``` -Shows a ranked list of the projects you have visited most recently and frequently — up to 20 by default (configurable in `config.json`). +Shows a ranked list of the projects you have visited most recently and frequently, up to `preferences.maxRecent` projects from `config.json` (`20` by default). The list includes the time since your last visit: -``` +```text my-app-web Next.js 2h ago blog-site Next.js 5h ago design-system React 2d ago @@ -133,43 +151,207 @@ wd scan Scans all configured directories and rebuilds the project cache. Use this when: -- You have created new projects since the last scan -- You have deleted or moved projects -- The cache seems out of date +- you have created new projects since the last scan +- you have deleted or moved projects +- the cache seems out of date The cache is also refreshed automatically when you run `wd` or `wd recent` if the cache is older than 24 hours. -After scanning, `wd scan` prints a summary: +After scanning, `wd scan` prints a summary with: + +- number of scan roots +- total project count +- counts grouped by scan root label +- counts grouped by detected project type + +It also refreshes the template cache in the background using the currently configured template source URL. + +--- + +## Creating a project + +`wd new` is the Project Constructor. It creates a new project from a template, either fully interactively or by pre-filling some or all choices with CLI flags. + +```sh +wd new +``` + +You can also pass the project name as the first positional argument: + +```sh +wd new my-app +``` + +### Template sources + +`wd new` merges templates from two places: + +- the built-in remote template source +- local custom template files in `~/.config/wd/templates/*.json` + +The remote source can be overridden with `projectConstructor.templates.gistUrl` in `config.json`, or via the `Templates URL` entry in `wd config`. + +Important behaviors: + +- `hidden: true` templates are loaded but not shown in the normal selection list +- if a local template uses the same `id` as a remote template, `wd new` exits with a template ID collision error +- remote templates are cached in `~/.config/wd/templates/template-cache.json` +- if the remote source is unavailable but a matching cache exists, `wd new` warns and falls back to the cached template set +- `--raw` forces a remote refresh instead of reusing the current cache + +### Wizard flow + +The normal interactive flow is: + +1. Project name +2. Template +3. Variant +4. Package manager +5. Additional template-specific parameters +6. Target directory +7. Summary and action choice: `Create`, `Edit`, or `Cancel` + +If you choose `Edit` from the summary screen, the wizard loops back with the previous selections pre-filled. + +### Validation and behavior + +- Project names must use only lowercase letters, numbers, hyphens, and underscores. +- If a selected template has only one variant or one package manager, `wd new` auto-selects it. +- If the final project path already exists, `wd new` asks whether to continue. +- If the target directory itself does not exist, `wd new` asks whether to create it. +- If you type a directory outside your configured scan roots in the directory picker, `wd new` offers to add it as a new scan root. +- After a successful create, `wd new` changes into the new project directory and records the visit in frecency history. +- If a post-create command fails, `wd new` shows the error and asks whether to continue anyway. +- Unknown flags are warned about and ignored. +- Dynamic flags that do not belong to the selected variant are warned about and ignored. + +### Directory picker + +Unless you pass `--dir`, `wd new` opens a directory picker for the target directory. + +Useful behaviors: + +- type to filter visible directories +- press `Tab` to autocomplete the highlighted directory +- type `@` to switch to scan-root matching mode +- in `@` mode, use Left/Right to cycle through matching roots and `Tab` to insert the selected root path +- pressing Enter on a typed path accepts it even if it is new + +### Flags + +| Flag | Meaning | +| ---- | ------- | +| `my-app` | Positional app name | +| `--template `, `-t ` | Pre-select a template by ID or name | +| `--variant `, `-v ` | Pre-select a template variant | +| `--pm `, `--package-manager ` | Pre-select the package manager | +| `--dir ` | Skip the directory picker and use a target directory directly | +| `--dry-run` | Show the interpolated create command and post-create commands without creating anything | +| `--verbose` | Stream command output live instead of using the spinner UI | +| `--raw` | Force-refresh remote templates instead of using the current template cache | + +### Dynamic template flags + +Templates can expose additional CLI flags through `wizardParameter` definitions. + +That gives you: + +- a long dynamic flag such as `--base-color zinc` +- an optional shorthand such as `-bc zinc` + +These flags are template-defined, not global. Available dynamic flags depend on the selected variant. + +### Examples + +Fully interactive: + +```sh +wd new +``` + +Partially pre-filled: + +```sh +wd new my-app -t nextjs -v shadcn +``` + +Fully pre-filled: +```sh +wd new my-app -t nextjs -v shadcn --pm bun --dir ~/Developer/Work --base-color zinc +``` + +Dry run: + +```sh +wd new my-app -t nextjs -v shadcn --pm bun --dry-run ``` -Scanned 2 roots in 0.14s - Found 109 projects - Work 22 projects - Personal 87 projects +Verbose command execution: + +```sh +wd new my-app -t nextjs --verbose +``` + +### Example dry-run output + +```text +Dry run summary: + Project: my-app + Template: Next.js + Variant: shadcn + Package: bun + + Command: bunx --bun create-next-app my-app --yes - Types: - [Next.js] 18 - [NestJS] 3 - [Angular] 2 - [Flutter] 2 - [Tauri] 6 - ... +Dry run complete — no project was created. ``` --- ## Workspaces -A workspace is a named group of related projects. The main use case is when you regularly work on two projects together — like a frontend and a backend — and you want to jump to the right place, start your services, and have all your terminal tabs ready with a single command. +A workspace is a named group of related projects. The main use case is when you regularly work on two or more projects together, like a frontend and a backend, and you want to jump to the right place, start services, and open your usual tabs with one command. A workspace stores: -- Which projects are part of it -- Which project is the "primary" (where `wd open` will `cd` to) -- Which terminal tabs to open for each project, and what command to run in each -- Which Docker containers to start (optional) -- A docker-compose file to bring up (optional) +- which projects are part of it +- which project is the primary project +- which terminal tabs to open for each project +- which Docker containers to start, if any +- which compose file to bring up, if any + +### Interactive workspace management + +```sh +wd ws +``` + +Running `wd ws` without a subcommand opens an interactive workspace browser. + +The first screen shows all saved workspaces with: + +- workspace name +- optional description +- included projects +- primary project marker +- attached containers and compose file, if configured + +From the list: + +- press Enter to open the detail view for the selected workspace +- press `o` on a selected workspace to open it immediately +- choose `Back` or press Escape to exit + +The detail view offers these actions: + +- `Open` +- `Edit` +- `Duplicate` +- `Delete` +- `Back` + +This is the fastest way to inspect or manage workspaces when you do not want to remember exact names. ### Creating a workspace @@ -181,29 +363,33 @@ You will be guided through: **1. Name and description** -``` +```text Workspace name: my-app Description (optional): Frontend + API ``` -Names must be alphanumeric, hyphens and underscores are allowed. No spaces. +Workspace names must use only lowercase letters, numbers, hyphens, and underscores. Spaces are not allowed. + +If a workspace with the same name already exists, creation is blocked. **2. Selecting projects** -A checkbox list appears. Use **Space** to select projects, **Enter** to confirm. Multiple projects can be selected at once. +A checkbox list appears. Use `Space` to select projects and `Enter` to confirm. -``` +```text Select projects (Space to select, Enter to confirm): [ ] my-app-web Next.js Work / my-app [ ] my-app-api NestJS Work / my-app [ ] blog-site Next.js Personal / Ongoing ``` +At least one project must be selected. + **3. Choosing the primary project** -If you selected more than one project, you choose which one `wd open` will `cd` into. Usually this is the frontend. +If you selected more than one project, you choose which one `wd open` will `cd` into. -``` +```text Which is the primary project (where wd will cd)? > my-app-web my-app-api @@ -211,44 +397,36 @@ Which is the primary project (where wd will cd)? **4. Tab configuration** -For each project, you choose how many terminal tabs to open and what command to run in each. The first tab of the primary project is your current shell — any command you set there runs after the `cd`. +For each project, you choose how many terminal tabs to open and what command to run in each tab. -``` +```text my-app-web (primary) /Volumes/MyDrive/Developer/Work/my-app-web How many tabs to open? (1 = just cd, 0 = skip): 3 Tab 1 command: (this is your current shell) Tab 2 command: claude Tab 3 command: bun dev - - my-app-api /Volumes/MyDrive/Developer/Work/my-app-api - - How many tabs to open? (1 = just cd, 0 = skip): 1 - Tab 1 command: npm run start:dev ``` -Leave a command blank to open a tab that just `cd`s into the project without running anything. +Rules: -**5. Docker configuration (optional)** +- `0` means do not open a tab for that project +- `1` means one shell in that directory +- leaving a tab command blank opens the tab and only changes directory +- the first tab of the primary project is your current shell, not a newly opened tab -If Docker is running, `wd` lists all your existing containers and lets you pick which ones to start when the workspace opens. +**5. Docker configuration** -``` -Configure Docker containers for this workspace? (y/N): y - -Select containers to start when opening this workspace: - [ ] my-app-postgres-1 postgres:15 [exited] - [ ] my-app-redis-1 redis:7 [exited] - [ ] other-app-db-1 postgres:14 [running] -``` +If Docker is available, `wd` can attach: -Use Space to select, Enter to confirm. +- named containers to start with `docker start` +- a compose file to start with `docker compose up -d` -You can also attach a docker-compose file instead of (or in addition to) named containers. `wd` will suggest compose files it detected in your selected projects. +For compose setup, `wd ws new` first suggests compose files it detected inside the selected projects. If you do not choose one, it lets you enter the path and filename manually. **6. Saving** -The workspace is saved to `~/.config/wd/workspaces/my-app.json`. +The workspace is saved as `~/.config/wd/workspaces/.json`. ### Opening a workspace @@ -258,49 +436,39 @@ wd open my-app This does the following: -1. Starts any Docker containers attached to the workspace (`docker start `) -2. Runs `docker compose up -d` if a compose file is attached -3. Changes your directory to the primary project -4. Runs the first tab's command in the current shell (if set) -5. Opens additional terminal tabs as configured, with their commands +1. starts any attached Docker containers +2. runs `docker compose up -d` if a compose file is attached +3. changes your directory to the primary project +4. runs the first tab command for the primary project in the current shell, if set +5. opens any additional configured tabs Example output: -``` +```text my-app — Frontend + API - 🐳 Starting containers: my-app-postgres-1, my-app-redis-1... ✓ - ✓ cd → my-app-web - ⇥ Opening tabs... + Starting containers: my-app-postgres-1, my-app-redis-1... done + cd → my-app-web + Opening tabs... ``` **Docker port conflict resolution:** -If a container fails to start because a port is already in use, `wd` detects which container is causing the conflict and offers to resolve it: +If a container fails to start because a port is already in use, `wd` detects the blocking container and offers to stop it and retry. -``` - ! my-app-postgres-1 failed: port 5432 already in use by other-app-db-1 +If Docker is not running, `wd` warns and still performs the `cd`. - What to do? -> Stop other-app-db-1 and retry my-app-postgres-1 - Skip -``` - -If Docker is not running (OrbStack or Docker Desktop not started), `wd` warns you and still performs the `cd`. - -If a project path no longer exists (for example, if the drive is not mounted), `wd open` exits with a clear error message. +If a project path no longer exists, `wd open` exits with a clear error. **Terminal tab support:** -`wd` detects your terminal from the `$TERM_PROGRAM` environment variable and uses the appropriate method to open tabs: - -| Terminal | Method | -| ------------ | ---------------------------- | -| iTerm2 | Native AppleScript API | -| Terminal.app | AppleScript `do script` | -| Ghostty | Keystroke simulation (Cmd+T) | -| Warp | Keystroke simulation (Cmd+T) | +| Terminal | Method | +| -------- | ------ | +| iTerm2 | Native AppleScript API | +| Terminal.app | AppleScript `do script` | +| Ghostty | Keystroke simulation (`Cmd+T`) | +| Warp | Keystroke simulation (`Cmd+T`) | -macOS will ask for Automation permission the first time `wd` tries to open tabs via AppleScript. Allow it in System Settings → Privacy & Security → Automation. +macOS asks for Automation permission the first time `wd` opens tabs with AppleScript. ### Editing a workspace @@ -308,18 +476,42 @@ macOS will ask for Automation permission the first time `wd` tries to open tabs wd ws edit ``` -Opens the same wizard as `wd ws new`, but with all fields pre-filled from the existing workspace. Tab-complete the workspace name after `ws edit`. +This opens the same wizard as `wd ws new`, but pre-fills it with the existing workspace values. + +Important differences from creation: + +- the name is editable +- if you rename the workspace, the old file is deleted and the new name is saved instead +- selected projects are pre-checked +- the current primary project is pre-selected if it is still present +- tab counts and commands are pre-filled +- attached containers are pre-checked +- the current compose config is offered first in the compose suggestions list +- if you remove a project, its old tab configuration is discarded + +The same lowercase-only name validation applies here too, and the new name must not collide with an existing workspace. + +### Duplicating a workspace + +```sh +wd ws duplicate +``` + +Duplicates an existing workspace under a new name. + +Default name generation works like this: -Every step works the same as creation, with these differences: +- `my-app` → `my-app-duplicate` +- if that already exists: `my-app-duplicate-2` +- then `my-app-duplicate-3`, and so on -- **Name** — editable; if you change it, the old workspace file is deleted and a new one is saved under the new name -- **Projects** — currently selected projects are pre-checked in the checkbox list; you can add or remove freely -- **Primary project** — the current primary is pre-selected; if you removed it from the project list, the first selected project is used as the default -- **Tab configuration** — existing tab counts and commands are pre-filled as defaults; just press Enter to keep them -- **Docker containers** — currently attached containers are pre-checked; the Docker section defaults to open if the workspace already has Docker config -- **Compose file** — current compose config is offered as the first option in the list +The duplicate flow asks for the new name and then offers: -If you removed a project from the workspace, its tab configuration is discarded. If you added a new project, its tab configuration starts fresh. +- `Save & Exit` +- `Edit Workspace` +- `Cancel` + +If you choose `Edit Workspace`, the duplicated workspace is saved first and then opened in the normal edit wizard. ### Listing workspaces @@ -327,21 +519,23 @@ If you removed a project from the workspace, its tab configuration is discarded. wd ws list ``` -Shows all saved workspaces with their projects and Docker configuration: +Shows all saved workspaces with their projects and Docker configuration. -``` +Example output: + +```text Workspaces my-app Frontend + API - -> my-app-web (primary) 3 tabs - -> my-app-api 1 tab - Containers: my-app-postgres-1, my-app-redis-1 + → my-app-web (primary) + → my-app-api + 🐳 Containers: my-app-postgres-1, my-app-redis-1 blog - -> blog-site (primary) - -> blog-api - Compose: docker-compose.local.yaml in blog-api + → blog-site (primary) + → blog-api + 🐳 Compose: docker-compose.local.yaml in blog-api ``` ### Deleting a workspace @@ -352,20 +546,137 @@ wd ws delete my-app Removes the workspace definition file. Your projects and Docker containers are unaffected. +You can also delete a workspace from the interactive `wd ws` detail view. + +--- + +## Managing configuration + +```sh +wd config +``` + +`wd config` opens an interactive configuration menu. It is the main way to manage `wd` after the first setup. + +Main menu entries: + +- `Scan roots` +- `Custom project types` +- `Show project type badge` +- `Show category` +- `Max recent projects` +- `Scan ignore list` +- `Templates URL` + +### Scan roots + +This section lets you add or remove scan roots without re-running `wd setup`. + +Adding a scan root asks for: + +- directory path +- label +- category +- max scan depth + +Validation rules: + +- path must exist +- path must be a directory +- the same path cannot be added twice + +### Custom project types + +This section lets you add, edit, or remove custom project detection rules. + +Each custom type has: + +- `name` +- `markers` +- `patterns` +- `color` + +Rules: + +- at least one marker or pattern is required +- regex patterns are validated before saving +- a preview is shown before final confirmation + +### Show project type badge + +Toggles whether selectors show the `[TypeName]` badge next to projects. + +### Show category + +Toggles whether selectors show the scan-root label / location column next to projects. + +### Max recent projects + +Sets how many recent projects `wd recent` keeps and displays. + +Valid range: `1` to `50`. + +### Scan ignore list + +Controls which directory names are skipped during scanning. + +Available actions: + +- add an entry +- remove an entry +- press `e` to open the whole list in your editor + +When using the editor flow: + +- one entry per line +- empty lines are ignored +- changes are shown as additions and removals before saving +- an empty final list is rejected + +### Templates URL + +Controls the remote source for built-in project templates used by `wd new`. + +Supported URL formats: + +- `https://...` +- `file:///absolute/path/to/templates.json` + +Available actions: + +- set a custom URL +- test the current URL +- reset back to the built-in default + +Before saving a new URL, `wd config` tests it and reports: + +- whether the source was reachable +- how many valid templates were found +- validation errors for invalid templates when relevant + +`file://` rules: + +- the path must be absolute +- the path must not contain `..` +- the file must exist + +If the test fails, you can still choose whether to save the URL anyway. + --- ## Custom project types -By default, `wd` recognizes these project types: Next.js, NestJS, Angular, Flutter, Swift, Rust, Tauri, Bun, Node, Python. +By default, `wd` recognizes these built-in project types: Next.js, NestJS, Angular, Flutter, Swift, Rust, Tauri, React, Vue, Bun, Node, Python. -If you work with a framework that is not in this list, you can define your own detection rules in `~/.config/wd/config.json`. +If you work with a framework that is not in this list, define your own detection rules in `wd config` or by editing `~/.config/wd/config.json`. -Add a `customTypes` array: +Example: ```json { "version": 1, - "scanRoots": [...], + "configVersion": 1, + "scanRoots": [], "customTypes": [ { "name": "Django", @@ -378,44 +689,65 @@ Add a `customTypes` array: "markers": ["artisan"], "patterns": ["^composer\\.json$"], "color": "red" - }, - { - "name": "Elixir", - "markers": [], - "patterns": ["^mix\\.exs$"], - "color": "magenta" } - ] + ], + "preferences": { + "showProjectType": true, + "showCategory": true, + "maxRecent": 20, + "scanIgnore": [ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".angular", + "target", + ".dart_tool", + "Pods", + ".build", + "DerivedData", + ".cache" + ] + }, + "projectConstructor": { + "templates": { + "gistUrl": "" + } + } } ``` **Fields:** -| Field | Type | Description | -| ---------- | -------- | ---------------------------------------------------------------------------------------------- | -| `name` | string | Display name shown in the project list | -| `markers` | string[] | Exact filenames that must exist in the project root | -| `patterns` | string[] | Regular expressions matched against filenames in the project root | -| `color` | string | Color for the type badge: `cyan`, `green`, `yellow`, `blue`, `magenta`, `red`, `gray`, `white` | +| Field | Type | Description | +| ----- | ---- | ----------- | +| `name` | string | Display name shown in the project list | +| `markers` | string[] | Exact filenames that must exist in the project root | +| `patterns` | string[] | Regular expressions matched against filenames in the project root | +| `color` | string | Badge color: `cyan`, `green`, `yellow`, `blue`, `magenta`, `red`, `gray`, `white` | **Matching logic:** -- If `markers` is non-empty, all listed files must exist -- If `patterns` is non-empty, at least one must match a filename in the directory -- Both can be combined: markers must all match AND at least one pattern must match -- Custom types are checked after all built-in types, so they have lower priority +- if `markers` is non-empty, all listed files must exist +- if `patterns` is non-empty, at least one pattern must match +- if both are present, both conditions must pass +- custom types are checked after built-in types, so built-ins win if both match -After editing `config.json`, run `wd scan` to rebuild the cache with the new rules. +After changing custom types, run `wd scan` to rebuild the cache. --- ## Config file reference -The main config file is `~/.config/wd/config.json`. It is created and managed by `wd setup`, but you can also edit it directly. +The main config file is `~/.config/wd/config.json`. It is created by `wd setup` and managed by `wd config`, but you can also edit it directly. + +Example: ```json { "version": 1, + "configVersion": 1, "scanRoots": [ { "path": "/Volumes/MyDrive/Developer/Work", @@ -449,37 +781,68 @@ The main config file is `~/.config/wd/config.json`. It is created and managed by "DerivedData", ".cache" ] + }, + "projectConstructor": { + "templates": { + "gistUrl": "" + } } } ``` -**`scanRoots`** — directories to scan for projects +### Top-level fields + +| Field | Default | Description | +| ----- | ------- | ----------- | +| `version` | `1` | Config schema version | +| `configVersion` | `0` or higher | Silent migration version applied to the config | +| `scanRoots` | `[]` | Directories to scan for projects | +| `customTypes` | `[]` | Custom project detection rules | +| `preferences` | object | UI and scanning preferences | +| `projectConstructor` | object | Template source settings for `wd new` | + +### `scanRoots[]` -| Field | Default | Description | -| ---------- | ---------------- | ----------------------------------------- | -| `path` | required | Absolute path to scan | -| `label` | directory name | Shown in the project list | -| `category` | label lowercased | Used for grouping | -| `maxDepth` | 3 | How many levels deep to look for projects | +| Field | Default | Description | +| ----- | ------- | ----------- | +| `path` | required | Absolute path to scan | +| `label` | directory name | Label shown in selectors and scan summaries | +| `category` | label lowercased | Stored grouping value | +| `maxDepth` | `3` | How many levels deep to scan | -**`preferences`** +### `preferences` -| Field | Default | Description | -| ----------------- | --------- | ----------------------------------------------- | -| `showProjectType` | true | Show the `[Next.js]` badge in the project list | -| `showCategory` | true | Show the scan root label in the project list | -| `maxRecent` | 20 | Maximum number of projects shown in `wd recent` | -| `scanIgnore` | see above | Directory names to skip during scanning | +| Field | Default | Description | +| ----- | ------- | ----------- | +| `showProjectType` | `true` | Show the project type badge in selectors | +| `showCategory` | `true` | Show the location/category column in selectors | +| `maxRecent` | `20` | Maximum number of projects shown by `wd recent` | +| `scanIgnore` | see default list above | Directory names to skip during scanning | -**Adding directories to `scanIgnore`:** +**About `scanIgnore`:** -If scanning is slow, or if `wd` is picking up directories that are not projects, add their names to `scanIgnore`. Names are matched against directory entries, not full paths — so `"build"` will skip any directory named `build` anywhere in the scan. +Entries are matched against directory entry names, not full paths. For example, `"build"` skips any directory named `build` anywhere inside a scan root. + +### `projectConstructor.templates` + +| Field | Default | Description | +| ----- | ------- | ----------- | +| `gistUrl` | `""` | Optional override for the remote template source used by `wd new` | + +### Related files under `~/.config/wd/` + +| Path | Purpose | +| ---- | ------- | +| `templates/*.json` | Local custom templates loaded by `wd new` | +| `templates/template-cache.json` | Cached remote templates used for offline fallback and normal loads | --- ## Workspace file reference -Workspace files live in `~/.config/wd/workspaces/.json`. They are created by `wd ws new` but can be edited manually. +Workspace files live in `~/.config/wd/workspaces/.json`. They are created by `wd ws new`, `wd ws duplicate`, or `wd ws edit`, but can also be edited manually. + +Example: ```json { @@ -499,77 +862,87 @@ Workspace files live in `~/.config/wd/workspaces/.json`. They are created } ], "docker": { - "containers": ["my-app-postgres-1", "my-app-redis-1"] + "containers": ["my-app-postgres-1", "my-app-redis-1"], + "compose": { + "path": "/Volumes/MyDrive/Developer/Work/my-app-api", + "file": "docker-compose.yml" + } } } ``` -**`projects[].tabs`** — array of tabs to open for this project +### Fields -Each tab opens in the project's directory. `command` is optional — if omitted or null, the tab just `cd`s into the directory without running anything. +| Field | Description | +| ----- | ----------- | +| `version` | Workspace schema version (`2` for newly saved workspaces) | +| `name` | Workspace name | +| `description` | Optional human-readable description | +| `projects[]` | Included projects and their tab setup | +| `docker.containers[]` | Named containers to start | +| `docker.compose` | Optional compose file configuration | -The first tab of the primary project is special: instead of opening a new terminal tab, its command runs directly in your current shell after the `cd`. This is how the primary shell gets its startup command (e.g. `bun dev`). +### `projects[].tabs` -**`docker.containers`** — named containers to `docker start` when opening the workspace. +Each tab opens in that project's directory. -**`docker.compose`** — optional docker-compose configuration: - -```json -"compose": { - "path": "/Volumes/MyDrive/Developer/Work/my-app-api", - "file": "docker-compose.yml" -} -``` +- if `command` is missing or empty, the tab only changes directory +- the first tab of the primary project is special: its command runs in your current shell after the `cd` +- other tabs are opened as new terminal tabs --- ## How frecency works -Frecency combines frequency (how often you visit a project) and recency (how recently you visited it). Each visit is stored with a timestamp. When ranking, each visit earns points based on how recent it is: +Frecency combines frequency and recency. Each visit stores a timestamp. When ranking projects, recent visits are worth more than older ones. + +| Age of visit | Points | +| ------------ | ------ | +| Last 4 hours | 100 | +| Last 24 hours | 80 | +| Last 3 days | 60 | +| Last week | 40 | +| Last 2 weeks | 20 | +| Last month | 10 | +| Older | 2 | -| Age of visit | Points | -| ------------- | ------ | -| Last 4 hours | 100 | -| Last 24 hours | 80 | -| Last 3 days | 60 | -| Last week | 40 | -| Last 2 weeks | 20 | -| Last month | 10 | -| Older | 2 | +A project's total score is the sum of its visit scores. -A project's total score is the sum of all its visits' points. The project you opened this morning and last week will score higher than a project you opened only once last month. +Additional rules: -Visit records older than 90 days are automatically pruned. Each project stores at most 50 visit timestamps. +- visit records older than 90 days are pruned +- each project stores at most 50 visit timestamps +- `wd`, `wd recent`, and successful `wd new` navigation all feed the same history file -The history file is `~/.config/wd/history.json`. You can delete it to reset frecency entirely. +The history file is `~/.config/wd/history.json`. Delete it to reset frecency completely. --- ## How project detection works -When `wd` scans a directory, it looks at the files directly inside it (not recursively) and tries to match them against detection rules. The first rule that matches wins. +When `wd` scans a directory, it looks at the files directly inside it and tries to match them against detection rules. The first rule that matches wins. -**Built-in detection rules (in priority order):** +**Built-in detection rules (priority order):** -| Type | Detected by | -| ------- | -------------------------------------------------------- | -| Tauri | `src-tauri/` directory exists | -| Flutter | `pubspec.yaml` exists | -| Swift | `*.xcodeproj`, `*.xcworkspace`, or `Package.swift` | -| Rust | `Cargo.toml` | -| Angular | `angular.json` | -| NestJS | `nest-cli.json` | +| Type | Detected by | +| ---- | ----------- | +| Tauri | `src-tauri/` directory exists | +| Flutter | `pubspec.yaml` exists | +| Swift | `*.xcodeproj`, `*.xcworkspace`, or `Package.swift` | +| Rust | `Cargo.toml` | +| Angular | `angular.json` | +| NestJS | `nest-cli.json` | | Next.js | `next.config.ts`, `next.config.js`, or `next.config.mjs` | -| Bun | `bunfig.toml` | -| Node | `package.json` | -| Python | `pyproject.toml`, `setup.py`, or `requirements.txt` | -| unknown | (none of the above matched) | +| Bun | `bunfig.toml` | +| Node | `package.json` | +| Python | `pyproject.toml`, `setup.py`, or `requirements.txt` | +| unknown | none of the above | -A directory is recognized as a project at all if it contains any of the above markers, a `.git` directory, or markers from your custom types. If none of these are present, the directory is treated as a container (like `Personal/Ongoing/`) and `wd` recurses into it looking for projects. +A directory is recognized as a project at all if it contains any built-in marker, a `.git` directory, or markers from your custom types. Otherwise, `wd` treats it as a container directory and keeps recursing until `maxDepth` is reached. -This is why `maxDepth` matters: a directory like `Personal/Ongoing/my-project/` needs at least depth 3 to be found when scanning from `Personal/`. +This is why `maxDepth` matters. A project at `Personal/Ongoing/my-project/` needs a scan depth of at least `3` when scanning from `Personal/`. -**Custom types** are checked after all built-in rules, so a project that contains both `manage.py` and `package.json` will be classified as `Node` (since `package.json` is a built-in marker), not `Django`. To change this behavior, you would need to remove the project from the Node category by ensuring it does not match any higher-priority rule — which is not currently possible to override on a per-project basis. +Custom types are checked after built-in types, so they cannot override a built-in match. --- @@ -583,57 +956,83 @@ The shell integration is not active. Make sure your `~/.zshrc` contains: source ~/.config/wd/wd.zsh ``` -And that you have reloaded your shell (`source ~/.zshrc` or open a new tab). +Then reload your shell. **`wd-bin: command not found`** -The binary is not on your PATH. Check that `~/.local/bin` is in your `$PATH` and that the symlink exists: +The binary is not on your `PATH`. Check that your symlink exists: ```sh ls -la ~/.local/bin/wd-bin ``` -If missing, re-run the symlink step from the installation instructions. - **`wd is not configured yet`** -You need to run `wd setup` first to create the config file. +Run `wd setup` first. `wd config` and other config-backed commands expect `~/.config/wd/config.json` to exist. **Projects are missing from the list** -Run `wd scan` to refresh the cache. If projects are still missing, check: +Run `wd scan` and then check: + +- is the directory inside a configured scan root? +- does the project contain a recognized marker file? +- is one of its parent directories filtered by `scanIgnore`? +- is `maxDepth` high enough? + +**`wd new` says no templates were found** + +Check: + +- whether your configured template URL is reachable +- whether your local templates in `~/.config/wd/templates/` are valid JSON +- whether all available templates are marked `hidden: true` + +You can reset the remote source from `wd config` under `Templates URL`. + +**`wd new` says the template cache is stale** + +This means the remote template source could not be refreshed and `wd` fell back to the last cached copy. That is safe for normal use, but you may be missing newer templates until the source becomes reachable again. + +Use `wd new --raw` after connectivity is restored to force a refresh. + +**Template URL testing fails for `file://` paths** + +For local template sources: + +- the path must be absolute +- the path must not contain `..` +- the file must exist +- the JSON must be either an array of templates or an object with a `templates` array + +**Workspace name already exists** -- Is the directory inside a configured scan root? -- Does the project have any marker files (`package.json`, `pubspec.yaml`, etc.)? -- Is the directory name in `scanIgnore`? -- Is `maxDepth` deep enough for your directory structure? +Workspace names must be unique. Use `wd ws list` to see existing names, then choose another name or duplicate and rename from there. -**The drive is not mounted** +**Unknown command or unknown workspace command** -If your projects live on an external drive that is not currently mounted, `wd` will warn you during scan but continue with any other accessible roots. The cached entries from the unmounted drive remain in the list but will fail with an error if you try to navigate to them. +Recent versions of `wd` print a helpful command list instead of silently falling back to the main selector. If you see this, double-check the command spelling and available subcommands. **Docker containers are not starting** -- Make sure OrbStack or Docker Desktop is running before you run `wd open` -- Check that the container names in your workspace match the actual container names (`docker ps -a`) -- Container names can change when you recreate them — update the workspace with `wd ws edit ` to reselect the right containers -- If a container fails due to a port conflict, `wd` will detect this and offer to stop the blocking container interactively +- make sure OrbStack or Docker Desktop is running +- make sure the container names in the workspace still match `docker ps -a` +- if a container fails because of a port conflict, `wd` can offer to stop the blocking container and retry **Tabs are not opening** -- `wd` uses AppleScript (`osascript`) to open tabs — macOS will prompt for Automation permission the first time. Allow it in System Settings → Privacy & Security → Automation. -- Ghostty and Warp use keystroke simulation rather than a native API. This is less reliable if the terminal loses focus during the operation. If tabs are not appearing, try keeping the terminal focused while `wd open` runs. -- Tabs are only opened if at least one project in the workspace has a `tabs` array with entries. +- `wd` uses AppleScript and macOS Automation permissions for tab opening +- Ghostty and Warp rely on keystroke simulation, which is less reliable if the terminal loses focus +- tabs only open for workspace projects that actually have tab entries configured -**`wd open` changed directory but my editor is still in the old place** +**`wd open` changed directory but my editor stayed in the old place** -`wd open` only changes the directory in the terminal tab where you ran it. If you want your editor to open the project, open it manually after navigating (`cursor .`, `code .`, etc.). +`wd open` only changes the terminal shell directory. Open your editor manually in the new project if needed. **The project list is slow to appear** -The cache may be rebuilding. This happens when: +The cache may be rebuilding because: -- The cache is older than 24 hours -- The cache file does not exist +- the cache is older than 24 hours +- the cache file does not exist -If rebuilding consistently takes more than a second or two, check if any of your scan roots contain very large numbers of directories. Add commonly large directories like `node_modules` to `scanIgnore` (it is there by default, but nested `node_modules` inside unusual structures might not be caught). +If this keeps happening, reduce scan size by lowering `maxDepth` or adding more names to `scanIgnore`. diff --git a/package.json b/package.json index 9374751..e455b41 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "wd-cli", - "version": "1.2.1", + "version": "1.3.0", "private": true, "description": "Workspace Director - fast project navigation CLI", "type": "module", "scripts": { "dev": "bun run src/index.ts", "build": "bun build src/index.ts --compile --outfile dist/wd-bin", - "test": "bun test" + "test": "bun test src/", + "test:integration": "bun test integration/template-integration.test.ts" }, "dependencies": { "@inquirer/prompts": "^7.0.0", 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/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..5368901 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,714 @@ +import { select, input, confirm, number, editor } from "@inquirer/prompts"; +import { actionSelect, ESCAPE_VALUE } from "../ui/action-select"; +import { requireConfig, saveConfig } from "../config/manager"; +import type { Config, CustomType, CustomTypeColor } from "../config/schema"; +import { CustomTypeColorSchema, TemplateSchema } from "../config/schema"; +import { + bold, + green, + yellow, + red, + gray, + cyan, + blue, + magenta, + clearScreen, + printHeader, + Spinner, +} from "../ui/format"; +import { gracefulRun } from "../utils/prompt-wrapper"; +import { addScanRoot } from "../utils/scan-root-prompt"; +import { TEMPLATES_SOURCE_URL } from "../core/templates"; + +// ─── Color helpers ──────────────────────────────────────────────────────────── + +const COLOR_FN_MAP: Record string> = { + cyan, + green, + yellow, + blue, + magenta, + red, + gray, + white: (s) => s, +}; + +function colorize(color: CustomTypeColor, s: string): string { + return (COLOR_FN_MAP[color] ?? cyan)(s); +} + +// ─── Escape check ───────────────────────────────────────────────────────────── + +function isEscape(v: string): boolean { + return v === ESCAPE_VALUE; +} + +// ─── Template URL test ──────────────────────────────────────────────────────── + +interface TestResult { + ok: boolean; + count?: number; + error?: string; +} + +async function testTemplateUrl(url: string): Promise { + try { + let raw: unknown; + + if (url.startsWith("file://")) { + const filePath = url.slice("file://".length); + // Safety: must be an absolute path with no traversal segments + if (!filePath.startsWith("/") || filePath.split("/").includes("..")) { + return { ok: false, error: `Invalid file path: must be absolute with no '..' segments` }; + } + const file = Bun.file(filePath); + if (!(await file.exists())) { + return { ok: false, error: `File not found: ${filePath}` }; + } + raw = await file.json(); + } else { + const resp = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { + return { ok: false, error: `HTTP ${resp.status} ${resp.statusText}` }; + } + raw = await resp.json(); + } + + const data = Array.isArray(raw) + ? raw + : ((raw as { templates?: unknown[] }).templates ?? []); + + let validCount = 0; + const errors: string[] = []; + + for (const entry of data) { + const result = TemplateSchema.safeParse(entry); + if (result.success) { + validCount++; + } else { + const id = (entry as { id?: string }).id ?? "unknown"; + errors.push( + ` ${gray("·")} ${id}: ${result.error.issues[0]?.message ?? "invalid"}`, + ); + } + } + + if (validCount === 0 && errors.length === 0) { + return { ok: false, error: "No templates found (empty array)" }; + } + + if (validCount === 0) { + return { + ok: false, + error: `No valid templates found.\n${errors.slice(0, 3).join("\n")}`, + }; + } + + return { ok: true, count: validCount }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: msg }; + } +} + +// ─── Custom type prompt ─────────────────────────────────────────────────────── + +async function promptCustomType( + existing: CustomType | null, +): Promise { + const colorChoices = CustomTypeColorSchema.options.map((c) => ({ + name: colorize(c, c), + value: c as CustomTypeColor, + })); + + const name = await input({ + message: "Type name:", + default: existing?.name ?? "", + validate: (v) => (v.trim() ? true : "Name cannot be empty"), + }); + + const markersRaw = await input({ + message: "Marker files (comma-separated, e.g. manage.py, Gemfile):", + default: existing?.markers.join(", ") ?? "", + }); + + const patternsRaw = await input({ + message: "Filename patterns (comma-separated regex, e.g. ^Gemfile$):", + default: existing?.patterns.join(", ") ?? "", + validate: (v) => { + if (!v.trim()) return true; + for (const p of v + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + try { + new RegExp(p); + } catch { + return `Invalid regex: ${p}`; + } + } + return true; + }, + }); + + const markers = markersRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const patterns = patternsRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + if (markers.length === 0 && patterns.length === 0) { + console.log(` ${yellow("!")} At least one marker or pattern is required.`); + return null; + } + + const color = await select({ + message: "Display color:", + default: existing?.color ?? "cyan", + loop: false, + choices: colorChoices, + }); + + // Preview + console.log(); + console.log( + ` Preview: ${colorize(color, `[${name}]`)} markers: ${markers.join(", ") || gray("(none)")} patterns: ${patterns.join(", ") || gray("(none)")}`, + ); + console.log(); + + const save = await confirm({ message: "Save this type?", default: true }); + if (!save) return null; + + return { name, markers, patterns, color }; +} + +// ─── Section: Scan Roots ────────────────────────────────────────────────────── + +async function configureScanRoots(config: Config): Promise { + let running = true; + while (running) { + clearScreen(); + console.log(); + if (config.scanRoots.length > 0) { + console.log(bold("Scan roots:")); + config.scanRoots.forEach((r, i) => { + console.log( + ` ${gray(String(i + 1) + ".")} ${r.label ?? r.path} ${gray(r.path)}`, + ); + }); + } else { + console.log(gray(" No scan roots configured.")); + } + console.log(); + + const action = await actionSelect({ + message: "Scan roots:", + choices: [ + { name: "Add scan root", value: "add" }, + ...(config.scanRoots.length > 0 + ? [{ name: "Remove scan root", value: "remove" }] + : []), + { name: "Back", value: "back" }, + ], + }); + + if (isEscape(action) || action === "back") { + running = false; + } else if (action === "add") { + const root = await addScanRoot(config.scanRoots); + if (root) { + config.scanRoots.push(root); + await saveConfig(config); + console.log(` ${green("✓")} Added: ${root.label} (${root.path})`); + } + } else if (action === "remove") { + const choices = [ + ...config.scanRoots.map((r, i) => ({ + name: `${r.label ?? r.path} ${gray(r.path)}`, + value: String(i), + })), + { name: "Cancel", value: "cancel" }, + ]; + const result = await actionSelect({ + message: "Which root to remove?", + choices, + }); + if (!isEscape(result) && result !== "cancel") { + const idx = parseInt(result); + const removed = config.scanRoots.splice(idx, 1)[0]; + await saveConfig(config); + console.log( + ` ${yellow("✓")} Removed: ${removed?.label ?? removed?.path}`, + ); + } + } + } +} + +// ─── Section: Custom Types ──────────────────────────────────────────────────── + +async function configureCustomTypes(config: Config): Promise { + let running = true; + while (running) { + clearScreen(); + console.log(); + if (config.customTypes.length > 0) { + console.log(bold("Custom types:")); + config.customTypes.forEach((t, i) => { + console.log( + ` ${gray(String(i + 1) + ".")} ${colorize(t.color, t.name)} ${gray(t.markers.join(", ") || "(no markers)")}`, + ); + }); + } else { + console.log(gray(" No custom types configured.")); + } + console.log(); + + const action = await actionSelect({ + message: "Custom types:", + choices: [ + { name: "Add type", value: "add" }, + ...(config.customTypes.length > 0 + ? [ + { name: "Edit type", value: "edit" }, + { name: "Remove type", value: "remove" }, + ] + : []), + { name: "Back", value: "back" }, + ], + }); + + if (isEscape(action) || action === "back") { + running = false; + } else if (action === "add") { + const newType = await promptCustomType(null); + if (newType) { + config.customTypes.push(newType); + await saveConfig(config); + console.log( + ` ${green("✓")} Added: ${colorize(newType.color, newType.name)}`, + ); + } + } else if (action === "edit") { + const choices = [ + ...config.customTypes.map((t, i) => ({ + name: `${colorize(t.color, t.name)} ${gray(t.markers.join(", ") || "(no markers)")}`, + value: String(i), + })), + { name: "Cancel", value: "cancel" }, + ]; + const result = await actionSelect({ message: "Which type to edit?", choices }); + if (!isEscape(result) && result !== "cancel") { + const idx = parseInt(result); + const updated = await promptCustomType(config.customTypes[idx]!); + if (updated) { + config.customTypes[idx] = updated; + await saveConfig(config); + console.log( + ` ${green("✓")} Updated: ${colorize(updated.color, updated.name)}`, + ); + } + } + } else if (action === "remove") { + const choices = [ + ...config.customTypes.map((t, i) => ({ + name: `${colorize(t.color, t.name)} ${gray(t.markers.join(", ") || "(no markers)")}`, + value: String(i), + })), + { name: "Cancel", value: "cancel" }, + ]; + const result = await actionSelect({ message: "Which type to remove?", choices }); + if (!isEscape(result) && result !== "cancel") { + const idx = parseInt(result); + const removed = config.customTypes.splice(idx, 1)[0]; + await saveConfig(config); + console.log(` ${yellow("✓")} Removed: ${removed?.name}`); + } + } + } +} + +// ─── Section: boolean toggle (reusable) ────────────────────────────────────── + +async function configureBoolToggle(opts: { + label: string; + description: string; + current: boolean; + onSave: (val: boolean) => Promise; +}): Promise { + const { label, description, current, onSave } = opts; + + console.log(); + console.log(gray(description)); + console.log(); + + const action = await actionSelect({ + message: label, + choices: [ + { + name: `Enable${current ? ` ${green("← current")}` : ""}`, + value: "on", + }, + { + name: `Disable${!current ? ` ${red("← current")}` : ""}`, + value: "off", + }, + { name: "Back", value: "back" }, + ], + }); + + if (isEscape(action) || action === "back") return; + + const newValue = action === "on"; + if (newValue !== current) { + await onSave(newValue); + console.log( + ` ${green("✓")} Set to ${newValue ? green("ON") : red("OFF")}`, + ); + } +} + +// ─── Section: showProjectType toggle ───────────────────────────────────────── + +async function configureShowProjectType(config: Config): Promise { + await configureBoolToggle({ + label: "Show project type badge", + description: + "Display the [TypeName] badge next to projects in the selector.", + current: config.preferences.showProjectType, + onSave: async (val) => { + config.preferences.showProjectType = val; + await saveConfig(config); + }, + }); +} + +// ─── Section: showCategory toggle ───────────────────────────────────────────── + +async function configureShowCategory(config: Config): Promise { + await configureBoolToggle({ + label: "Show category", + description: + "Display the category/label column next to projects in the selector.", + current: config.preferences.showCategory, + onSave: async (val) => { + config.preferences.showCategory = val; + await saveConfig(config); + }, + }); +} + +// ─── Section: maxRecent ─────────────────────────────────────────────────────── + +async function configureMaxRecent(config: Config): Promise { + const current = config.preferences.maxRecent; + + const action = await actionSelect({ + message: "Max recent projects", + choices: [ + { + name: `Edit ${gray("(current: " + String(current) + ")")}`, + value: "edit", + }, + { name: "Back", value: "back" }, + ], + }); + + if (isEscape(action) || action === "back") return; + + console.log(); + console.log( + gray("Number of recently visited projects to keep in frecency history."), + ); + console.log(); + + const newValue = await number({ + message: "Max recent (1-50):", + default: current, + min: 1, + max: 50, + }); + + const resolved = newValue ?? current; + if (resolved !== current) { + config.preferences.maxRecent = resolved; + await saveConfig(config); + console.log(` ${green("✓")} Set to ${cyan(String(resolved))}`); + } +} + +// ─── Section: scanIgnore ────────────────────────────────────────────────────── + +async function applyScanIgnoreFromEditor( + config: Config, + current: string[], +): Promise { + const edited = await editor({ + message: "Edit ignore list (one entry per line):", + default: current.join("\n"), + }); + + const newEntries = edited + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + + if (newEntries.length === 0) { + console.log( + ` ${yellow("!")} Cannot save empty ignore list. Changes discarded.`, + ); + return; + } + + const added = newEntries.filter((e) => !current.includes(e)); + const removed = current.filter((e) => !newEntries.includes(e)); + + if (added.length === 0 && removed.length === 0) { + console.log(gray(" No changes.")); + return; + } + + console.log(); + if (added.length > 0) { + added.forEach((e) => console.log(` ${green("+")} ${e}`)); + } + if (removed.length > 0) { + removed.forEach((e) => console.log(` ${red("-")} ${e}`)); + } + console.log(); + + const apply = await confirm({ message: "Apply changes?", default: true }); + if (apply) { + config.preferences.scanIgnore = newEntries; + await saveConfig(config); + console.log(` ${green("✓")} Saved (${newEntries.length} entries)`); + } +} + +async function configureScanIgnore(config: Config): Promise { + let running = true; + while (running) { + clearScreen(); + const current = config.preferences.scanIgnore; + + console.log(); + console.log(bold("Scan ignore list")); + console.log(gray("Directories and files to skip during project scanning.")); + console.log(); + + if (current.length > 0) { + current.forEach((e, i) => + console.log(` ${gray(String(i + 1) + ".")} ${e}`), + ); + } else { + console.log(gray(" (empty)")); + } + console.log(); + + const action = await actionSelect({ + message: "Scan ignore list:", + choices: [ + { name: "Add entry", value: "add" }, + ...(current.length > 0 + ? [{ name: "Remove entry", value: "remove" }] + : []), + { name: "Back", value: "back" }, + ], + shortcut: { key: "e", value: "editor", label: "open in editor" }, + }); + + if (isEscape(action) || action === "back") { + running = false; + } else if (action === "add") { + const entry = await input({ + message: "Entry name to ignore:", + validate: (v) => { + if (!v.trim()) return "Cannot be empty"; + if (current.includes(v.trim())) return "Already in list"; + return true; + }, + }); + config.preferences.scanIgnore.push(entry.trim()); + await saveConfig(config); + console.log(` ${green("✓")} Added: ${entry.trim()}`); + } else if (action === "remove") { + const choices = [ + ...current.map((e, i) => ({ name: e, value: String(i) })), + { name: "Cancel", value: "cancel" }, + ]; + const result = await actionSelect({ message: "Which entry to remove?", choices }); + if (!isEscape(result) && result !== "cancel") { + const idx = parseInt(result); + const removed = config.preferences.scanIgnore.splice(idx, 1)[0]; + await saveConfig(config); + console.log(` ${yellow("✓")} Removed: ${removed}`); + } + } else if (action === "editor") { + await applyScanIgnoreFromEditor(config, [...config.preferences.scanIgnore]); + } + } +} + +// ─── Section: Templates URL ─────────────────────────────────────────────────── + +async function runTemplatesTest(url: string): Promise { + const spinner = new Spinner(`Testing ${gray(url)} ...`); + spinner.start(); + const result = await testTemplateUrl(url); + spinner.stop(); + + if (result.ok) { + console.log( + ` ${green("✓")} Found ${bold(String(result.count))} valid templates`, + ); + } else { + console.log(` ${red("✗")} ${result.error ?? "Unknown error"}`); + } + return result; +} + +async function configureTemplatesUrl(config: Config): Promise { + let running = true; + while (running) { + clearScreen(); + const currentUrl = config.projectConstructor.templates.gistUrl; + const isDefault = !currentUrl; + + console.log(); + console.log(bold("Templates URL")); + console.log( + gray( + "URL for fetching built-in project templates. Supports https:// and file://.", + ), + ); + console.log(`Current: ${isDefault ? gray("(default)") : cyan(currentUrl)}`); + if (isDefault) { + console.log(gray(`Default: ${TEMPLATES_SOURCE_URL}`)); + } + console.log(); + + const action = await actionSelect({ + message: "Templates URL:", + choices: [ + { name: "Set custom URL", value: "set" }, + ...(isDefault + ? [] + : [ + { name: "Test current URL", value: "test" }, + { name: "Reset to default", value: "reset" }, + ]), + { name: "Back", value: "back" }, + ], + }); + + if (isEscape(action) || action === "back") { + running = false; + } else if (action === "set") { + const newUrl = await input({ + message: "Templates URL:", + default: currentUrl || "", + validate: (v) => { + if (!v.trim()) return "URL cannot be empty"; + if (!v.startsWith("https://") && !v.startsWith("file://")) { + return "URL must start with https:// or file://"; + } + return true; + }, + }); + + console.log(); + const result = await runTemplatesTest(newUrl); + console.log(); + + const savePrompt = result.ok + ? await confirm({ message: "Save this URL?", default: true }) + : await confirm({ + message: "Test failed. Save anyway?", + default: false, + }); + + if (savePrompt) { + config.projectConstructor.templates.gistUrl = newUrl; + await saveConfig(config); + console.log(` ${green("✓")} Saved`); + } + } else if (action === "test") { + console.log(); + await runTemplatesTest(currentUrl); + } else if (action === "reset") { + const doReset = await confirm({ + message: "Reset templates URL to default?", + default: true, + }); + if (doReset) { + config.projectConstructor.templates.gistUrl = ""; + await saveConfig(config); + console.log(` ${green("✓")} Reset to default`); + } + } + } +} + +// ─── Main config command ────────────────────────────────────────────────────── + +export async function config(): Promise { + await gracefulRun(_config); +} + +async function _config(): Promise { + const cfg = await requireConfig(); + + let running = true; + while (running) { + clearScreen(); + printHeader(); + + const section = await actionSelect({ + message: "What would you like to configure?", + choices: [ + { name: "Scan roots", value: "scanRoots" }, + { name: "Custom project types", value: "customTypes" }, + { name: "Show project type badge", value: "showProjectType" }, + { name: "Show category", value: "showCategory" }, + { name: "Max recent projects", value: "maxRecent" }, + { name: "Scan ignore list", value: "scanIgnore" }, + { name: "Templates URL", value: "templatesUrl" }, + { name: "Exit", value: "exit" }, + ], + }); + + if (isEscape(section) || section === "exit") { + running = false; + } else { + switch (section) { + case "scanRoots": + await configureScanRoots(cfg); + break; + case "customTypes": + await configureCustomTypes(cfg); + break; + case "showProjectType": + await configureShowProjectType(cfg); + break; + case "showCategory": + await configureShowCategory(cfg); + break; + case "maxRecent": + await configureMaxRecent(cfg); + break; + case "scanIgnore": + await configureScanIgnore(cfg); + break; + case "templatesUrl": + await configureTemplatesUrl(cfg); + break; + } + } + } +} diff --git a/src/commands/new.ts b/src/commands/new.ts new file mode 100644 index 0000000..93989a0 --- /dev/null +++ b/src/commands/new.ts @@ -0,0 +1,714 @@ +/** + * `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, basename } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { + loadTemplates, + TEMPLATES_SOURCE_URL, + 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 { filterDynamicFlagsForVariant } from "../core/new-flags"; +import { + requireConfig, + saveConfig, + loadHistory, + saveHistory, + loadCache, +} 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 { recordVisit } from "../core/frecency"; +import { pathExists } from "../utils/fs"; +import directorySearch from "../ui/directory-search"; + +// ─── Tips shown randomly on each wizard run ─────────────────────────────────── + +const TIPS = [ + `Use ${bold("-t ")} to skip template selection, e.g. ${gray("wd new -t nextjs")}`, + `Use ${bold("-v ")} to pre-select a variant, e.g. ${gray("wd new -t nextjs -v shadcn")}`, + `Use ${bold("--pm")} or ${bold("--package-manager")} to set the package manager, e.g. ${gray("--pm bun")}`, + `Use ${bold("--dir ")} to skip the directory picker and go straight to creation`, + `Use ${bold("--dry-run")} to preview the create command without writing any files`, + `Use ${bold("--verbose")} to stream live output instead of the spinner`, + `Use ${bold("--raw")} to force-refresh the template cache from the source`, + `Pass the project name as the first argument: ${gray("wd new my-app -t nextjs")}`, + `Dynamic template flags like ${bold("--base-color zinc")} pre-fill additional parameters`, + `Templates expose short aliases for dynamic flags, e.g. ${gray("-bc zinc")} instead of ${gray("--base-color zinc")}`, + `In the directory picker, type ${bold("@")} to fuzzy-search your configured scan roots`, + `In the directory picker, press ${bold("Tab")} to autocomplete the highlighted path`, + `Skip every prompt in one go: ${gray("wd new my-app -t nextjs -v shadcn -bc zinc --pm bun")}`, + `Choose ${bold("Edit")} in the summary prompt to go back and change any step`, + `If you type a new path in the directory picker, you'll be offered to add it as a scan root`, + `After creation the new project is automatically recorded in your frecency history`, +]; + +function randomTip(): string { + return TIPS[Math.floor(Math.random() * TIPS.length)]!; +} + +// ─── 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 newArgv = extractNewArgv(process.argv); + const cliArgs = parseNewArgs(newArgv); + + const config = await runPendingMigrations(); + const gistUrl = + config?.projectConstructor?.templates?.gistUrl || TEMPLATES_SOURCE_URL; + + 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(newArgv, templates); + + if (cliArgsFull.unknownFlags.length > 0) { + for (const flag of cliArgsFull.unknownFlags) { + console.warn(`${yellow("!")} Unknown flag ignored: ${gray(flag)}`); + } + console.warn( + `${gray(" Known flags: --template (-t), --variant (-v), --pm, --dir, --dry-run, --verbose, --raw")}`, + ); + } + + // ── Wizard loop ─────────────────────────────────────────────────────────── + // `prev` is null on the first run, set to the previous round's values on edit. + + let prev: WizardState | null = null; + const warnedIgnoredVariantFlags = new Set(); + + while (true) { + clearScreen(); + printHeader(); + console.log(`${bold("Project Constructor - BETA")}`); + console.log( + yellow( + "Project Constructor is currently in beta — you may encounter rough edges.\n", + ), + ); + console.log(`${gray("Tip:")} ${randomTip()}\n`); + + const isEdit = prev !== null; + + // ── Step 1: Project name ──────────────────────────────────────────────── + + let appName: string; + if (cliArgsFull.appName && !isEdit) { + const trimmed = cliArgsFull.appName.trim(); + if (!/^[a-z0-9\-_]+$/.test(trimmed)) { + console.error( + `${red("✗")} Invalid project name: ${bold(trimmed)}\n` + + ` Use only lowercase letters, numbers, hyphens, underscores.`, + ); + process.exit(1); + } + appName = trimmed; + 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\-_]+$/.test(v.trim())) + return "Use only lowercase 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