diff --git a/.commitlintrc.json b/.commitlintrc.json index d68dd54..40f9842 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -9,7 +9,7 @@ "scope-enum": [ 2, "always", - ["repo", "nvim", "wezterm", "tmux", "clrs", "swatch", "scripts", "shared", "global"] + ["repo", "nvim", "wezterm", "kitty", "tmux", "clrs", "swatch", "scripts", "shared", "global"] ], "scope-empty": [2, "never"], "subject-empty": [2, "never"], diff --git a/.github/workflows/code-checks.yml b/.github/workflows/code-checks.yml index cb829d6..e3927b9 100644 --- a/.github/workflows/code-checks.yml +++ b/.github/workflows/code-checks.yml @@ -48,6 +48,9 @@ jobs: node-version: 24 cache: pnpm + - name: Install LuaJIT + run: brew install luajit + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/AGENTS.md b/AGENTS.md index 4892733..627b96b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,34 +12,39 @@ - Root package scripts are the repository's public command surface. Keep them limited to repository-wide workflows and Forge-owned domains; do not add root aliases that only forward to one package's domain-specific command. -- Workspace packages expose the shared `ensure`, `build`, `lint:check`, - `lint:fix`, `typecheck`, and `test` scripts when applicable so root aggregation - discovers them automatically. `ensure` prepares missing external build inputs - without refreshing valid existing input. Package-specific maintenance commands - remain in that package and are invoked with +- Workspace packages expose `ensure`, `build`, and `sync` when applicable. Root + lifecycle commands aggregate those package-owned scripts. `ensure` + prepares missing external build inputs without refreshing valid existing + input. Package-specific maintenance commands remain in that package and are invoked with `pnpm --filter `. -- Packages must not install configuration directly into user directories. - Installation targets remain explicit in `forge.config.yaml`; do not auto-scan - `packages/`. Package-owned persistent runtime state lives under `~/.forge/`. +- Packages own syncing of their runtime artifacts into community-standard user + directories. `forge.config.yaml#modules` explicitly registers enabled + configuration manifests. Domain-relative sources resolve from the manifest + directory; Forge aggregates and applies their installation plans. - Swatch's built catalog is the sole Theme data source. Its persistent state - `~/.forge/swatch/current.json` stores only the selected Theme ID. `current` and - `current --json` initialize the catalog default only when that state is - missing; `current --path` never creates it, and `use` writes only an explicitly - validated selection. Invalid or dangling state must fail and can only be - repaired by an explicit valid selection. + `${XDG_STATE_HOME:-$HOME/.local/state}/swatch/current.json` stores only the + selected Theme ID. `current` and `current --json` initialize the catalog + default only when that state is missing; `current --path` never creates it, + and `use` writes only an explicitly validated selection. Invalid or dangling + state must fail and can only be repaired by an explicit valid selection. +- Swatch integrations consume Theme data through the public `swatch current +--json` interface and use `current --path` only to register state watchers. + They must not duplicate catalog/state validation. Runtime discovery must start + from the current shell startup files, discard inherited shell-derived paths, + and never cache discovered paths across consumer configuration reloads. - Workspace packages that produce CLIs are build-first: runtime commands use their complete `dist/` output and must not execute TypeScript sources directly. -- There is no CI workflow yet. Add one only when a real need appears. -- `forge.config.yaml` is the single source of truth for `brew`, `links`, and - `build` tasks. Do not hard-code new targets inside scripts. Config sources - live under `configs/`. +- `.github/workflows/code-checks.yml` runs the repository checks in CI. +- `forge.config.yaml` owns repository-wide Homebrew requirements and the explicit + config module registry. Config manifests own generic `sync` entries; packages + do not expose Forge-specific manifests. ## Scripts Layout - `scripts/` root holds only CLI entrypoints. Entrypoints are thin: parse args, call into lib/ or a domain folder, handle exit code. No business logic. -- Commands map 1:1 to entrypoint files (`pnpm sync` -> `scripts/sync.ts`). +- Commands map 1:1 to entrypoint files (`pnpm start` -> `scripts/start.ts`). - Shared infrastructure goes in `scripts/lib/` (config loading, path helpers). - Domain logic goes in a domain folder (`builders/`, future `doctor/`, etc.). @@ -47,19 +52,11 @@ - Use `pnpm install` if dependencies need refresh; the repo pins `pnpm@10.29.3` via `packageManager`. -- Use `pnpm start` for full local setup after dependencies are installed. It - runs `brew`, `build`, then `sync` and stops at the first failed step. -- Use `pnpm brew` to install Homebrew taps, formulae, and casks declared under - `brew:` in `forge.config.yaml`. The command is idempotent and skips already - installed entries. +- Use `pnpm start` for full local setup after dependencies are installed. It runs + `brew`, `build`, then `sync`, stopping at the first failed stage. +- Use `pnpm sync:preview` for a read-only link plan. - Do not use `pnpm setup`; that is pnpm's built-in global setup command, not this repo's flow. -- Use `pnpm ensure` to prepare and validate workspace build prerequisites. -- Use `pnpm build` to ensure prerequisites, build workspace packages, and then - run the builders declared under `build:` in `forge.config.yaml`. -- Use `pnpm sync:preview` to preview configured symlinks before applying them - with `pnpm sync`; do not use `pnpm link`, which is pnpm's built-in package - linking command. - Run `pnpm test` to run Forge tests and tests in workspace packages that define a `test` script. - Run `pnpm check` for full non-mutating verification: repository-wide linting, @@ -73,50 +70,34 @@ `pnpm --filter @forge/swatch schemes:check`; do not add a root forwarding alias for them. -## Build System - -- `scripts/build.ts` is a dispatcher: it looks up builders by name in an - explicit registry and calls them. Adding a builder means adding a file under - `scripts/builders/` and registering it in the registry. Builders with options - validate them with their own zod `optsSchema`. -- Config-level schema only validates base task shape (`builder`/`source`/ - `output`); each builder validates its own `opts`. -- Forge-generated regular files carry a `GENERATED_BY_FORGE` marker, and - `writeGeneratedFile` in `scripts/builders/shared.ts` only replaces regular - files that contain it. Never remove the marker or bypass this check. -- Unknown `{{ token }}` placeholders fail loudly; each templating builder - defines its own token set. -- The `zshenv` builder may initialize the configured machine-local environment - file when missing, but it must never overwrite or delete an existing one. -- The `zsh-runtime` builder explicitly links package-owned CLI and completion - artifacts into root runtime outputs. It derives `PATH` and `fpath` directories - from each declared output and writes the final `.zshrc` from explicitly - ordered sources; it must not scan workspace packages or assume fixed runtime - folders. -- Newly created runtime links use relative targets. An existing symlink is kept - when it resolves to the declared source, even if its stored target is absolute. - The generated runtime fragment contains build-time-resolved absolute discovery - paths and must be rebuilt after moving the repository. -- Zsh sources are split into explicitly ordered files under `configs/zsh/`. - The `zsh-runtime` builder assembles them into the Forge-owned - `dist/zsh/.zshrc`; it must not auto-scan the source directory. Persistent Zsh - data and completion dumps live together under `~/.forge/zsh`, leaving - `dist/zsh` disposable. - -## Sync Script +## Convergence - TypeScript scripts are run with `tsx`; do not add a build step just to execute them. -- Do not auto-scan `configs/` unless the repo gains a concrete convention for - targets. +- Modules are enabled only through `forge.config.yaml#modules`; never scan + directories for manifests or infer module identity and type from a path. +- Module names and normalized manifest paths must be globally unique. Modules + use repository-relative manifest paths and load in declaration order, but + conflicting outputs always fail rather than allowing later modules to override + earlier ones. +- `sync` is always an array. Entry names must be unique within a domain and final + targets globally unique. Forge merges every domain's entries into one plan + before writing anything. - The linker is intentionally conservative: already-correct symlinks are skipped, conflicting symlinks or real files/directories fail, and nothing is overwritten. -- Sync plans every configured link before writing. If any target conflicts, - `pnpm sync` must fail without creating any links from that plan. -- `pnpm sync:preview` reports conflicts without failing because local machines - may already have unmanaged dotfiles; `pnpm sync` is the command that must - fail on conflicts. +- Forge plans every configured link before writing. If any target conflicts, + `pnpm start` must fail without creating any links from that plan. +- Build only generates artifacts inside the repository; domains without + generated artifacts do not define a build step. Sync is the only lifecycle + that exposes repository content in user directories. +- Packages that produce CLIs sync commands and integrations through their own + lifecycle without root knowledge of package layouts. Root sync runs package + sync scripts before applying links from registered config manifests. +- Zsh uses `~/.zshenv` to set `ZDOTDIR` to + `${XDG_CONFIG_HOME:-$HOME/.config}/zsh`. History lives under XDG state and + completion data under XDG cache. User commands and completions use standard + XDG bin/data locations. - Keep `pnpm sync:preview` in the verification path when changing link targets. ## Formatting And Hooks @@ -134,5 +115,5 @@ YAML files are formatted with Prettier and staged Lua files with StyLua. - Husky `commit-msg` runs `pnpm exec commitlint --edit "$1"`. - Commit messages must use conventional types from `.commitlintrc.json` and a - non-empty scope. Allowed scopes are `repo`, `nvim`, `wezterm`, `tmux`, + non-empty scope. Allowed scopes are `repo`, `nvim`, `wezterm`, `kitty`, `tmux`, `clrs`, `swatch`, `scripts`, `shared`, and `global`. diff --git a/README.md b/README.md index d4aa67e..5a8b6d9 100644 --- a/README.md +++ b/README.md @@ -1,284 +1,121 @@ # Forge -Forge is my frontend-native development environment workspace. +Forge is my development-environment workspace. It keeps editable configuration +and workspace packages in one checkout, builds generated runtime files, and +links configuration into the paths used by local applications. -It is a personal repository for managing editor configuration, terminal setup, -shell environment, machine bootstrap tasks, and workflow tooling as maintainable -software. +## Model -## Why - -I am a frontend developer, so I want my development environment to be shaped with -the same habits I use in application work: - -- package-managed tooling -- scriptable workflows -- formatting and commit checks -- modular structure when it earns its place -- typed automation when shell scripts become too implicit - -Forge is not meant to be a pile of copied dotfiles. It is meant to grow into a -small, reproducible system for building and maintaining my own development -environment. - -## Current Status - -Forge is intentionally early-stage. - -The repository currently contains the automation engine, Homebrew bootstrap -support, the first Zsh and Starship config sources, and the initial -Swatch workspace package. More config sources and package functionality will be -added only as real migration needs appear. - -## Current Tooling - -- `pnpm@10.29.3` for workspace dependency management -- Husky for Git hooks -- Commitlint for commit message validation -- lint-staged for formatting staged files during pre-commit -- Prettier for JS, TypeScript, JSON, Markdown, and YAML formatting -- oxlint for TypeScript source, script, and test linting, configured by - `.oxlintrc.json` -- StyLua for Lua formatting -- TypeScript and tsx for local automation scripts - -## Repository Shape - -Current structure: +Forge has three setup stages: ```text -forge/ -├── packages/ -│ └── swatch/ -│ ├── scripts/ -│ ├── src/ -│ ├── test/ -│ ├── package.json -│ ├── tsconfig.build.json -│ ├── tsconfig.json -│ └── README.md -├── scripts/ -│ ├── builders/ -│ │ ├── shared.ts -│ │ ├── zsh-runtime.ts -│ │ ├── zshenv.ts -│ ├── lib/ -│ │ ├── colors.ts -│ │ ├── config.ts -│ │ └── paths.ts -│ ├── start.ts -│ ├── brew.ts -│ ├── build.ts -│ └── sync.ts -├── test/ -│ ├── sync.test.ts -│ └── zsh-runtime.test.ts -├── configs/ -│ ├── starship/ -│ └── zsh/ -├── forge.config.yaml -├── package.json -├── pnpm-lock.yaml -├── pnpm-workspace.yaml -└── README.md +pnpm start -> brew -> build -> sync ``` -`forge.config.yaml` declares what gets installed as `brew` packages, `links` -(symlink as-is), and `build` tasks (generated outputs and declared runtime -links). The repository root owns environment orchestration and workspace-wide -tooling; packages under `packages/` own their domain logic, runtime dependencies, -tests, and build outputs. +- `brew` converges Homebrew taps, formulae, and casks from `forge.config.yaml`. +- `build` ensures missing external inputs and generates package artifacts inside + the repository. Domains without generated artifacts do not need a build step. +- `sync` lets packages expose built artifacts in standard user locations, then + applies links from enabled configuration modules. -`packages/swatch` is a theme catalog CLI package. Its offline build processes a -previously fetched scheme cache and assembles a complete application under -`packages/swatch/dist/`. Its catalog is the sole source of normalized Theme data; -the `current` and `use` commands manage a selected Theme ID under -`~/.forge/swatch/current.json`, initializing the built catalog's `default-dark` -default only when that state file is missing. Invalid JSON, an invalid shape, or -a Theme ID absent from the catalog fails without changing the state and must be -repaired with an explicit valid `swatch use`. Consumers remain responsible for -transforming and applying the resulting palettes. Package-specific cache -maintenance commands remain scoped to the Swatch workspace. Forge installs its -CLI and generated Zsh completion through explicit links in `forge.config.yaml`. +Enabled configuration modules are explicitly registered in +`forge.config.yaml#modules`. Unregistered manifests have no effect. Packages own +their build, sync, and runtime state independently. -## Setup Workflow +## Setup -Run the full local setup: +Install dependencies and run the complete setup: ```sh +pnpm install pnpm start ``` -`pnpm start` runs `brew`, `build`, then `sync`. The build step first ensures -workspace package inputs, compiles the packages, and then runs the tasks declared -under `build:` in `forge.config.yaml`. Install local dependencies with -`pnpm install` before running the TypeScript entrypoint. - -Run only the Homebrew bootstrap step: +Preview links without writing them: ```sh -pnpm brew +pnpm sync:preview ``` -`pnpm brew` reads `brew.taps`, `brew.formulas`, and `brew.casks` from -`forge.config.yaml`, checks what is already installed, and installs only missing -entries. - -## Build vs Sync - -Forge installs config in three ways, declared in `forge.config.yaml`: - -- `brew` installs Homebrew taps, formulae, and casks idempotently. -- `sync` symlinks repo files that are usable as-is, so edits in the repo take - effect immediately. Example: `configs/starship/starship.toml` -> - `~/.config/starship.toml`. -- `build` runs explicit builders that generate managed files and expose declared - runtime artifacts. Example: `configs/zsh/zshenv.zsh` -> `~/.zshenv`, with - `ZDOTDIR` resolved at build time. - -`build` may write generated files under `dist/`. That directory is gitignored -and rebuilt on demand. - -### Build - -`pnpm build` first runs each workspace package's optional `ensure` script, then -runs package builds and dispatches the Forge tasks registered in -`scripts/build.ts`. An `ensure` script may obtain a missing external build input, -but must validate and preserve any input that already exists. Each Forge task -declares: +The linker skips correct symlinks and fails on regular files, directories, +incorrect symlinks, missing sources, or duplicate targets. It plans every link +before applying any change. -- `builder`: which builder handles it (`zsh-runtime` and `zshenv` are currently - available) -- `source`: the input, interpreted by the builder (a template file here) -- `output`: where the built file lands -- `opts`: builder-specific data. The `zshenv` builder takes `zdotdir` and - `localenv`; the `zsh-runtime` builder takes runtime entries and an ordered - `sources` list. +## Zsh -The `zsh-runtime` builder explicitly maps package-owned executables and -completions into root runtime outputs. Newly created links use relative targets -such as those stored by `dist/bin/swatch` and `dist/completions/_swatch`. The -builder derives their parent directories, injects the resolved absolute `PATH` -and `fpath` entries into the main Zsh source, and directly assembles the final -`.zshrc`. To expose another already-built CLI, declare its executable and -optional completion outputs in `forge.config.yaml`; its package must produce -those artifacts before the Forge builders run. +Forge links the editable Zsh configuration into standard locations: -Forge-generated regular files carry a `GENERATED_BY_FORGE` marker and may only -replace an existing regular file containing that marker. Runtime links use a -separate conservative rule: an existing output is accepted only when it is a -symlink that resolves to the declared source. - -The `zshenv` builder also initializes its configured `localenv` file with mode -`0600` when it is missing. This machine-local file is loaded by `~/.zshenv` and -is suitable for proxy settings, SDK paths, and other environment preferences. -Forge never overwrites or deletes it after creation. - -Zsh source is split across explicitly ordered files under `configs/zsh/`; the -main source receives runtime discovery paths derived from the declared CLI -outputs. Their assembly order is explicit in `forge.config.yaml`. The built -`dist/zsh/.zshrc` is Forge-owned and disposable. Persistent history and the -rebuildable completion dump both live under `~/.forge/zsh/`; Apple Terminal -sessions are disabled so they cannot write state into `dist/zsh/`. - -```sh -pnpm build +```text +configs/zsh/files/zshenv.zsh -> ~/.zshenv +configs/zsh/files/zshrc.zsh -> ~/.config/zsh/.zshrc +configs/zsh/files/keys.zsh -> ~/.config/zsh/keys.zsh +configs/zsh/files/tools.zsh -> ~/.config/zsh/tools.zsh +configs/zsh/files/fzf.zsh -> ~/.config/zsh/fzf.zsh ``` -### Sync +`~/.zshenv` sets `ZDOTDIR` to `${XDG_CONFIG_HOME:-$HOME/.config}/zsh` and sources +`${XDG_CONFIG_HOME:-$HOME/.config}/zsh-local/env.zsh` when present. `.zshrc` +contains the core interactive configuration, including completion, and loads focused key, tool, +and fzf configuration files. -Preview symlink changes before applying them: +History lives under `${XDG_STATE_HOME:-$HOME/.local/state}/zsh`; completion cache +lives under `${XDG_CACHE_HOME:-$HOME/.cache}/zsh`. NVM is initialized with its +official `--no-use` option and the stable `~/.nvm/current/bin` path, keeping Node +available without version resolution during every shell startup. -```sh -pnpm sync:preview -``` +## Swatch -Apply the configured symlinks: +`packages/swatch` builds a normalized Base24 Theme catalog and a JSON-first CLI. +Its catalog is the sole Theme data source. Persistent state stores only the +selected Theme ID at: -```sh -pnpm sync +```text +${XDG_STATE_HOME:-$HOME/.local/state}/swatch/current.json ``` -The linker is intentionally conservative: preview reports conflicts without -changing anything, apply skips links that are already correct, and apply fails -on any existing non-Forge target instead of overwriting files. Apply plans and -validates every configured link before writing, so one conflict cannot leave a -partially synchronized environment. - -## Planned Areas +`current` and `current --json` initialize the catalog default only when state is +missing. `current --path` never creates state. Invalid or dangling state fails +and can only be repaired by an explicit valid `swatch use` selection. -Forge will grow only as real needs appear. Likely areas include: +Swatch may own thin runtime adapters that expose its current Palette through a +consumer's APIs. Consumers remain responsible for mapping and applying that +Palette to their appearance configuration. -- editor configuration, especially Neovim -- terminal configuration, including WezTerm and tmux -- more builders for configs that need compilation -- Git and global developer defaults -- a doctor/validation command for the local environment -- migration tooling for adopting existing hand-written configs +Swatch syncs its command to `${XDG_BIN_HOME:-$HOME/.local/bin}`, its Zsh +completion to `${XDG_DATA_HOME:-$HOME/.local/share}/zsh/site-functions`, and its +WezTerm adapter below +`${XDG_DATA_HOME:-$HOME/.local/share}/swatch`. -## Design Principles - -- Prefer executable configuration over documentation-only intent. -- Keep the structure small until repeated work justifies a new abstraction. -- Prefer TypeScript for growing automation logic; keep shell for thin glue. -- Make setup reproducible across machines instead of relying on manual edits. -- Treat personal environment configuration as software that can be reviewed, - formatted, and evolved. +WezTerm stores its font selection at +`${XDG_STATE_HOME:-$HOME/.local/state}/wezterm/font.json`. ## Commands -### Daily Workflows - ```sh -pnpm install pnpm start +pnpm brew pnpm ensure pnpm build -pnpm check -pnpm fix -pnpm test -``` - -`pnpm start` is the single full-environment setup entrypoint. `pnpm ensure` -aggregates workspace build prerequisites and normally skips valid existing -inputs. `pnpm check` runs linting, type checking, and format checks without -modifying files. `pnpm fix` applies safe lint fixes and repository-wide -formatting; it cannot fix type errors or every lint error. `ensure`, `build`, and -`test` invoke matching package scripts recursively. `check` and `fix` cover -packages through aggregated lint and typecheck steps plus repository-wide -formatting. Root tests cover synchronization and the Zsh runtime builder; -workspace aggregation also runs Swatch's catalog, CLI, completion, state, cache, -scheme, and theme tests. - -### Focused Maintenance - -```sh -pnpm brew pnpm sync:preview pnpm sync - -pnpm lint:check -pnpm lint:fix -pnpm typecheck -pnpm format:check -pnpm format:fix +pnpm test +pnpm check +pnpm fix ``` -The focused quality commands are the individual steps composed by `check` and -`fix`; use them when diagnosing or repairing one class of issue. - -Package-specific commands stay in their package instead of receiving root -aliases. Invoke them with a workspace filter, for example: +Package-specific maintenance stays scoped to its workspace, for example: ```sh -pnpm --filter @forge/swatch schemes:fetch pnpm --filter @forge/swatch schemes:check ``` -Pre-commit formatting is handled by lint-staged, which only formats staged -files and re-stages the result. +`check` performs non-mutating lint, type, and formatting verification. `fix` +applies safe lint and formatting changes and then runs `check`. -## Roadmap +## Tooling -- Migrate the real `~/.config/nvim` content into the repo. -- Add a focused validation command for the local environment. +- `pnpm@10.29.3` for workspace management +- TypeScript and tsx for Forge automation +- Vitest for tests +- Oxlint, Prettier, and StyLua for code quality +- Husky, lint-staged, and Commitlint for commit checks diff --git a/configs/starship/starship.toml b/configs/starship/files/starship.toml similarity index 100% rename from configs/starship/starship.toml rename to configs/starship/files/starship.toml diff --git a/configs/starship/forge.yaml b/configs/starship/forge.yaml new file mode 100644 index 0000000..acfeb1b --- /dev/null +++ b/configs/starship/forge.yaml @@ -0,0 +1,4 @@ +sync: + - name: main + source: ./files/starship.toml + target: ~/.config/starship.toml diff --git a/configs/wezterm/files/font.lua b/configs/wezterm/files/font.lua new file mode 100644 index 0000000..2916c75 --- /dev/null +++ b/configs/wezterm/files/font.lua @@ -0,0 +1,266 @@ +local wezterm = require('wezterm') + +local M = {} +local act = wezterm.action +local xdg_state_home = os.getenv('XDG_STATE_HOME') +if not xdg_state_home or xdg_state_home == '' or xdg_state_home:sub(1, 1) ~= '/' then + xdg_state_home = wezterm.home_dir .. '/.local/state' +end +local state_directory = xdg_state_home .. '/wezterm' +local state_path = state_directory .. '/font.json' +local default_state = { + main = 'fira-code', + comment = 'jetbrains-mono', +} +local fonts = { + { + id = 'fira-code', + label = 'FiraCode Nerd Font', + family = 'FiraCode Nerd Font', + }, + { + id = 'jetbrains-mono', + label = 'JetBrainsMono Nerd Font', + family = 'JetBrainsMono Nerd Font', + }, +} + +local function font_by_id(id) + for _, font in ipairs(fonts) do + if font.id == id then + return font + end + end +end + +local function validate_state(state) + if type(state) ~= 'table' then + error('Font state must be a JSON object: ' .. state_path) + end + + for key in pairs(state) do + if key ~= 'main' and key ~= 'comment' then + error('Unknown field in font state: ' .. tostring(key)) + end + end + + for _, role in ipairs({ 'main', 'comment' }) do + if type(state[role]) ~= 'string' or not font_by_id(state[role]) then + error('Invalid ' .. role .. ' font ID in state: ' .. tostring(state[role])) + end + end + + return state +end + +local function read_state() + local file, open_error, error_code = io.open(state_path, 'r') + if not file then + if error_code == 2 then + return { + main = default_state.main, + comment = default_state.comment, + }, + false + end + error('Failed to read font state: ' .. state_path .. ': ' .. tostring(open_error)) + end + + local contents = file:read('*a') + file:close() + + local ok, state = pcall(wezterm.json_parse, contents) + if not ok then + error('Invalid JSON in font state: ' .. state_path .. ': ' .. tostring(state)) + end + + return validate_state(state), true +end + +local function create_state_directory() + local args + if wezterm.target_triple:find('windows') then + args = { + 'powershell.exe', + '-NoProfile', + '-NonInteractive', + '-Command', + 'New-Item -ItemType Directory -Force -LiteralPath $args[0] | Out-Null', + state_directory, + } + else + args = { '/bin/mkdir', '-p', state_directory } + end + + local success, _, stderr = wezterm.run_child_process(args) + if not success then + error('Failed to create font state directory: ' .. stderr) + end +end + +local function write_state(state, window) + validate_state(state) + create_state_directory() + + local temporary_path = state_path .. '.' .. tostring(window:window_id()) .. '.tmp' + os.remove(temporary_path) + + local file, open_error = io.open(temporary_path, 'w') + if not file then + error('Failed to open temporary font state: ' .. tostring(open_error)) + end + + local ok, write_error = file:write(wezterm.json_encode(state), '\n') + local close_ok, close_error = file:close() + if not ok or not close_ok then + os.remove(temporary_path) + error('Failed to write font state: ' .. tostring(write_error or close_error)) + end + + local renamed, rename_error = os.rename(temporary_path, state_path) + if not renamed and wezterm.target_triple:find('windows') then + os.remove(state_path) + renamed, rename_error = os.rename(temporary_path, state_path) + end + if not renamed then + os.remove(temporary_path) + error('Failed to replace font state: ' .. tostring(rename_error)) + end +end + +local function platform_settings() + if wezterm.target_triple:find('apple%-darwin$') then + return 12, { 'PingFang SC', 'Apple Color Emoji', 'Symbols Nerd Font Mono' } + end + if wezterm.target_triple:find('windows') then + return 10, { 'Microsoft YaHei', 'Segoe UI Emoji', 'Symbols Nerd Font Mono' } + end + return 12, { 'WenQuanYi Micro Hei', 'Symbols Nerd Font Mono' } +end + +local function font_with_fallback(font, fallback, weight, italic) + local candidates = { + { + family = font.family, + weight = weight, + italic = italic, + }, + } + for _, family in ipairs(fallback) do + table.insert(candidates, family) + end + return wezterm.font_with_fallback(candidates) +end + +local function apply_fonts(config, state) + local font_size, fallback = platform_settings() + local main = font_by_id(state.main) + local comment = font_by_id(state.comment) + + config.font = font_with_fallback(main, fallback, 'Regular', false) + config.font_size = font_size + config.line_height = 1.4 + config.font_rules = { + { + intensity = 'Bold', + italic = false, + font = font_with_fallback(main, fallback, 'Bold', false), + }, + { + intensity = 'Normal', + italic = true, + font = font_with_fallback(comment, fallback, 'Regular', true), + }, + { + intensity = 'Bold', + italic = true, + font = font_with_fallback(comment, fallback, 'Bold', true), + }, + { + intensity = 'Half', + italic = false, + font = font_with_fallback(main, fallback, 'Regular', false), + }, + { + intensity = 'Half', + italic = true, + font = font_with_fallback(comment, fallback, 'Regular', true), + }, + } +end + +local function report_error(window, message) + wezterm.log_error(message) + window:toast_notification('WezTerm font configuration', message, nil, 5000) +end + +local function select_font(role, current_state) + local choices = {} + for _, font in ipairs(fonts) do + local marker = font.id == current_state[role] and '[current] ' or '' + table.insert(choices, { + id = font.id, + label = marker .. font.label, + }) + end + + return act.InputSelector({ + title = 'Select ' .. role .. ' font', + description = 'Enter = select, Esc = cancel', + choices = choices, + action = wezterm.action_callback(function(window, _, id) + if not id then + return + end + + local ok, changed_or_error = pcall(function() + local state, exists = read_state() + if exists and state[role] == id then + return false + end + state[role] = id + write_state(state, window) + return true + end) + + if not ok then + report_error(window, tostring(changed_or_error)) + elseif changed_or_error then + wezterm.reload_configuration() + end + end), + }) +end + +local function configure_fonts(state) + local main = font_by_id(state.main) + local comment = font_by_id(state.comment) + + return act.InputSelector({ + title = 'Configure fonts', + description = 'Choose a font role, Esc = cancel', + choices = { + { id = 'main', label = 'Main ' .. main.label }, + { id = 'comment', label = 'Comment ' .. comment.label }, + }, + action = wezterm.action_callback(function(window, pane, role) + if role then + window:perform_action(select_font(role, state), pane) + end + end), + }) +end + +function M.setup(config) + local state = read_state() + apply_fonts(config, state) + + config.keys = config.keys or {} + table.insert(config.keys, { + key = 'F', + mods = 'LEADER|SHIFT', + action = configure_fonts(state), + }) +end + +return M diff --git a/configs/wezterm/files/keymaps.lua b/configs/wezterm/files/keymaps.lua new file mode 100644 index 0000000..554e799 --- /dev/null +++ b/configs/wezterm/files/keymaps.lua @@ -0,0 +1,86 @@ +local wezterm = require('wezterm') + +local M = {} +local act = wezterm.action +local direction_keys = { + { key = 'h', direction = 'Left' }, + { key = 'j', direction = 'Down' }, + { key = 'k', direction = 'Up' }, + { key = 'l', direction = 'Right' }, +} + +local function map(keys, key, mods, action) + table.insert(keys, { + key = key, + mods = mods, + action = action, + }) +end + +-- Let macOS Command+direction trigger Neovim's portable Alt+direction mappings. +local function apply_macos_key_adapters(keys) + if wezterm.target_triple:find('apple%-darwin$') then + for _, binding in ipairs(direction_keys) do + map(keys, binding.key, 'SUPER', act.SendKey({ key = binding.key, mods = 'ALT' })) + end + end +end + +-- Bind the shared direction keys to pane focus and continuous pane resizing. +local function apply_pane_keys(keys) + for _, binding in ipairs(direction_keys) do + map(keys, binding.key, 'LEADER', act.ActivatePaneDirection(binding.direction)) + map( + keys, + binding.key:upper(), + 'LEADER|SHIFT', + act.Multiple({ + act.AdjustPaneSize({ binding.direction, 3 }), + act.ActivateKeyTable({ + name = 'resize_pane', + one_shot = false, + timeout_milliseconds = 1000, + until_unknown = true, + }), + }) + ) + end +end + +-- Build the temporary key table used for repeated pane resizing. +local function resize_pane_key_table() + local keys = {} + + for _, binding in ipairs(direction_keys) do + map(keys, binding.key:upper(), 'SHIFT', act.AdjustPaneSize({ binding.direction, 3 })) + end + + map(keys, 'Escape', nil, act.PopKeyTable) + return keys +end + +function M.apply(config) + config.leader = { + key = ' ', + mods = 'SHIFT', + timeout_milliseconds = 1500, + } + + config.keys = config.keys or {} + config.key_tables = config.key_tables or {} + config.key_tables.resize_pane = resize_pane_key_table() + + -- stylua: ignore start + map(config.keys, 'y', 'LEADER', act.ActivateCopyMode) + map(config.keys, 'f', 'LEADER', act.Search({ CaseSensitiveString = '' })) + map(config.keys, 'c', 'LEADER', act.CloseCurrentPane({ confirm = true })) + map(config.keys, 'v', 'LEADER', act.SplitPane({ direction = 'Right', size = { Percent = 50 } })) + map(config.keys, 's', 'LEADER', act.SplitPane({ direction = 'Down', size = { Percent = 50 } })) + map(config.keys, 'z', 'LEADER', act.TogglePaneZoomState) + -- stylua: ignore end + + apply_macos_key_adapters(config.keys) + apply_pane_keys(config.keys) +end + +return M diff --git a/configs/wezterm/files/swatch.lua b/configs/wezterm/files/swatch.lua new file mode 100644 index 0000000..105cbae --- /dev/null +++ b/configs/wezterm/files/swatch.lua @@ -0,0 +1,8 @@ +local data_home = os.getenv('XDG_DATA_HOME') +if not data_home or data_home == '' or data_home:sub(1, 1) ~= '/' then + data_home = require('wezterm').home_dir .. '/.local/share' +end +package.path = data_home .. '/?.lua;' .. package.path + +-- Load the WezTerm adapter owned by Swatch. +return require('swatch.integrations.wezterm') diff --git a/configs/wezterm/files/tab/init.lua b/configs/wezterm/files/tab/init.lua new file mode 100644 index 0000000..4ae6de2 --- /dev/null +++ b/configs/wezterm/files/tab/init.lua @@ -0,0 +1,11 @@ +local keymap = require('tab.keymap') +local title = require('tab.title') + +local M = {} + +function M.setup(config) + keymap.apply(config) + title.register() +end + +return M diff --git a/configs/wezterm/files/tab/keymap.lua b/configs/wezterm/files/tab/keymap.lua new file mode 100644 index 0000000..dd9a430 --- /dev/null +++ b/configs/wezterm/files/tab/keymap.lua @@ -0,0 +1,70 @@ +local wezterm = require('wezterm') + +local M = {} + +local keys = { + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '0', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '*', + '(', + ')', +} + +function M.label_for(tab_index) + return keys[tab_index + 1] +end + +function M.apply(config) + config.keys = config.keys or {} + + table.insert(config.keys, { + key = 't', + mods = 'LEADER', + action = wezterm.action.SpawnTab('CurrentPaneDomain'), + }) + table.insert(config.keys, { + key = '[', + mods = 'LEADER', + action = wezterm.action.ActivateTabRelative(-1), + }) + table.insert(config.keys, { + key = ']', + mods = 'LEADER', + action = wezterm.action.ActivateTabRelative(1), + }) + table.insert(config.keys, { + key = 'LeftArrow', + mods = 'LEADER', + action = wezterm.action.MoveTabRelative(-1), + }) + table.insert(config.keys, { + key = 'RightArrow', + mods = 'LEADER', + action = wezterm.action.MoveTabRelative(1), + }) + + for tab_index, key in ipairs(keys) do + table.insert(config.keys, { + key = key, + mods = 'LEADER', + action = wezterm.action.ActivateTab(tab_index - 1), + }) + end +end + +return M diff --git a/configs/wezterm/files/tab/title.lua b/configs/wezterm/files/tab/title.lua new file mode 100644 index 0000000..42f3dc8 --- /dev/null +++ b/configs/wezterm/files/tab/title.lua @@ -0,0 +1,50 @@ +local keymap = require('tab.keymap') +local wezterm = require('wezterm') + +local M = {} +local MIN_WIDTH = 12 + +local function directory_name(path) + if path == wezterm.home_dir then + return '~' + end + + local normalized = path:gsub('/+$', '') + return normalized:match('([^/]+)$') or '/' +end + +local function title_for(tab) + local pane = tab.active_pane + local cwd = pane.current_working_dir + + if cwd and cwd.scheme == 'file' then + return directory_name(cwd.file_path) + end + + return pane.title +end + +local function label_for(tab) + local key = keymap.label_for(tab.tab_index) + + if key then + return key .. '. ' .. title_for(tab) + end + + return title_for(tab) +end + +local function align_left(title) + local width = wezterm.column_width(title) + local target_width = math.max(MIN_WIDTH, width + 2) + + return wezterm.pad_right(title, target_width) +end + +function M.register() + wezterm.on('format-tab-title', function(tab) + return align_left(label_for(tab)) + end) +end + +return M diff --git a/configs/wezterm/files/theme.lua b/configs/wezterm/files/theme.lua new file mode 100644 index 0000000..ae64e9e --- /dev/null +++ b/configs/wezterm/files/theme.lua @@ -0,0 +1,134 @@ +---@class WezTermColors +---@field foreground string +---@field background string +---@field cursor_bg string +---@field cursor_fg string +---@field cursor_border string +---@field selection_bg string +---@field selection_fg string +---@field ansi string[] +---@field brights string[] +---@field tab_bar WezTermTabBarColors + +---@class WezTermTabStyle +---@field bg_color string +---@field fg_color string + +---@class WezTermTabBarColors +---@field background string +---@field active_tab WezTermTabStyle +---@field inactive_tab WezTermTabStyle +---@field inactive_tab_hover WezTermTabStyle +---@field new_tab WezTermTabStyle +---@field new_tab_hover WezTermTabStyle +---@field inactive_tab_edge string + +---@class WezTermWindowFrame +---@field active_titlebar_bg string +---@field inactive_titlebar_bg string +---@field active_titlebar_fg string +---@field inactive_titlebar_fg string +---@field active_titlebar_border_bottom string +---@field inactive_titlebar_border_bottom string +---@field button_bg string +---@field button_fg string +---@field button_hover_bg string +---@field button_hover_fg string + +---@class WezTermTheme +---@field apply fun(config: table, palette: Base24Palette) +local M = {} + +---创建标签栏各个交互状态的颜色。 +---@param palette Base24Palette +---@return WezTermTabBarColors +local function create_tab_bar_colors(palette) + return { + background = palette.base01, + active_tab = { + bg_color = palette.base00, + fg_color = palette.base05, + }, + inactive_tab = { + bg_color = palette.base01, + fg_color = palette.base04, + }, + inactive_tab_hover = { + bg_color = palette.base00, + fg_color = palette.base05, + }, + new_tab = { + bg_color = palette.base01, + fg_color = palette.base04, + }, + new_tab_hover = { + bg_color = palette.base01, + fg_color = palette.base05, + }, + inactive_tab_edge = palette.base03, + } +end + +---创建 WezTerm 的终端和标签栏颜色。 +---@param palette Base24Palette +---@return WezTermColors +local function create_colors(palette) + return { + foreground = palette.base05, + background = palette.base00, + cursor_bg = palette.base05, + cursor_fg = palette.base00, + cursor_border = palette.base05, + selection_bg = palette.base02, + selection_fg = palette.base05, + ansi = { + palette.base00, + palette.base08, + palette.base0B, + palette.base0A, + palette.base0D, + palette.base0E, + palette.base0C, + palette.base05, + }, + brights = { + palette.base03, + palette.base12, + palette.base14, + palette.base13, + palette.base16, + palette.base17, + palette.base15, + palette.base07, + }, + tab_bar = create_tab_bar_colors(palette), + } +end + +---创建 Fancy 标签栏使用的窗口框架颜色。 +---@param palette Base24Palette +---@return WezTermWindowFrame +local function create_window_frame(palette) + return { + active_titlebar_bg = palette.base01, + inactive_titlebar_bg = palette.base01, + active_titlebar_fg = palette.base05, + inactive_titlebar_fg = palette.base04, + active_titlebar_border_bottom = palette.base02, + inactive_titlebar_border_bottom = palette.base02, + button_bg = palette.base01, + button_fg = palette.base04, + button_hover_bg = palette.base01, + button_hover_fg = palette.base05, + } +end + +---将 Base24 Palette 应用到 WezTerm 的全部颜色配置。 +---@param config table +---@param palette Base24Palette +function M.apply(config, palette) + config.colors = create_colors(palette) + config.window_frame = create_window_frame(palette) +end + +return M diff --git a/configs/wezterm/files/wezterm.lua b/configs/wezterm/files/wezterm.lua new file mode 100644 index 0000000..3b681ab --- /dev/null +++ b/configs/wezterm/files/wezterm.lua @@ -0,0 +1,31 @@ +local font = require('font') +local keymapping = require('keymaps') +local swatch = require('swatch') +local tab = require('tab') +local theme = require('theme') +local wezterm = require('wezterm') + +local config = wezterm.config_builder() + +font.setup(config) +keymapping.apply(config) +theme.apply(config, swatch.current_palette()) +tab.setup(config) + +config.front_end = 'WebGpu' +config.automatically_reload_config = true +config.adjust_window_size_when_changing_font_size = false +config.show_close_tab_button_in_tabs = true +config.window_decorations = 'INTEGRATED_BUTTONS|RESIZE' +config.window_content_alignment = { + horizontal = 'Center', + vertical = 'Center', +} +config.window_padding = { + left = '0.5cell', + right = '0.5cell', + top = 0, + bottom = 0, +} + +return config diff --git a/configs/wezterm/forge.yaml b/configs/wezterm/forge.yaml new file mode 100644 index 0000000..4239708 --- /dev/null +++ b/configs/wezterm/forge.yaml @@ -0,0 +1,4 @@ +sync: + - name: main + source: ./files + target: ~/.config/wezterm diff --git a/configs/zsh/aliases.zsh b/configs/zsh/aliases.zsh deleted file mode 100644 index 92d601c..0000000 --- a/configs/zsh/aliases.zsh +++ /dev/null @@ -1,3 +0,0 @@ -# Aliases -alias ls='ls -G' -alias ll='ls -lG' diff --git a/configs/zsh/completion.zsh b/configs/zsh/completion.zsh deleted file mode 100644 index 0a71d9a..0000000 --- a/configs/zsh/completion.zsh +++ /dev/null @@ -1,6 +0,0 @@ -# Completion -autoload -Uz compinit -compinit -d "$FORGE_ZSH_DATA_DIR/zcompdump" - -zstyle ':completion:*' menu select -zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' diff --git a/configs/zsh/extra.zsh b/configs/zsh/extra.zsh deleted file mode 100644 index 527a7d0..0000000 --- a/configs/zsh/extra.zsh +++ /dev/null @@ -1,9 +0,0 @@ -# NVM -export NVM_DIR="$HOME/.nvm" -[[ -s "$HOMEBREW_PREFIX/opt/nvm/nvm.sh" ]] && source "$HOMEBREW_PREFIX/opt/nvm/nvm.sh" - -# Smart directory navigation -eval "$(zoxide init zsh --cmd cd)" - -# Prompt -eval "$(starship init zsh)" diff --git a/configs/zsh/fzf.zsh b/configs/zsh/files/fzf.zsh similarity index 100% rename from configs/zsh/fzf.zsh rename to configs/zsh/files/fzf.zsh diff --git a/configs/zsh/files/keys.zsh b/configs/zsh/files/keys.zsh new file mode 100644 index 0000000..c0d5913 --- /dev/null +++ b/configs/zsh/files/keys.zsh @@ -0,0 +1,28 @@ +# Editing mode +export KEYTIMEOUT=1 +bindkey -v + +# Insert mode +bindkey -M viins '^?' backward-delete-char +bindkey -M viins '^H' backward-delete-char +bindkey -M viins '^U' backward-kill-line +bindkey -M viins '^W' backward-kill-word + +bindkey -M viins '^[[A' history-substring-search-up +bindkey -M viins '^[[B' history-substring-search-down + +# Command mode +bindkey -M vicmd 'j' undefined-key +bindkey -M vicmd 'k' undefined-key +bindkey -M vicmd '^N' down-history +bindkey -M vicmd '^P' up-history + +# Completion menu +# Keep Tab available to fzf while adding reverse and spatial menu navigation. +bindkey -M viins "$terminfo[kcbt]" reverse-menu-complete +bindkey -M menuselect '^I' menu-complete +bindkey -M menuselect "$terminfo[kcbt]" reverse-menu-complete +bindkey -M menuselect 'h' backward-char +bindkey -M menuselect 'j' down-line-or-history +bindkey -M menuselect 'k' up-line-or-history +bindkey -M menuselect 'l' forward-char diff --git a/configs/zsh/zinit.zsh b/configs/zsh/files/tools.zsh similarity index 56% rename from configs/zsh/zinit.zsh rename to configs/zsh/files/tools.zsh index 8afe91f..afe2702 100644 --- a/configs/zsh/zinit.zsh +++ b/configs/zsh/files/tools.zsh @@ -1,3 +1,20 @@ +# NVM +export NVM_DIR="$HOME/.nvm" +export NVM_SYMLINK_CURRENT=true +path=( + "$NVM_DIR/current/bin" + $path +) +[[ -s "$HOMEBREW_PREFIX/opt/nvm/nvm.sh" ]] && source "$HOMEBREW_PREFIX/opt/nvm/nvm.sh" --no-use + +# Smart directory navigation +eval "$(zoxide init zsh --cmd cd)" + +# Prompt +eval "$(starship init zsh)" + +source "$ZDOTDIR/fzf.zsh" + # Zinit plugins source "$HOMEBREW_PREFIX/opt/zinit/zinit.zsh" diff --git a/configs/zsh/files/zshenv.zsh b/configs/zsh/files/zshenv.zsh new file mode 100644 index 0000000..4d5e296 --- /dev/null +++ b/configs/zsh/files/zshenv.zsh @@ -0,0 +1,11 @@ +export SHELL_SESSIONS_DISABLE=1 + +export ZDOTDIR="${XDG_CONFIG_HOME:-$HOME/.config}/zsh" + +# Homebrew shellenv +eval "$(/opt/homebrew/bin/brew shellenv)" + +# Machine-local environment +typeset zsh_local_env="${XDG_CONFIG_HOME:-$HOME/.config}/zsh-local/env.zsh" +[[ -r "$zsh_local_env" ]] && source "$zsh_local_env" +unset zsh_local_env diff --git a/configs/zsh/files/zshrc.zsh b/configs/zsh/files/zshrc.zsh new file mode 100644 index 0000000..e273153 --- /dev/null +++ b/configs/zsh/files/zshrc.zsh @@ -0,0 +1,53 @@ +# History +typeset zsh_state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/zsh" +mkdir -p "$zsh_state_dir" + +HISTFILE="$zsh_state_dir/history" +HISTSIZE=100000 +SAVEHIST=100000 + +setopt APPEND_HISTORY +setopt SHARE_HISTORY +setopt HIST_FCNTL_LOCK +setopt HIST_IGNORE_ALL_DUPS +setopt HIST_IGNORE_SPACE +setopt HIST_EXPIRE_DUPS_FIRST +setopt HIST_FIND_NO_DUPS +setopt HIST_SAVE_NO_DUPS + +# Options +setopt AUTOCD +setopt NOBEEP +setopt NUMERIC_GLOB_SORT + +# Standard user command and completion paths. +typeset -U path fpath +path=( + "${XDG_BIN_HOME:-$HOME/.local/bin}" + $path +) +fpath=( + "${XDG_DATA_HOME:-$HOME/.local/share}/zsh/site-functions" + $fpath +) + +# Completion +autoload -Uz compinit +zmodload zsh/complist +zmodload zsh/terminfo +typeset zsh_cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/zsh" +mkdir -p "$zsh_cache_dir" +compinit -d "$zsh_cache_dir/zcompdump-$ZSH_VERSION" +unset zsh_cache_dir + +zstyle ':completion:*' menu select +zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' + +# Aliases +alias ls='ls -G' +alias ll='ls -lG' + +source "$ZDOTDIR/keys.zsh" +source "$ZDOTDIR/tools.zsh" + +unset zsh_state_dir diff --git a/configs/zsh/forge.yaml b/configs/zsh/forge.yaml new file mode 100644 index 0000000..75d3d2a --- /dev/null +++ b/configs/zsh/forge.yaml @@ -0,0 +1,16 @@ +sync: + - name: environment + source: ./files/zshenv.zsh + target: ~/.zshenv + - name: interactive + source: ./files/zshrc.zsh + target: ~/.config/zsh/.zshrc + - name: keys + source: ./files/keys.zsh + target: ~/.config/zsh/keys.zsh + - name: tools + source: ./files/tools.zsh + target: ~/.config/zsh/tools.zsh + - name: fzf + source: ./files/fzf.zsh + target: ~/.config/zsh/fzf.zsh diff --git a/configs/zsh/keybindings.zsh b/configs/zsh/keybindings.zsh deleted file mode 100644 index e40e2a1..0000000 --- a/configs/zsh/keybindings.zsh +++ /dev/null @@ -1,16 +0,0 @@ -# Keybindings -export KEYTIMEOUT=1 -bindkey -v - -bindkey -M viins '^?' backward-delete-char -bindkey -M viins '^H' backward-delete-char -bindkey -M viins '^U' backward-kill-line -bindkey -M viins '^W' backward-kill-word - -bindkey -M viins '^[[A' history-substring-search-up -bindkey -M viins '^[[B' history-substring-search-down - -bindkey -M vicmd 'j' undefined-key -bindkey -M vicmd 'k' undefined-key -bindkey -M vicmd '^N' down-history -bindkey -M vicmd '^P' up-history diff --git a/configs/zsh/zshenv.zsh b/configs/zsh/zshenv.zsh deleted file mode 100644 index 59c4224..0000000 --- a/configs/zsh/zshenv.zsh +++ /dev/null @@ -1,13 +0,0 @@ -# ZDOTDIR entry point: redirect zsh to the built config directory. -export ZDOTDIR="{{ zdotdir }}" - -# Keep machine-local Zsh data outside disposable Forge build output. -export FORGE_ZSH_DATA_DIR="$HOME/.forge/zsh" -export SHELL_SESSIONS_DISABLE=1 -mkdir -p "$FORGE_ZSH_DATA_DIR" - -# Homebrew shellenv -eval "$(/opt/homebrew/bin/brew shellenv)" - -# Machine-local environment -[[ -r {{ localenv }} ]] && source {{ localenv }} diff --git a/configs/zsh/zshrc.zsh b/configs/zsh/zshrc.zsh deleted file mode 100644 index 3f20f33..0000000 --- a/configs/zsh/zshrc.zsh +++ /dev/null @@ -1,31 +0,0 @@ -# History -HISTFILE="$FORGE_ZSH_DATA_DIR/history" -HISTSIZE=100000 -SAVEHIST=100000 - -setopt APPEND_HISTORY -setopt SHARE_HISTORY -setopt HIST_FCNTL_LOCK -setopt HIST_IGNORE_ALL_DUPS -setopt HIST_IGNORE_SPACE -setopt HIST_EXPIRE_DUPS_FIRST -setopt HIST_FIND_NO_DUPS -setopt HIST_SAVE_NO_DUPS - -# Options -setopt AUTOCD -setopt NOBEEP -setopt NUMERIC_GLOB_SORT - -# Forge-built command and completion discovery paths. -typeset -U path fpath - -path=( -{{ executable_dirs }} - $path -) - -fpath=( -{{ completion_dirs }} - $fpath -) diff --git a/forge.config.yaml b/forge.config.yaml index 50b47c0..2b17154 100644 --- a/forge.config.yaml +++ b/forge.config.yaml @@ -1,75 +1,38 @@ -# Forge configuration — the single source of truth for local setup. +# Forge-wide orchestration configuration. # # brew installs Homebrew taps, formulae, and casks. `pnpm brew` is idempotent: # already-installed entries are skipped, missing entries are installed. # -# links symlinks repo files into place as-is. Use it for config files that do -# not need template rendering, such as Starship's TOML config. -# -# build runs explicit builders that generate managed files or create declared -# runtime links. Generated files carry a GENERATED_BY_FORGE marker; runtime links -# must be absent or resolve to their declared source. -# Relative paths are resolved from the repo root; `~` resolves to the home dir. -# -# Zsh currently uses two build tasks: -# - zsh-runtime links the declared CLI and completion artifacts, then assembles -# the main template and ordered sources into ./dist/zsh/.zshrc. -# - zshenv writes ~/.zshenv, points ZDOTDIR at ./dist/zsh, and initializes the -# machine-local environment file only when it is missing. The generated -# zshenv keeps persistent Zsh data under ~/.forge/zsh. -# +# modules explicitly enables configuration domains. Workspace packages own +# their own build and deployment lifecycle. + brew: taps: - # Filesystem extensions - - gromgit/fuse # Additional macOS FUSE packages + - gromgit/fuse formulas: - # Shell experience - - starship # Fast, customizable cross-shell prompt - - zinit # Zsh plugin manager - - zoxide # Smarter cd command with directory ranking - # CLI productivity - - bat # cat alternative with syntax highlighting - - fd # Fast, user-friendly find alternative - - fzf # Interactive command-line fuzzy finder - - ripgrep # Fast recursive text search - # Development runtimes - - nvm # Install and switch between Node.js versions - # Editors - - neovim # Extensible terminal-based text editor + - starship + - zinit + - zoxide + - bat + - fd + - fzf + - ripgrep + - luajit + - nvm + - neovim casks: - # Filesystems - - macfuse # Filesystem extension framework for macOS - - sshfs-mac # Mount remote filesystems over SSH - # Developer fonts - - font-fira-code-nerd-font # Fira Code with icons and extra glyphs - - font-jetbrains-mono-nerd-font # JetBrains Mono with icons and extra glyphs -links: + - macfuse + - sshfs-mac + - font-fira-code-nerd-font + - font-jetbrains-mono-nerd-font + +modules: - name: starship - source: ./configs/starship/starship.toml - target: ~/.config/starship.toml -build: - - builder: zsh-runtime - source: ./configs/zsh/zshrc.zsh - output: ./dist/zsh/.zshrc - opts: - executables: - - name: swatch - source: ./packages/swatch/dist/cli.js - output: ./dist/bin/swatch - completions: - - name: swatch completion - source: ./packages/swatch/dist/completions/_swatch - output: ./dist/completions/_swatch - sources: - - ./configs/zsh/completion.zsh - - ./configs/zsh/aliases.zsh - - ./configs/zsh/keybindings.zsh - - ./configs/zsh/extra.zsh - - ./configs/zsh/fzf.zsh - - ./configs/zsh/zinit.zsh - - builder: zshenv - source: ./configs/zsh/zshenv.zsh - output: ~/.zshenv - opts: - zdotdir: ./dist/zsh - localenv: ~/.local.env.zsh + type: config + manifest: ./configs/starship/forge.yaml + - name: wezterm + type: config + manifest: ./configs/wezterm/forge.yaml + - name: zsh + type: config + manifest: ./configs/zsh/forge.yaml diff --git a/package.json b/package.json index e3a3b94..e291f05 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,10 @@ "private": true, "type": "module", "scripts": { - "prepare": "husky", "start": "tsx scripts/start.ts", "brew": "tsx scripts/brew.ts", "ensure": "pnpm --recursive --if-present ensure", - "build": "pnpm ensure && pnpm --recursive --if-present build && tsx scripts/build.ts", + "build": "pnpm ensure && pnpm --recursive --if-present build", "sync": "tsx scripts/sync.ts", "sync:preview": "tsx scripts/sync.ts --preview", "check": "pnpm lint:check && pnpm typecheck && pnpm format:check", @@ -17,7 +16,8 @@ "typecheck": "tsc --noEmit && pnpm --recursive --if-present typecheck", "format:check": "prettier --check \"**/*.{js,mjs,ts,json,jsonc,md,yaml,yml}\" && stylua --check .", "format:fix": "prettier --write \"**/*.{js,mjs,ts,json,jsonc,md,yaml,yml}\" && stylua .", - "test": "vitest run && pnpm --recursive --if-present test" + "test": "vitest run && pnpm --recursive --if-present test", + "prepare": "husky" }, "lint-staged": { "*.{js,mjs,ts,json,jsonc,md,yaml,yml}": "prettier --write", @@ -29,7 +29,7 @@ "@johnnymorganz/stylua-bin": "2.5.2", "@types/node": "catalog:", "husky": "9.1.7", - "lint-staged": "17.2.0", + "lint-staged": "17.3.0", "oxlint": "catalog:", "prettier": "catalog:", "tsx": "catalog:", diff --git a/packages/swatch/README.md b/packages/swatch/README.md index 01ee1b4..44b4f30 100644 --- a/packages/swatch/README.md +++ b/packages/swatch/README.md @@ -10,16 +10,26 @@ transforming and applying themes to tools such as Neovim and tmux. Swatch does not write consumer configuration, reload applications, or provide consumer-specific export formats. +Swatch may own thin runtime adapters that expose its current Palette through a +consumer's APIs. Consumers remain responsible for mapping and applying that +Palette to their own appearance configuration. + +Adapters use `swatch current --json` as the Theme data interface and `swatch +current --path` for reload watchers. They do not read catalog/state internals or +cache Shell-derived runtime paths across consumer configuration reloads. + The package must be built before use. A build compiles the CLI and transforms cached Base16 schemes into a normalized Base24 catalog under `dist/`. ## Package Shape - `src/` contains the runtime CLI. +- `integrations/` contains thin consumer adapters for accessing Swatch data; + appearance policy remains in each consumer's configuration. - `src/catalog.ts` reads and validates the built manifest and themes without depending on the current working directory. - `src/state.ts` stores only the selected Theme ID in - `~/.forge/swatch/current.json`. + `${XDG_STATE_HOME:-$HOME/.local/state}/swatch/current.json`. - `scripts/` assembles the package and fetches, validates, and processes themes. - `test/` contains local upstream fixtures and catalog, CLI, completion, state, cache publication, scheme, and theme contract tests. @@ -39,6 +49,7 @@ Run package commands from the repository root through the workspace scope: pnpm --filter @forge/swatch start -- list pnpm --filter @forge/swatch ensure pnpm --filter @forge/swatch build +pnpm --filter @forge/swatch sync pnpm --filter @forge/swatch schemes:fetch pnpm --filter @forge/swatch schemes:check @@ -68,6 +79,12 @@ JSON file per theme plus `dist/catalog/manifest.json`. Every Theme includes its `dark` or `light` variant. The manifest records the source revision, the stable theme ID list, and the validated `default-dark` default. +The package `sync` script links the built CLI into +`${XDG_BIN_HOME:-$HOME/.local/bin}`, the Zsh completion into +`${XDG_DATA_HOME:-$HOME/.local/share}/zsh/site-functions`, and the WezTerm +adapter below `${XDG_DATA_HOME:-$HOME/.local/share}/swatch`. It plans +every artifact before writing and refuses unmanaged conflicts. + ## CLI Contract The runtime interface uses explicit subcommands: @@ -93,7 +110,8 @@ the network or `.cache/` source data. The catalog is the only source of Theme data and must be built before any state command can run. The first `swatch current` or `swatch current --json` creates -`~/.forge/swatch/current.json` with this minimal state: +`${XDG_STATE_HOME:-$HOME/.local/state}/swatch/current.json` with this minimal +state: ```json { @@ -150,8 +168,6 @@ The package build generates a static Zsh completion at from the same validated manifest, so completion does not start Node or access the network. -Forge exposes the package-owned CLI and completion under the root `dist/bin/` -and `dist/completions/` runtime directories. Newly created links use relative -targets. The generated Zsh configuration adds those build-time-resolved -directories to `PATH` and `fpath` before `compinit`. Swatch writes only its -current selection under the Forge-owned `~/.forge/swatch/` data directory. +The package syncs its CLI and completion to the standard user command and Zsh +completion directories. Newly created links use relative targets. Swatch writes +only its current selection under its XDG state directory. diff --git a/packages/swatch/integrations/wezterm.lua b/packages/swatch/integrations/wezterm.lua new file mode 100644 index 0000000..7d761f3 --- /dev/null +++ b/packages/swatch/integrations/wezterm.lua @@ -0,0 +1,149 @@ +local wezterm = require('wezterm') + +---@class SwatchRuntimeContext +---@field command_search_path string 交互式 Shell 初始化后的 PATH。 +---@field current_theme_state_file string Swatch 当前主题状态文件的绝对路径。 + +---@class Base24Palette +---@field base00 string +---@field base01 string +---@field base02 string +---@field base03 string +---@field base04 string +---@field base05 string +---@field base06 string +---@field base07 string +---@field base08 string +---@field base09 string +---@field base0A string +---@field base0B string +---@field base0C string +---@field base0D string +---@field base0E string +---@field base0F string +---@field base10 string +---@field base11 string +---@field base12 string +---@field base13 string +---@field base14 string +---@field base15 string +---@field base16 string +---@field base17 string + +---@class SwatchTheme +---@field id string +---@field palette Base24Palette + +---@class SwatchAdapter +---@field current_palette fun(): Base24Palette +local M = {} + +---移除命令输出首尾的空白字符。 +---@param value string +---@return string +local function trim(value) + return value:match('^%s*(.-)%s*$') or '' +end + +---同步执行子进程,并在命令失败时抛出包含 stderr 的配置错误。 +---@param command string[] 可执行文件及其参数。 +---@param operation string 用于错误信息的操作名称。 +---@return string stdout 移除首尾空白后的内容。 +local function execute_child_process(command, operation) + local succeeded, stdout, stderr = wezterm.run_child_process(command) + if not succeeded then + error(operation .. ' failed: ' .. trim(stderr)) + end + + return trim(stdout) +end + +---读取交互式 Shell 初始化后的 PATH,不复制其他 Shell 环境变量。 +---@return string command_search_path +local function read_interactive_shell_path() + local shell = os.getenv('SHELL') + if not shell or shell == '' then + error('Swatch initialization failed: SHELL is not set') + end + + -- WezTerm GUI 的 PATH 不等于窗格内 Shell 的 PATH。NUL 分隔可以避免 + -- Shell 初始化输出或环境变量中的换行干扰 PATH 的边界。清除父进程中 + -- 可能陈旧的 Shell 派生变量,让当前 ~/.zshenv 重新建立运行环境。 + local environment = execute_child_process({ + '/usr/bin/env', + '-u', + 'PATH', + '-u', + 'ZDOTDIR', + '-u', + 'NVM_BIN', + '-u', + 'NVM_INC', + shell, + '-ic', + 'exec /usr/bin/env -0', + }, 'Shell environment discovery') + local command_search_path = ('\0' .. environment):match('%zPATH=([^%z]*)%z') + if not command_search_path then + error('Swatch initialization failed: could not read PATH from the interactive Shell') + end + + return command_search_path +end + +---使用指定 PATH 按命令名执行 Swatch,不依赖 Forge 或 Node 的绝对路径。 +---@param command_search_path string +---@param ... string Swatch CLI 参数。 +---@return string stdout +local function execute_swatch_with_path(command_search_path, ...) + return execute_child_process( + { '/usr/bin/env', 'PATH=' .. command_search_path, 'swatch', ... }, + 'Swatch command' + ) +end + +---发现 Swatch 所需的 Shell PATH 和当前主题状态文件。 +---@return SwatchRuntimeContext +local function create_swatch_runtime_context() + local command_search_path = read_interactive_shell_path() + local current_theme_state_file = + execute_swatch_with_path(command_search_path, 'current', '--path') + + return { + command_search_path = command_search_path, + current_theme_state_file = current_theme_state_file, + } +end + +---使用已发现的 Shell PATH 执行 Swatch。 +---@param context SwatchRuntimeContext +---@param ... string Swatch CLI 参数。 +---@return string stdout +local function execute_swatch(context, ...) + return execute_swatch_with_path(context.command_search_path, ...) +end + +---读取并校验当前 Theme,同时注册状态文件以触发 WezTerm 配置重载。 +---@return SwatchTheme +local function load_current_theme() + local context = create_swatch_runtime_context() + + -- 每次配置求值都重新注册 watcher;状态文件变化后,WezTerm 会重新执行配置。 + wezterm.add_to_config_reload_watch_list(context.current_theme_state_file) + + local theme = wezterm.json_parse(execute_swatch(context, 'current', '--json')) + if type(theme) ~= 'table' or type(theme.palette) ~= 'table' then + error('Swatch returned an invalid Theme') + end + + ---@cast theme SwatchTheme + return theme +end + +---加载当前 Swatch Palette,并监听主题选择以触发 WezTerm 配置重载。 +---@return Base24Palette +function M.current_palette() + return load_current_theme().palette +end + +return M diff --git a/packages/swatch/package.json b/packages/swatch/package.json index 9209da0..1fa1b35 100644 --- a/packages/swatch/package.json +++ b/packages/swatch/package.json @@ -11,6 +11,7 @@ "start": "node dist/cli.js", "ensure": "tsx scripts/ensure.ts", "build": "tsx scripts/build.ts", + "sync": "tsx scripts/sync.ts", "schemes:fetch": "tsx scripts/schemes-fetch.ts", "schemes:check": "tsx scripts/schemes-check.ts", "lint:check": "oxlint src scripts test", diff --git a/packages/swatch/scripts/lib/build.ts b/packages/swatch/scripts/lib/build.ts index 652d408..f8bc5c4 100644 --- a/packages/swatch/scripts/lib/build.ts +++ b/packages/swatch/scripts/lib/build.ts @@ -1,7 +1,7 @@ // Compiles the CLI, validates the complete output, and replaces dist. import { spawnSync } from 'node:child_process'; -import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -27,10 +27,21 @@ export async function buildPackage(): Promise { await Promise.all([ chmod(path.join(buildDirectory, 'cli.js'), 0o755), writeZshCompletion(path.join(buildDirectory, 'completions', '_swatch'), manifest), + copyIntegrations(), ]); await publishDist(); } +async function copyIntegrations(): Promise { + const destination = path.join(buildDirectory, 'integrations'); + await mkdir(destination, { recursive: true }); + await Promise.all( + ['wezterm.lua'].map((name) => + copyFile(path.join(packageRoot, 'integrations', name), path.join(destination, name)), + ), + ); +} + async function buildCatalog(destination: string) { const { revision, schemes } = await readSchemesCache().catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') { diff --git a/packages/swatch/scripts/lib/sync.ts b/packages/swatch/scripts/lib/sync.ts new file mode 100644 index 0000000..e1a9500 --- /dev/null +++ b/packages/swatch/scripts/lib/sync.ts @@ -0,0 +1,101 @@ +import { lstat, mkdir, readlink, symlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +export type SyncPaths = { + home: string; + binHome?: string; + dataHome?: string; +}; + +export type PackageSyncEntry = { name: string; source: string; target: string }; + +export function createPackageSyncEntries( + paths: SyncPaths = { + home: homedir(), + binHome: process.env.XDG_BIN_HOME, + dataHome: process.env.XDG_DATA_HOME, + }, + root = packageRoot, +): PackageSyncEntry[] { + const binHome = absolutePath(paths.binHome) ?? path.join(paths.home, '.local', 'bin'); + const dataHome = absolutePath(paths.dataHome) ?? path.join(paths.home, '.local', 'share'); + return [ + { + name: 'swatch/command', + source: path.join(root, 'dist', 'cli.js'), + target: path.join(binHome, 'swatch'), + }, + { + name: 'swatch/completion', + source: path.join(root, 'dist', 'completions', '_swatch'), + target: path.join(dataHome, 'zsh', 'site-functions', '_swatch'), + }, + { + name: 'swatch/WezTerm integration', + source: path.join(root, 'dist', 'integrations', 'wezterm.lua'), + target: path.join(dataHome, 'swatch', 'integrations', 'wezterm.lua'), + }, + ]; +} + +export async function syncPackage( + paths: SyncPaths = { + home: homedir(), + binHome: process.env.XDG_BIN_HOME, + dataHome: process.env.XDG_DATA_HOME, + }, + root = packageRoot, + preview = false, +): Promise { + const links = createPackageSyncEntries(paths, root); + + const plan = await Promise.all( + links.map(async (link) => { + const sourceStat = await maybeLstat(link.source); + if (!sourceStat?.isFile()) throw new Error(`missing ${link.name}: ${link.source}`); + const targetStat = await maybeLstat(link.target); + if (!targetStat) return { ...link, status: 'create' as const }; + if (!targetStat.isSymbolicLink()) { + throw new Error(`conflict ${link.name}: ${link.target} exists and is not a symlink`); + } + const current = path.resolve(path.dirname(link.target), await readlink(link.target)); + if (current !== link.source) { + throw new Error(`conflict ${link.name}: ${link.target} points to ${current}`); + } + return { ...link, status: 'skip' as const }; + }), + ); + + for (const link of plan) { + if (link.status === 'skip') { + console.log(`skip ${link.name}: already linked`); + continue; + } + if (preview) { + console.log(`preview ${link.name}: ${link.source} -> ${link.target}`); + continue; + } + await mkdir(path.dirname(link.target), { recursive: true }); + await symlink(path.relative(path.dirname(link.target), link.source), link.target, 'file'); + console.log(`sync ${link.name}: ${link.source} -> ${link.target}`); + } +} + +function absolutePath(value: string | undefined): string | undefined { + return value && path.isAbsolute(value) ? value : undefined; +} + +async function maybeLstat(filePath: string) { + try { + return await lstat(filePath); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return undefined; + } + throw error; + } +} diff --git a/packages/swatch/scripts/sync.ts b/packages/swatch/scripts/sync.ts new file mode 100644 index 0000000..973d4f4 --- /dev/null +++ b/packages/swatch/scripts/sync.ts @@ -0,0 +1,27 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { createPackageSyncEntries, syncPackage } from './lib/sync.js'; + +try { + const args = new Set(process.argv.slice(2)); + const preview = args.delete('--preview'); + const planDirectoryArgument = [...args].find((arg) => arg.startsWith('--plan-dir=')); + if (planDirectoryArgument) args.delete(planDirectoryArgument); + if (args.size > 0) throw new Error(`unknown argument: ${[...args].join(', ')}`); + if (planDirectoryArgument) { + if (preview) throw new Error('--preview and --plan-dir cannot be used together'); + const planDirectory = planDirectoryArgument.slice('--plan-dir='.length); + if (!path.isAbsolute(planDirectory)) throw new Error('--plan-dir must be absolute'); + await mkdir(planDirectory, { recursive: true }); + await writeFile( + path.join(planDirectory, 'swatch.json'), + `${JSON.stringify(createPackageSyncEntries())}\n`, + ); + } else { + await syncPackage(undefined, undefined, preview); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/packages/swatch/src/state.ts b/packages/swatch/src/state.ts index a7d7dd9..befdf1b 100644 --- a/packages/swatch/src/state.ts +++ b/packages/swatch/src/state.ts @@ -20,7 +20,13 @@ export class StateError extends Error { } export function currentStatePath(homeDirectory = homedir()): string { - return path.join(homeDirectory, '.forge', 'swatch', 'current.json'); + const stateHome = + absoluteXdgPath(process.env.XDG_STATE_HOME) ?? path.join(homeDirectory, '.local', 'state'); + return path.join(stateHome, 'swatch', 'current.json'); +} + +function absoluteXdgPath(value: string | undefined): string | undefined { + return value && path.isAbsolute(value) ? value : undefined; } export async function readOrInitializeCurrentThemeId( diff --git a/packages/swatch/test/fixtures/wezterm-adapter.lua b/packages/swatch/test/fixtures/wezterm-adapter.lua new file mode 100644 index 0000000..f7e436a --- /dev/null +++ b/packages/swatch/test/fixtures/wezterm-adapter.lua @@ -0,0 +1,65 @@ +local adapter_path = assert(arg[1], 'adapter path is required') +local shell = assert(os.getenv('SHELL'), 'SHELL is required') +local discovery_count = 0 +local watched_paths = {} + +local function assert_command(actual, expected) + assert(#actual == #expected, 'command argument count differs') + for index, value in ipairs(expected) do + assert( + actual[index] == value, + 'unexpected command argument ' .. index .. ': ' .. tostring(actual[index]) + ) + end +end + +package.preload.wezterm = function() + return { + add_to_config_reload_watch_list = function(path) + table.insert(watched_paths, path) + end, + json_parse = function(value) + assert(value == '{"id":"theme","palette":{"base00":"#000000"}}') + return { id = 'theme', palette = { base00 = '#000000' } } + end, + run_child_process = function(command) + if command[#command] == 'exec /usr/bin/env -0' then + discovery_count = discovery_count + 1 + assert_command(command, { + '/usr/bin/env', + '-u', + 'PATH', + '-u', + 'ZDOTDIR', + '-u', + 'NVM_BIN', + '-u', + 'NVM_INC', + shell, + '-ic', + 'exec /usr/bin/env -0', + }) + return true, 'PATH=/fresh/bin\0', '' + end + + assert(command[1] == '/usr/bin/env') + assert(command[2] == 'PATH=/fresh/bin') + assert(command[3] == 'swatch') + if command[4] == 'current' and command[5] == '--path' then + return true, '/home/test/.local/state/swatch/current.json\n', '' + end + if command[4] == 'current' and command[5] == '--json' then + return true, '{"id":"theme","palette":{"base00":"#000000"}}\n', '' + end + error('unexpected Swatch command') + end, + } +end + +local adapter = assert(loadfile(adapter_path))() +assert(adapter.current_palette().base00 == '#000000') +assert(adapter.current_palette().base00 == '#000000') +assert(discovery_count == 2, 'runtime context must be rediscovered for every config evaluation') +assert(#watched_paths == 2) +assert(watched_paths[1] == '/home/test/.local/state/swatch/current.json') +assert(watched_paths[2] == '/home/test/.local/state/swatch/current.json') diff --git a/packages/swatch/test/state.test.ts b/packages/swatch/test/state.test.ts index 6f1a8a5..81a7dc6 100644 --- a/packages/swatch/test/state.test.ts +++ b/packages/swatch/test/state.test.ts @@ -29,12 +29,19 @@ beforeEach(async () => { afterEach(async () => { await rm(directory, { force: true, recursive: true }); + vi.unstubAllEnvs(); }); describe('readOrInitializeCurrentThemeId', () => { - it('resolves current state under the Forge data directory in the user home', () => { + it('resolves current state under XDG state home', () => { expect(currentStatePath('/Users/example')).toBe( - path.join('/Users/example', '.forge', 'swatch', 'current.json'), + path.join('/Users/example', '.local', 'state', 'swatch', 'current.json'), + ); + vi.stubEnv('XDG_STATE_HOME', '/state'); + expect(currentStatePath('/Users/example')).toBe(path.join('/state', 'swatch', 'current.json')); + vi.stubEnv('XDG_STATE_HOME', 'relative'); + expect(currentStatePath('/Users/example')).toBe( + path.join('/Users/example', '.local', 'state', 'swatch', 'current.json'), ); }); diff --git a/packages/swatch/test/sync.test.ts b/packages/swatch/test/sync.test.ts new file mode 100644 index 0000000..1d8c911 --- /dev/null +++ b/packages/swatch/test/sync.test.ts @@ -0,0 +1,108 @@ +import { mkdir, mkdtemp, readlink, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createPackageSyncEntries, syncPackage } from '../scripts/lib/sync.js'; + +const temporaryDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe('package sync', () => { + it('exposes absolute entries for root sync planning', async () => { + const root = await fixture(); + const home = await temporaryDirectory(); + expect(createPackageSyncEntries({ home }, root).map(({ name }) => name)).toEqual([ + 'swatch/command', + 'swatch/completion', + 'swatch/WezTerm integration', + ]); + expect( + createPackageSyncEntries({ home }, root).every(({ source, target }) => { + return path.isAbsolute(source) && path.isAbsolute(target); + }), + ).toBe(true); + }); + + it('links package artifacts into standard user directories', async () => { + const root = await fixture(); + const home = await temporaryDirectory(); + await syncPackage({ home }, root); + expect(await readlink(path.join(home, '.local/bin/swatch'))).toBe( + path.relative(path.join(home, '.local/bin'), path.join(root, 'dist/cli.js')), + ); + expect(await readlink(path.join(home, '.local/share/zsh/site-functions/_swatch'))).toBe( + path.relative( + path.join(home, '.local/share/zsh/site-functions'), + path.join(root, 'dist/completions/_swatch'), + ), + ); + expect(await readlink(path.join(home, '.local/share/swatch/integrations/wezterm.lua'))).toBe( + path.relative( + path.join(home, '.local/share/swatch/integrations'), + path.join(root, 'dist/integrations/wezterm.lua'), + ), + ); + }); + + it('respects absolute XDG sync directories', async () => { + const root = await fixture(); + const home = await temporaryDirectory(); + const binHome = path.join(home, 'commands'); + const dataHome = path.join(home, 'data'); + await syncPackage({ home, binHome, dataHome }, root); + await expect(readlink(path.join(binHome, 'swatch'))).resolves.toBe( + path.relative(binHome, path.join(root, 'dist/cli.js')), + ); + await expect(readlink(path.join(dataHome, 'zsh/site-functions/_swatch'))).resolves.toBe( + path.relative( + path.join(dataHome, 'zsh/site-functions'), + path.join(root, 'dist/completions/_swatch'), + ), + ); + }); + + it('plans every artifact before writing and refuses conflicts', async () => { + const root = await fixture(); + const home = await temporaryDirectory(); + const conflict = path.join(home, '.local/share/zsh/site-functions/_swatch'); + await mkdir(path.dirname(conflict), { recursive: true }); + await writeFile(conflict, 'unmanaged'); + await expect(syncPackage({ home }, root)).rejects.toThrow(/conflict swatch\/completion/); + await expect(readlink(path.join(home, '.local/bin/swatch'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('previews missing links without writing them', async () => { + const root = await fixture(); + const home = await temporaryDirectory(); + await syncPackage({ home }, root, true); + await expect(readlink(path.join(home, '.local/bin/swatch'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); +}); + +async function fixture(): Promise { + const root = await temporaryDirectory(); + for (const relative of [ + 'dist/cli.js', + 'dist/completions/_swatch', + 'dist/integrations/wezterm.lua', + ]) { + const filePath = path.join(root, relative); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, relative); + } + return root; +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'swatch-sync-')); + temporaryDirectories.push(directory); + return directory; +} diff --git a/packages/swatch/test/weztermIntegration.test.ts b/packages/swatch/test/weztermIntegration.test.ts new file mode 100644 index 0000000..27a5143 --- /dev/null +++ b/packages/swatch/test/weztermIntegration.test.ts @@ -0,0 +1,17 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const harness = fileURLToPath(new URL('./fixtures/wezterm-adapter.lua', import.meta.url)); +const adapter = fileURLToPath(new URL('../integrations/wezterm.lua', import.meta.url)); + +describe('WezTerm integration', () => { + it('rediscovers Swatch through a clean shell environment on every evaluation', () => { + const result = spawnSync('luajit', [harness, adapter], { encoding: 'utf8' }); + + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 265eab2..9e5935e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,8 +51,8 @@ importers: specifier: 9.1.7 version: 9.1.7 lint-staged: - specifier: 17.2.0 - version: 17.2.0 + specifier: 17.3.0 + version: 17.3.0 oxlint: specifier: 'catalog:' version: 1.77.0 @@ -1032,8 +1032,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lint-staged@17.2.0: - resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} + lint-staged@17.3.0: + resolution: {integrity: sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==} engines: {node: '>=22.22.1'} hasBin: true @@ -1940,7 +1940,7 @@ snapshots: lines-and-columns@1.2.4: {} - lint-staged@17.2.0: + lint-staged@17.3.0: dependencies: picomatch: 4.0.5 string-argv: 0.3.2 diff --git a/scripts/brew.ts b/scripts/brew.ts index 96a5f65..250a00f 100644 --- a/scripts/brew.ts +++ b/scripts/brew.ts @@ -9,101 +9,69 @@ function runBrew(args: string[], stdio: 'inherit' | 'pipe' = 'inherit') { function ensureBrew() { const result = runBrew(['--version'], 'pipe'); - - if (result.error) { - throw new Error(`${errTag()} ${red('missing brew: command not found')}`); - } - - if (result.status !== 0) { + if (result.error) throw new Error(`${errTag()} ${red('missing brew: command not found')}`); + if (result.status !== 0) throw new Error(`${errTag()} ${red('missing brew: failed to run brew')}`); - } } function isTapped(tap: string) { const result = runBrew(['tap'], 'pipe'); - - if (result.status !== 0) { - throw new Error(`${errTag()} ${red('brew tap: failed to list taps')}`); - } - + if (result.status !== 0) throw new Error(`${errTag()} ${red('brew tap: failed to list taps')}`); return result.stdout.toString('utf8').split('\n').includes(tap); } function isTrusted(tap: string) { const result = runBrew(['trust', '--json=v1'], 'pipe'); - - if (result.status !== 0) { + if (result.status !== 0) throw new Error(`${errTag()} ${red('brew trust: failed to list trusted taps')}`); - } - - const trusted = JSON.parse(result.stdout.toString('utf8')) as { - taps?: unknown; - }; + const trusted = JSON.parse(result.stdout.toString('utf8')) as { taps?: unknown }; return Array.isArray(trusted.taps) && trusted.taps.includes(tap); } function isInstalled(kind: 'formula' | 'cask', name: string) { - const result = runBrew(['list', `--${kind}`, name], 'pipe'); - return result.status === 0; + return runBrew(['list', `--${kind}`, name], 'pipe').status === 0; } function install(args: string[], label: string) { const result = runBrew(args); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - throw new Error(`${errTag()} ${red(`${label}: command failed`)}`); - } + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${errTag()} ${red(`${label}: command failed`)}`); } try { const config = await loadConfig(); const { taps, formulas, casks } = config.brew; - if (taps.length === 0 && formulas.length === 0 && casks.length === 0) { console.log(`${skipTag()} no brew packages configured`); process.exit(0); } - ensureBrew(); - for (const tap of taps) { - if (isTapped(tap)) { - console.log(`${skipTag()} tap ${tap}: already tapped`); - } else { + if (isTapped(tap)) console.log(`${skipTag()} tap ${tap}: already tapped`); + else { console.log(`${okTag()} tap ${tap}: installing`); install(['tap', tap], `tap ${tap}`); } - - if (isTrusted(tap)) { - console.log(`${skipTag()} tap ${tap}: already trusted`); - } else { + if (isTrusted(tap)) console.log(`${skipTag()} tap ${tap}: already trusted`); + else { console.log(`${okTag()} tap ${tap}: trusting`); install(['trust', '--tap', tap], `trust ${tap}`); } } - for (const cask of casks) { - if (isInstalled('cask', cask)) { - console.log(`${skipTag()} cask ${cask}: already installed`); - continue; + if (isInstalled('cask', cask)) console.log(`${skipTag()} cask ${cask}: already installed`); + else { + console.log(`${okTag()} cask ${cask}: installing`); + install(['install', '--cask', cask], `cask ${cask}`); } - - console.log(`${okTag()} cask ${cask}: installing`); - install(['install', '--cask', cask], `cask ${cask}`); } - for (const formula of formulas) { - if (isInstalled('formula', formula)) { + if (isInstalled('formula', formula)) console.log(`${skipTag()} brew ${formula}: already installed`); - continue; + else { + console.log(`${okTag()} brew ${formula}: installing`); + install(['install', formula], `brew ${formula}`); } - - console.log(`${okTag()} brew ${formula}: installing`); - install(['install', formula], `brew ${formula}`); } } catch (error) { console.error(error instanceof Error ? error.message : error); diff --git a/scripts/build.ts b/scripts/build.ts deleted file mode 100644 index be87de3..0000000 --- a/scripts/build.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { loadConfig } from './lib/config.js'; -import { zshenvBuilder } from './builders/zshenv.js'; -import { zshRuntimeBuilder } from './builders/zsh-runtime.js'; -import { errTag, red, skipTag } from './lib/colors.js'; -import type { Builder } from './builders/shared.js'; - -const builders: Record = { - zshenv: zshenvBuilder, - 'zsh-runtime': zshRuntimeBuilder, -}; - -try { - const config = await loadConfig(); - - if (config.build.length === 0) { - console.log(`${skipTag()} no build tasks configured`); - process.exit(0); - } - - for (const task of config.build) { - const builder = builders[task.builder]; - - if (!builder) { - throw new Error( - `${errTag()} ${red(`unknown builder "${task.builder}"; available builders: ${Object.keys(builders).join(', ')}`)}`, - ); - } - - await builder.build({ - source: task.source, - output: task.output, - opts: task.opts, - }); - } -} catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exit(1); -} diff --git a/scripts/builders/shared.ts b/scripts/builders/shared.ts deleted file mode 100644 index 31851d9..0000000 --- a/scripts/builders/shared.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import path from 'node:path'; - -import { errTag, okTag, red, skipTag } from '../lib/colors.js'; -import { expandHome, repoRoot } from '../lib/paths.js'; - -// The marker is the ownership proof for generated files: writeGeneratedFile -// refuses to overwrite any target that does not contain it. Never bypass. -export const GENERATED_MARKER = 'GENERATED_BY_FORGE'; - -export interface Builder { - build(task: { source: string; output: string; opts: unknown }): Promise; -} - -function resolveOutputPath(output: string, root = repoRoot) { - const expanded = expandHome(output); - if (path.isAbsolute(expanded)) return expanded; - - return path.resolve(root, expanded); -} - -export async function validateGeneratedFile(label: string, output: string, root = repoRoot) { - const target = resolveOutputPath(output, root); - await validateNoSymlinkAncestors(target, trustedPathRoot(target, root)); - let targetStat; - - try { - targetStat = await lstat(target); - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return; - throw error; - } - - // Generated outputs must be regular files. Following a symlink here could - // turn the ownership marker into permission to overwrite an unrelated file. - if (!targetStat.isFile()) throw generatedFileConflict(label, target); - - const existing = await readFile(target, 'utf8'); - if (!existing.includes(GENERATED_MARKER)) throw generatedFileConflict(label, target); -} - -export async function validateNoSymlinkAncestors( - target: string, - trustedRoot: string, -): Promise { - const relative = path.relative(trustedRoot, path.dirname(target)); - if (relative === '') return; - if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new Error(`path is outside its trusted root: ${target}`); - } - - let current = trustedRoot; - for (const segment of relative.split(path.sep)) { - current = path.join(current, segment); - try { - const currentStat = await lstat(current); - if (currentStat.isSymbolicLink()) { - throw new Error(`path has a symlink ancestor: ${target} via ${current}`); - } - if (!currentStat.isDirectory()) { - throw new Error(`path has a non-directory ancestor: ${target} via ${current}`); - } - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return; - throw error; - } - } -} - -export async function writeGeneratedFile( - label: string, - output: string, - content: string, - root = repoRoot, -) { - const target = resolveOutputPath(output, root); - await validateGeneratedFile(label, output, root); - let existing: string | null = null; - - try { - existing = await readFile(target, 'utf8'); - } catch (error) { - if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) { - throw error; - } - } - - if (existing !== null) { - if (!existing.includes(GENERATED_MARKER)) throw generatedFileConflict(label, target); - - if (existing === content) { - console.log(`${skipTag()} ${label}: unchanged`); - return; - } - } - - await mkdir(path.dirname(target), { recursive: true }); - const temporary = path.join( - path.dirname(target), - `.${path.basename(target)}.${randomUUID()}.tmp`, - ); - - try { - await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' }); - // Renaming a regular temporary file replaces the directory entry without - // following a symlink that appears after the ownership preflight. - await rename(temporary, target); - } finally { - await rm(temporary, { force: true }); - } - console.log(`${okTag()} build ${label}: ${target}`); -} - -function generatedFileConflict(label: string, target: string): Error { - return new Error( - `${errTag()} ${red(`conflict ${label}: ${target} exists and is not a Forge-generated regular file; back it up or remove it manually`)}`, - ); -} - -function trustedPathRoot(target: string, root: string): string { - if (isWithin(root, target)) return root; - - const home = homedir(); - if (isWithin(home, target)) return home; - - return path.dirname(target); -} - -function isWithin(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..'); -} diff --git a/scripts/builders/zsh-runtime.ts b/scripts/builders/zsh-runtime.ts deleted file mode 100644 index 773f93e..0000000 --- a/scripts/builders/zsh-runtime.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { constants } from 'node:fs'; -import { access, lstat, mkdir, readFile, readlink, rm, stat, symlink } from 'node:fs/promises'; -import path from 'node:path'; -import { z } from 'zod'; - -import { errTag, okTag, red, skipTag } from '../lib/colors.js'; -import { expandHome, repoRoot } from '../lib/paths.js'; -import { - GENERATED_MARKER, - validateGeneratedFile, - validateNoSymlinkAncestors, - writeGeneratedFile, - type Builder, -} from './shared.js'; - -const runtimeEntrySchema = z - .object({ - name: z.string().min(1), - source: z.string().min(1), - output: z.string().min(1), - }) - .strict(); - -const optsSchema = z - .object({ - executables: z.array(runtimeEntrySchema).default([]), - completions: z.array(runtimeEntrySchema).default([]), - sources: z.array(z.string().min(1)).default([]), - }) - .strict(); - -type RuntimeEntry = z.infer; - -type PlannedRuntimeLink = RuntimeEntry & { - kind: 'completion' | 'executable'; - source: string; - output: string; - linkTarget: string; - status: 'create' | 'skip'; -}; - -export const zshRuntimeBuilder: Builder = { - async build(task) { - await buildZshRuntime(task, repoRoot); - }, -}; - -export async function buildZshRuntime( - task: { source: string; output: string; opts: unknown }, - root: string, -): Promise { - const opts = optsSchema.parse(task.opts); - const templatePath = resolvePath(root, task.source); - const outputPath = resolvePath(root, task.output); - const template = await readRequiredFile(templatePath, 'zsh runtime template'); - const sources = await Promise.all( - opts.sources.map((source) => - readRequiredFile(resolvePath(root, source), 'zsh runtime config source'), - ), - ); - const entries = [...opts.executables, ...opts.completions]; - const linkOutputs = entries.map(({ output }) => resolvePath(root, output)); - const inputPaths = [ - templatePath, - ...opts.sources.map((source) => resolvePath(root, source)), - ...entries.map(({ source }) => resolvePath(root, source)), - ]; - validatePathPlan([...linkOutputs, outputPath], inputPaths); - await Promise.all(linkOutputs.map((output) => validateNoSymlinkAncestors(output, root))); - - const plan = await Promise.all([ - ...opts.executables.map((entry) => planRuntimeLink(entry, root, 'executable')), - ...opts.completions.map((entry) => planRuntimeLink(entry, root, 'completion')), - ]); - const executableDirectories = uniqueDirectories(opts.executables, root); - const completionDirectories = uniqueDirectories(opts.completions, root); - const mainSource = renderTemplate(template, { - executable_dirs: renderDirectories(executableDirectories), - completion_dirs: renderDirectories(completionDirectories), - }); - const body = [mainSource, ...sources].map((content) => content.trim()).join('\n\n'); - const content = `# ${GENERATED_MARKER}. Edit the sources in the repo and run \`pnpm build\`.\n\n${body}\n`; - - await validateGeneratedFile('zsh runtime config', task.output, root); - const createdLinks: PlannedRuntimeLink[] = []; - - try { - for (const link of plan) { - if (link.status === 'skip') { - console.log(`${skipTag()} zsh runtime ${link.kind} "${link.name}": link already correct`); - continue; - } - - await mkdir(path.dirname(link.output), { recursive: true }); - await symlink(link.linkTarget, link.output, 'file'); - createdLinks.push(link); - console.log(`${okTag()} build zsh runtime ${link.kind} "${link.name}": ${link.output}`); - } - - await writeGeneratedFile('zsh runtime config', task.output, content, root); - } catch (error) { - await Promise.all(createdLinks.map(removePlannedLink)); - throw error; - } -} - -async function planRuntimeLink( - entry: RuntimeEntry, - root: string, - kind: PlannedRuntimeLink['kind'], -): Promise { - const source = resolvePath(root, entry.source); - const output = resolvePath(root, entry.output); - - let sourceStat; - try { - sourceStat = await stat(source); - } catch (error) { - if (isMissing(error)) { - throw new Error(`missing zsh runtime ${entry.name}: source does not exist: ${source}`); - } - throw error; - } - if (!sourceStat.isFile()) - throw new Error(`invalid zsh runtime ${entry.name}: not a file: ${source}`); - await access(source, kind === 'executable' ? constants.R_OK | constants.X_OK : constants.R_OK); - - const linkTarget = path.relative(path.dirname(output), source); - const outputStat = await maybeLstat(output); - - if (!outputStat) return { ...entry, kind, source, output, linkTarget, status: 'create' }; - if (!outputStat.isSymbolicLink()) { - throw new Error(`conflict zsh runtime ${entry.name}: ${output} exists and is not a symlink`); - } - - const currentTarget = path.resolve(path.dirname(output), await readlink(output)); - if (currentTarget !== source) { - throw new Error( - `conflict zsh runtime ${entry.name}: ${output} points to ${currentTarget}, expected ${source}`, - ); - } - - return { ...entry, kind, source, output, linkTarget, status: 'skip' }; -} - -function uniqueDirectories(entries: RuntimeEntry[], root: string): string[] { - return [...new Set(entries.map(({ output }) => path.dirname(resolvePath(root, output))))]; -} - -function renderDirectories(directories: string[]): string { - return directories.map((directory) => ` ${quoteForZsh(directory)}`).join('\n'); -} - -function renderTemplate(template: string, tokens: Record): string { - for (const token of Object.keys(tokens)) { - const matches = template.match(new RegExp(`\\{\\{\\s*${token}\\s*\\}\\}`, 'g')) ?? []; - if (matches.length !== 1) { - throw new Error(`zsh runtime template must contain exactly one {{ ${token} }} token`); - } - } - - const rendered = template.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, token: string) => { - const value = tokens[token]; - if (value === undefined) { - throw new Error(`${errTag()} ${red(`unknown zsh runtime: unknown token {{ ${token} }}`)}`); - } - return value; - }); - - if (rendered.includes('{{') || rendered.includes('}}')) { - throw new Error('zsh runtime template contains a malformed token'); - } - return rendered; -} - -function quoteForZsh(input: string): string { - return `'${input.replaceAll("'", "'\\''")}'`; -} - -function resolvePath(root: string, input: string): string { - const expanded = expandHome(input); - return path.isAbsolute(expanded) ? expanded : path.resolve(root, expanded); -} - -function validatePathPlan(outputs: string[], inputs: string[]): void { - for (let index = 0; index < outputs.length; index += 1) { - for (const other of outputs.slice(index + 1)) { - if (pathsOverlap(outputs[index], other)) { - throw new Error(`conflicting zsh runtime outputs: ${outputs[index]} and ${other}`); - } - } - for (const input of inputs) { - if (pathsOverlap(outputs[index], input)) { - throw new Error(`zsh runtime output overlaps input: ${outputs[index]} and ${input}`); - } - } - } -} - -function pathsOverlap(first: string, second: string): boolean { - return containsPath(first, second) || containsPath(second, first); -} - -function containsPath(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return ( - relative === '' || - (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) - ); -} - -async function readRequiredFile(filePath: string, label: string): Promise { - try { - return await readFile(filePath, 'utf8'); - } catch (error) { - if (isMissing(error)) throw new Error(`missing ${label}: ${filePath}`); - throw error; - } -} - -async function maybeLstat(filePath: string) { - try { - return await lstat(filePath); - } catch (error) { - if (isMissing(error)) return null; - throw error; - } -} - -async function removePlannedLink(link: PlannedRuntimeLink): Promise { - try { - const currentTarget = path.resolve(path.dirname(link.output), await readlink(link.output)); - if (currentTarget === link.source) await rm(link.output); - } catch (error) { - if (!isMissing(error)) throw error; - } -} - -function isMissing(error: unknown): boolean { - return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'); -} diff --git a/scripts/builders/zshenv.ts b/scripts/builders/zshenv.ts deleted file mode 100644 index a002c2e..0000000 --- a/scripts/builders/zshenv.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { z } from 'zod'; - -import { errTag, okTag, red, skipTag } from '../lib/colors.js'; -import { expandHome, repoRoot, resolveRepoPath } from '../lib/paths.js'; -import { GENERATED_MARKER, writeGeneratedFile, type Builder } from './shared.js'; - -const optsSchema = z.object({ - zdotdir: z.string().min(1), - localenv: z.string().min(1), -}); - -function resolveOutputPath(input: string) { - const expanded = expandHome(input); - if (path.isAbsolute(expanded)) return expanded; - - return path.resolve(repoRoot, expanded); -} - -function quoteForZsh(input: string) { - return `'${input.replaceAll("'", "'\\''")}'`; -} - -async function initializeLocalEnv(target: string) { - await mkdir(path.dirname(target), { recursive: true }); - - try { - await writeFile( - target, - '# Machine-local environment. Forge initializes this file once and never overwrites it.\n', - { encoding: 'utf8', flag: 'wx', mode: 0o600 }, - ); - console.log(`${okTag()} initialize local env: ${target}`); - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') { - console.log(`${skipTag()} local env: already exists`); - return; - } - - throw error; - } -} - -function renderTemplate(template: string, tokens: Record, label: string) { - return template.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, token: string) => { - const value = tokens[token]; - - if (value === undefined) { - throw new Error(`${errTag()} ${red(`unknown ${label}: unknown token {{ ${token} }`)}`); - } - - return value; - }); -} - -export const zshenvBuilder: Builder = { - async build({ source, output, opts }) { - const parsed = optsSchema.parse(opts); - const sourcePath = resolveRepoPath(source); - let template: string; - - try { - template = await readFile(sourcePath, 'utf8'); - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - throw new Error( - `${errTag()} ${red(`missing zshenv: source does not exist: ${sourcePath}`)}`, - ); - } - - throw error; - } - - const body = renderTemplate( - template, - { - zdotdir: resolveOutputPath(parsed.zdotdir), - localenv: quoteForZsh(resolveOutputPath(parsed.localenv)), - }, - 'zshenv', - ); - const content = `# ${GENERATED_MARKER}. Edit the template in the repo and run \`pnpm build\`.\n\n${body.trimStart()}`; - - await writeGeneratedFile('zshenv', output, content); - await initializeLocalEnv(resolveOutputPath(parsed.localenv)); - }, -}; diff --git a/scripts/lib/config.ts b/scripts/lib/config.ts index 4670fdc..482e44b 100644 --- a/scripts/lib/config.ts +++ b/scripts/lib/config.ts @@ -5,22 +5,6 @@ import { z } from 'zod'; import { repoRoot } from './paths.js'; -const linkSchema = z.object({ - name: z.string().min(1), - source: z.string().min(1), - target: z.string().min(1), -}); - -// Config-level schema validates only the base task shape; each builder -// validates its own opts with its own schema. New builders must not require -// changes here. -const buildTaskSchema = z.object({ - builder: z.string().min(1), - source: z.string().min(1), - output: z.string().min(1), - opts: z.record(z.string(), z.unknown()).default({}), -}); - const brewSchema = z .object({ taps: z.array(z.string().min(1)).default([]), @@ -28,21 +12,92 @@ const brewSchema = z casks: z.array(z.string().min(1)).default([]), }) .default({ taps: [], formulas: [], casks: [] }); - -const configSchema = z.object({ - links: z.array(linkSchema).default([]), - build: z.array(buildTaskSchema).default([]), - brew: brewSchema, -}); +const moduleNameSchema = z + .string() + .min(1) + .refine((name) => name !== '.' && name !== '..' && path.basename(name) === name, { + message: 'must be a single path segment', + }); +const moduleSchema = z + .object({ + name: moduleNameSchema, + type: z.literal('config'), + manifest: z.string().min(1), + }) + .strict(); +const configSchema = z + .object({ brew: brewSchema, modules: z.array(moduleSchema).default([]) }) + .strict(); +const syncSchema = z + .object({ name: z.string().min(1), source: z.string().min(1), target: z.string().min(1) }) + .strict(); +const domainSchema = z.object({ sync: z.array(syncSchema).default([]) }).strict(); export type ForgeConfig = z.infer; -export type LinkEntry = ForgeConfig['links'][number]; -export type BuildTask = ForgeConfig['build'][number]; +export type ResolvedSyncEntry = { name: string; source: string; target: string }; +export type DomainConfig = { sync: ResolvedSyncEntry[] }; + +export async function loadConfig(root = repoRoot) { + const configPath = path.join(root, 'forge.config.yaml'); + const result = configSchema.safeParse(parse(await readFile(configPath, 'utf8'))); + if (!result.success) throw new Error(`${configPath}: ${result.error.message}`); + return result.data; +} + +export async function loadDomainConfig(root = repoRoot): Promise { + const configPath = path.join(root, 'forge.config.yaml'); + const config = await loadConfig(root); + const names = new Set(); + const manifests = new Set(); + const sync: ResolvedSyncEntry[] = []; + + for (const module of config.modules) { + if (names.has(module.name)) { + throw new Error(`${configPath}: duplicate module name "${module.name}"`); + } + names.add(module.name); + if (path.isAbsolute(module.manifest)) { + throw new Error(`${configPath}: module "${module.name}" manifest must be relative`); + } + const manifestPath = path.resolve(root, module.manifest); + assertContained(root, manifestPath, configPath, `module manifest: ${module.manifest}`); + if (manifests.has(manifestPath)) { + throw new Error(`${configPath}: duplicate module manifest "${module.manifest}"`); + } + manifests.add(manifestPath); + } -export async function loadConfig() { - const configPath = path.join(repoRoot, 'forge.config.yaml'); - const configFile = await readFile(configPath, 'utf8'); - const parsedConfig = parse(configFile); + for (const module of config.modules) { + const manifestPath = path.resolve(root, module.manifest); + let input: unknown; + try { + input = parse(await readFile(manifestPath, 'utf8')); + } catch (error) { + throw new Error(`${manifestPath}: failed to read module "${module.name}" manifest`, { + cause: error, + }); + } + const result = domainSchema.safeParse(input); + if (!result.success) throw new Error(`${manifestPath}: ${result.error.message}`); + const domainRoot = path.dirname(manifestPath); + const domainNames = new Set(); + for (const entry of result.data.sync) { + if (domainNames.has(entry.name)) { + throw new Error(`${manifestPath}: duplicate sync name "${entry.name}"`); + } + domainNames.add(entry.name); + const source = path.resolve(domainRoot, entry.source); + assertContained(domainRoot, source, manifestPath, `path: ${entry.source}`); + sync.push({ name: `${module.name}/${entry.name}`, source, target: entry.target }); + } + } + + return { sync }; +} - return configSchema.parse(parsedConfig); +function assertContained(root: string, resolved: string, configPath: string, label: string): void { + const relative = path.relative(root, resolved); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`${configPath}: ${label} escapes its root`); + } } diff --git a/scripts/start.ts b/scripts/start.ts index 795599a..0a1fc9b 100644 --- a/scripts/start.ts +++ b/scripts/start.ts @@ -1,11 +1,11 @@ import { spawnSync } from 'node:child_process'; -// Build must finish before sync can link generated outputs, so setup runs in a +// Build must finish before sync exposes generated outputs, so setup runs in a // fixed order and stops before later steps can observe incomplete state. const setupSteps = ['brew', 'build', 'sync'] as const; for (const step of setupSteps) { - const result = spawnSync('pnpm', [step], { stdio: 'inherit' }); + const result = spawnSync('pnpm', ['run', step], { stdio: 'inherit' }); if (result.error) { throw result.error; diff --git a/scripts/sync.ts b/scripts/sync.ts index 8021f10..aebf126 100644 --- a/scripts/sync.ts +++ b/scripts/sync.ts @@ -1,38 +1,49 @@ -import { loadConfig } from './lib/config.js'; +import { spawnSync } from 'node:child_process'; + +import { loadDomainConfig } from './lib/config.js'; import { errTag, okTag, red, skipTag } from './lib/colors.js'; import { applySyncPlan, createSyncPlan } from './sync/linker.js'; +import { collectPackageSyncEntries } from './sync/packages.js'; const args = new Set(process.argv.slice(2)); const isPreview = args.has('--preview'); const unknownArgs = [...args].filter((arg) => arg !== '--preview'); - if (unknownArgs.length > 0) { console.error(`${errTag()} ${red(`unknown argument: ${unknownArgs.join(', ')}`)}`); process.exit(1); } - try { - const config = await loadConfig(); + const packageEntries = await collectPackageSyncEntries((planDirectory) => { + const packageArgs = [ + '--recursive', + '--if-present', + 'run', + 'sync', + `--plan-dir=${planDirectory}`, + ]; + const packageResult = spawnSync('pnpm', packageArgs, { stdio: 'inherit' }); + if (packageResult.error) throw packageResult.error; + if (packageResult.status !== 0) { + throw new Error(`package sync planning failed with exit code ${packageResult.status ?? 1}`); + } + }); - if (config.links.length === 0) { + const config = await loadDomainConfig(); + const entries = [...packageEntries, ...config.sync]; + if (entries.length === 0) { console.log(`${skipTag()} no links configured`); process.exit(0); } - - const plan = await createSyncPlan(config.links); - + const plan = await createSyncPlan(entries); for (const action of plan) { - if (action.status === 'skip') { - console.log(`${skipTag()} ${action.name}: already linked`); - } else if (action.status === 'conflict') { + if (action.status === 'skip') console.log(`${skipTag()} ${action.name}: already linked`); + else if (action.status === 'conflict') console.log(`${errTag()} ${red(`conflict ${action.name}: ${action.reason}`)}`); - } else { + else console.log( `${okTag()} ${isPreview ? 'preview' : 'sync'} ${action.name}: ${action.source} -> ${action.target}`, ); - } } - if (!isPreview) await applySyncPlan(plan); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/scripts/sync/linker.ts b/scripts/sync/linker.ts index dcfac32..744b7c8 100644 --- a/scripts/sync/linker.ts +++ b/scripts/sync/linker.ts @@ -1,123 +1,74 @@ -// Plans all configured links before applying any filesystem changes. - import { lstat, mkdir, readlink, symlink } from 'node:fs/promises'; import path from 'node:path'; -import type { LinkEntry } from '../lib/config.js'; -import { expandHome, resolveRepoPath } from '../lib/paths.js'; - -type PlannedLink = { - name: string; - source: string; - target: string; -}; +import type { ResolvedSyncEntry } from '../lib/config.js'; +import { expandHome } from '../lib/paths.js'; +type PlannedLink = { name: string; source: string; target: string }; export type SyncAction = - | (PlannedLink & { - status: 'create'; - sourceType: 'dir' | 'file'; - }) - | (PlannedLink & { - status: 'skip'; - }) - | (PlannedLink & { - status: 'conflict'; - reason: string; - }); + | (PlannedLink & { status: 'create'; sourceType: 'dir' | 'file' }) + | (PlannedLink & { status: 'skip' }) + | (PlannedLink & { status: 'conflict'; reason: string }); -export async function createSyncPlan(links: LinkEntry[]): Promise { +export async function createSyncPlan(links: ResolvedSyncEntry[]): Promise { const resolvedLinks = await Promise.all( links.map(async (link) => { - const source = resolveRepoPath(link.source); - const sourceStat = await maybeLstat(source); - - if (!sourceStat) { - throw new Error(`missing ${link.name}: source does not exist: ${source}`); - } - + const sourceStat = await maybeLstat(link.source); + if (!sourceStat) + throw new Error(`missing ${link.name}: source does not exist: ${link.source}`); return { - name: link.name, - source, + ...link, sourceType: sourceStat.isDirectory() ? ('dir' as const) : ('file' as const), target: expandHome(link.target), }; }), ); - const targetCounts = new Map(); - - for (const link of resolvedLinks) { + for (const link of resolvedLinks) targetCounts.set(link.target, (targetCounts.get(link.target) ?? 0) + 1); - } - return Promise.all( resolvedLinks.map(async (link): Promise => { - if ((targetCounts.get(link.target) ?? 0) > 1) { + if ((targetCounts.get(link.target) ?? 0) > 1) return { ...link, status: 'conflict', reason: `${link.target} is configured more than once`, }; - } - const targetStat = await maybeLstat(link.target); - - if (!targetStat) { - return { ...link, status: 'create' }; - } - - if (!targetStat.isSymbolicLink()) { + if (!targetStat) return { ...link, status: 'create' }; + if (!targetStat.isSymbolicLink()) return { ...link, status: 'conflict', reason: `${link.target} exists and is not a symlink`, }; - } - - const currentTarget = await readlink(link.target); - const resolvedCurrentTarget = path.resolve(path.dirname(link.target), currentTarget); - - if (resolvedCurrentTarget === link.source) { - return { - name: link.name, - source: link.source, - target: link.target, - status: 'skip', - }; - } - + const currentTarget = path.resolve(path.dirname(link.target), await readlink(link.target)); + if (currentTarget === link.source) + return { name: link.name, source: link.source, target: link.target, status: 'skip' }; return { ...link, status: 'conflict', - reason: `${link.target} points to ${resolvedCurrentTarget}, expected ${link.source}`, + reason: `${link.target} points to ${currentTarget}, expected ${link.source}`, }; }), ); } - export async function applySyncPlan(plan: SyncAction[]): Promise { - const conflictCount = plan.filter((action) => action.status === 'conflict').length; - - if (conflictCount > 0) { - throw new Error(`sync aborted: ${conflictCount} conflict${conflictCount === 1 ? '' : 's'}`); - } - + const conflicts = plan.filter((action) => action.status === 'conflict').length; + if (conflicts > 0) + throw new Error(`sync aborted: ${conflicts} conflict${conflicts === 1 ? '' : 's'}`); for (const action of plan) { if (action.status !== 'create') continue; - await mkdir(path.dirname(action.target), { recursive: true }); await symlink(action.source, action.target, action.sourceType); } } - async function maybeLstat(filePath: string) { try { return await lstat(filePath); } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return null; - } - throw error; } } diff --git a/scripts/sync/packages.ts b/scripts/sync/packages.ts new file mode 100644 index 0000000..ad1acb3 --- /dev/null +++ b/scripts/sync/packages.ts @@ -0,0 +1,42 @@ +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { z } from 'zod'; + +import type { ResolvedSyncEntry } from '../lib/config.js'; + +const entrySchema = z + .object({ name: z.string().min(1), source: z.string().min(1), target: z.string().min(1) }) + .strict(); +const planSchema = z.array(entrySchema); + +export async function collectPackageSyncEntries( + runPackageSync: (planDirectory: string) => void, +): Promise { + const planDirectory = await mkdtemp(path.join(tmpdir(), 'forge-sync-plan-')); + try { + runPackageSync(planDirectory); + const files = (await readdir(planDirectory)).filter((file) => file.endsWith('.json')).sort(); + const entries: ResolvedSyncEntry[] = []; + for (const file of files) { + const planPath = path.join(planDirectory, file); + let input: unknown; + try { + input = JSON.parse(await readFile(planPath, 'utf8')); + } catch (error) { + throw new Error(`${planPath}: invalid package sync plan`, { cause: error }); + } + const result = planSchema.safeParse(input); + if (!result.success) throw new Error(`${planPath}: ${result.error.message}`); + for (const entry of result.data) { + if (!path.isAbsolute(entry.source) || !path.isAbsolute(entry.target)) { + throw new Error(`${planPath}: package sync paths must be absolute`); + } + entries.push(entry); + } + } + return entries; + } finally { + await rm(planDirectory, { recursive: true, force: true }); + } +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..fb1d0df --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadDomainConfig } from '../scripts/lib/config.js'; + +const temporaryDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe('module configuration', () => { + it('loads generic config links in declaration order', async () => { + const root = await temporaryDirectory(); + await writeManifest( + root, + 'configs/alpha/forge.yaml', + 'sync:\n - name: main\n source: ./files\n target: ~/.config/alpha\n', + ); + await writeManifest( + root, + 'configs/wezterm/forge.yaml', + 'sync:\n - name: main\n source: ./files\n target: ~/.config/wezterm\n', + ); + await writeManifest( + root, + 'configs/zsh/forge.yaml', + 'sync:\n - name: environment\n source: ./files/zshenv.zsh\n target: ~/.zshenv\n - name: interactive\n source: ./files/zshrc.zsh\n target: ~/.config/zsh/.zshrc\n - name: keys\n source: ./files/keys.zsh\n target: ~/.config/zsh/keys.zsh\n - name: tools\n source: ./files/tools.zsh\n target: ~/.config/zsh/tools.zsh\n - name: fzf\n source: ./files/fzf.zsh\n target: ~/.config/zsh/fzf.zsh\n', + ); + await writeConfig(root, [ + configModule('alpha', './configs/alpha/forge.yaml'), + configModule('wezterm', './configs/wezterm/forge.yaml'), + configModule('zsh', './configs/zsh/forge.yaml'), + ]); + await expect(loadDomainConfig(root)).resolves.toEqual({ + sync: [ + { + name: 'alpha/main', + source: path.join(root, 'configs/alpha/files'), + target: '~/.config/alpha', + }, + { + name: 'wezterm/main', + source: path.join(root, 'configs/wezterm/files'), + target: '~/.config/wezterm', + }, + { + name: 'zsh/environment', + source: path.join(root, 'configs/zsh/files/zshenv.zsh'), + target: '~/.zshenv', + }, + { + name: 'zsh/interactive', + source: path.join(root, 'configs/zsh/files/zshrc.zsh'), + target: '~/.config/zsh/.zshrc', + }, + { + name: 'zsh/keys', + source: path.join(root, 'configs/zsh/files/keys.zsh'), + target: '~/.config/zsh/keys.zsh', + }, + { + name: 'zsh/tools', + source: path.join(root, 'configs/zsh/files/tools.zsh'), + target: '~/.config/zsh/tools.zsh', + }, + { + name: 'zsh/fzf', + source: path.join(root, 'configs/zsh/files/fzf.zsh'), + target: '~/.config/zsh/fzf.zsh', + }, + ], + }); + }); + it('ignores unregistered manifests', async () => { + const root = await temporaryDirectory(); + await writeConfig(root, []); + await expect(loadDomainConfig(root)).resolves.toEqual({ sync: [] }); + }); + it('rejects duplicate module names', async () => { + const root = await temporaryDirectory(); + await writeConfig(root, [ + configModule('same', './one.yaml'), + configModule('same', './two.yaml'), + ]); + await expect(loadDomainConfig(root)).rejects.toThrow(/duplicate module name/); + }); + it('rejects duplicate sync names', async () => { + const root = await temporaryDirectory(); + await writeManifest( + root, + 'configs/a/forge.yaml', + 'sync:\n - { name: main, source: ./one, target: ~/one }\n - { name: main, source: ./two, target: ~/two }\n', + ); + await writeConfig(root, [configModule('a', './configs/a/forge.yaml')]); + await expect(loadDomainConfig(root)).rejects.toThrow(/duplicate sync name/); + }); + it('rejects sync sources outside their module', async () => { + const root = await temporaryDirectory(); + await writeManifest( + root, + 'configs/a/forge.yaml', + 'sync:\n - { name: main, source: ../outside, target: ~/one }\n', + ); + await writeConfig(root, [configModule('a', './configs/a/forge.yaml')]); + await expect(loadDomainConfig(root)).rejects.toThrow(/escapes its root/); + }); +}); +function configModule(name: string, manifest: string) { + return { name, type: 'config', manifest }; +} +async function writeConfig(root: string, modules: object[]) { + await writeFile( + path.join(root, 'forge.config.yaml'), + `${JSON.stringify({ brew: {}, modules }, undefined, 2)}\n`, + ); +} +async function writeManifest(root: string, relativePath: string, contents: string) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents); +} +async function temporaryDirectory() { + const directory = await mkdtemp(path.join(tmpdir(), 'forge-config-')); + temporaryDirectories.push(directory); + return directory; +} diff --git a/test/sync.test.ts b/test/sync.test.ts index 6f63a0d..e9cc139 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -1,16 +1,13 @@ -// Verifies that link synchronization plans fully before changing the filesystem. - -import { mkdir, mkdtemp, readlink, rm, symlink, writeFile } from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import { mkdtemp, readlink, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; - import { afterEach, describe, expect, it } from 'vitest'; - -import type { LinkEntry } from '../scripts/lib/config.js'; +import type { ResolvedSyncEntry } from '../scripts/lib/config.js'; import { applySyncPlan, createSyncPlan } from '../scripts/sync/linker.js'; +import { collectPackageSyncEntries } from '../scripts/sync/packages.js'; const temporaryDirectories: string[] = []; - afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), @@ -22,9 +19,7 @@ describe('link synchronization', () => { const directory = await temporaryDirectory(); const source = await sourceFile(directory, 'source'); const target = path.join(directory, 'target'); - const plan = await createSyncPlan([link('theme', source, target)]); - expect(plan[0]?.status).toBe('create'); await expect(readlink(target)).rejects.toMatchObject({ code: 'ENOENT' }); }); @@ -36,51 +31,28 @@ describe('link synchronization', () => { const firstTarget = path.join(directory, 'first-target'); const secondTarget = path.join(directory, 'second-target'); await writeFile(secondTarget, 'unmanaged'); - const plan = await createSyncPlan([ link('first', firstSource, firstTarget), link('second', secondSource, secondTarget), ]); - await expect(applySyncPlan(plan)).rejects.toThrow(/sync aborted: 1 conflict/); await expect(readlink(firstTarget)).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('creates all links when the complete plan is conflict-free', async () => { - const directory = await temporaryDirectory(); - const fileSource = await sourceFile(directory, 'file-source'); - const directorySource = path.join(directory, 'directory-source'); - const fileTarget = path.join(directory, 'nested', 'file-target'); - const directoryTarget = path.join(directory, 'directory-target'); - await mkdir(directorySource); - - const plan = await createSyncPlan([ - link('file', fileSource, fileTarget), - link('directory', directorySource, directoryTarget), - ]); - await applySyncPlan(plan); - - expect(await readlink(fileTarget)).toBe(fileSource); - expect(await readlink(directoryTarget)).toBe(directorySource); - expect( - ( - await createSyncPlan([ - link('file', fileSource, fileTarget), - link('directory', directorySource, directoryTarget), - ]) - ).map((action) => action.status), - ).toEqual(['skip', 'skip']); - }); - - it('recognizes a correct relative symbolic link', async () => { + it('creates links and recognizes correct relative links', async () => { const directory = await temporaryDirectory(); const source = await sourceFile(directory, 'source'); - const target = path.join(directory, 'target'); - await symlink(path.relative(path.dirname(target), source), target); - - const plan = await createSyncPlan([link('relative', source, target)]); - - expect(plan[0]?.status).toBe('skip'); + const target = path.join(directory, 'nested', 'target'); + const plan = await createSyncPlan([link('file', source, target)]); + await applySyncPlan(plan); + expect(await readlink(target)).toBe(source); + expect((await createSyncPlan([link('file', source, target)]))[0]?.status).toBe('skip'); + + const relativeTarget = path.join(directory, 'relative-target'); + await symlink(path.relative(path.dirname(relativeTarget), source), relativeTarget); + expect((await createSyncPlan([link('relative', source, relativeTarget)]))[0]?.status).toBe( + 'skip', + ); }); it('rejects duplicate configured targets before applying the plan', async () => { @@ -88,28 +60,44 @@ describe('link synchronization', () => { const firstSource = await sourceFile(directory, 'first-source'); const secondSource = await sourceFile(directory, 'second-source'); const target = path.join(directory, 'target'); - const plan = await createSyncPlan([ link('first', firstSource, target), link('second', secondSource, target), ]); - expect(plan.map((action) => action.status)).toEqual(['conflict', 'conflict']); await expect(applySyncPlan(plan)).rejects.toThrow(/sync aborted: 2 conflicts/); - await expect(readlink(target)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not write package links when a config target conflicts', async () => { + const directory = await temporaryDirectory(); + const packageSource = await sourceFile(directory, 'package-source'); + const configSource = await sourceFile(directory, 'config-source'); + const packageTarget = path.join(directory, 'package-target'); + const configTarget = path.join(directory, 'config-target'); + await writeFile(configTarget, 'unmanaged'); + const entries = await collectPackageSyncEntries((planDirectory) => { + writeFileSync( + path.join(planDirectory, 'package.json'), + JSON.stringify([link('package/command', packageSource, packageTarget)]), + ); + }); + const plan = await createSyncPlan([ + ...entries, + link('config/main', configSource, configTarget), + ]); + await expect(applySyncPlan(plan)).rejects.toThrow(/sync aborted: 1 conflict/); + await expect(readlink(packageTarget)).rejects.toMatchObject({ code: 'ENOENT' }); }); }); -function link(name: string, source: string, target: string): LinkEntry { +function link(name: string, source: string, target: string): ResolvedSyncEntry { return { name, source, target }; } - async function sourceFile(directory: string, name: string): Promise { const source = path.join(directory, name); await writeFile(source, name); return source; } - async function temporaryDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), 'forge-sync-')); temporaryDirectories.push(directory); diff --git a/test/zsh-runtime.test.ts b/test/zsh-runtime.test.ts deleted file mode 100644 index bbd6955..0000000 --- a/test/zsh-runtime.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { - chmod, - lstat, - mkdir, - mkdtemp, - readFile, - readlink, - rm, - symlink, - writeFile, -} from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; - -import { afterEach, describe, expect, it } from 'vitest'; - -import { buildZshRuntime } from '../scripts/builders/zsh-runtime.js'; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), - ); -}); - -describe('zsh runtime builder', () => { - it('links declared runtime files and derives discovery directories from their outputs', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - - await buildZshRuntime(runtimeTask(), root); - - expect(await readlink(path.join(root, 'dist/bin/swatch'))).toBe( - '../../packages/swatch/dist/cli.js', - ); - expect(await readlink(path.join(root, 'dist/completions/_swatch'))).toBe( - '../../packages/swatch/dist/completions/_swatch', - ); - const zshrc = await readFile(path.join(root, 'dist/zsh/.zshrc'), 'utf8'); - expect(zshrc).toContain(`'${path.join(root, 'dist/bin')}'`); - expect(zshrc).toContain(`'${path.join(root, 'dist/completions')}'`); - expect(zshrc.indexOf('autoload -Uz compinit')).toBeLessThan(zshrc.indexOf('# Aliases')); - }); - - it('validates every source before creating runtime links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await rm(path.join(root, 'packages/swatch/dist/completions/_swatch')); - await writeTemplate(root); - - await expect(buildZshRuntime(runtimeTask(), root)).rejects.toThrow( - /missing zsh runtime swatch completion/, - ); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('validates every config source before creating runtime links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - const task = runtimeTask(); - task.opts.sources.push('./configs/zsh/missing.zsh'); - - await expect(buildZshRuntime(task, root)).rejects.toThrow(/missing zsh runtime config source/); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects an unmanaged runtime config before creating links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - await mkdir(path.join(root, 'dist/zsh'), { recursive: true }); - await writeFile(path.join(root, 'dist/zsh/.zshrc'), 'unmanaged'); - - await expect(buildZshRuntime(runtimeTask(), root)).rejects.toThrow( - /not a Forge-generated regular file/, - ); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects a zshrc that shares a link output', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - const task = runtimeTask(); - task.output = './dist/bin/swatch'; - - await expect(buildZshRuntime(task, root)).rejects.toThrow(/conflicting zsh runtime outputs/); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects nested outputs before creating links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - const task = runtimeTask(); - task.opts.executables[0].output = './dist/bin'; - task.opts.completions[0].output = './dist/bin/swatch'; - - await expect(buildZshRuntime(task, root)).rejects.toThrow(/conflicting zsh runtime outputs/); - await expect(lstat(path.join(root, 'dist/bin'))).rejects.toMatchObject({ code: 'ENOENT' }); - }); - - it('rejects outputs that overlap inputs before creating links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - const task = runtimeTask(); - task.output = './configs/zsh'; - - await expect(buildZshRuntime(task, root)).rejects.toThrow(/zsh runtime output overlaps input/); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects templates without every required token before creating links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - await writeSource(root, 'configs/zsh/zshrc.zsh', '{{ executable_dirs }}\n'); - - await expect(buildZshRuntime(runtimeTask(), root)).rejects.toThrow( - /exactly one \{\{ completion_dirs \}\}/, - ); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects a generated output symlink before creating runtime links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - await mkdir(path.join(root, 'dist/zsh'), { recursive: true }); - await writeSource(root, 'other-zshrc', '# GENERATED_BY_FORGE\n'); - await symlink('../../other-zshrc', path.join(root, 'dist/zsh/.zshrc')); - - await expect(buildZshRuntime(runtimeTask(), root)).rejects.toThrow( - /not a Forge-generated regular file/, - ); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects a runtime output whose ancestor is a symlink', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - await mkdir(path.join(root, 'external-bin')); - await mkdir(path.join(root, 'dist')); - await symlink('../external-bin', path.join(root, 'dist/bin')); - - await expect(buildZshRuntime(runtimeTask(), root)).rejects.toThrow(/symlink ancestor/); - await expect(lstat(path.join(root, 'external-bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects a generated output whose ancestor is a symlink', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await writeTemplate(root); - await mkdir(path.join(root, 'external-zsh')); - await mkdir(path.join(root, 'dist')); - await symlink('../external-zsh', path.join(root, 'dist/zsh')); - - await expect(buildZshRuntime(runtimeTask(), root)).rejects.toThrow(/symlink ancestor/); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - await expect(lstat(path.join(root, 'external-zsh/.zshrc'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects a non-executable CLI before creating links', async () => { - const root = await temporaryDirectory(); - await writeRuntimeSources(root); - await chmod(path.join(root, 'packages/swatch/dist/cli.js'), 0o644); - await writeTemplate(root); - - await expect(buildZshRuntime(runtimeTask(), root)).rejects.toMatchObject({ code: 'EACCES' }); - await expect(lstat(path.join(root, 'dist/bin/swatch'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); -}); - -function runtimeTask() { - return { - source: './configs/zsh/zshrc.zsh', - output: './dist/zsh/.zshrc', - opts: { - executables: [ - { - name: 'swatch', - source: './packages/swatch/dist/cli.js', - output: './dist/bin/swatch', - }, - ], - completions: [ - { - name: 'swatch completion', - source: './packages/swatch/dist/completions/_swatch', - output: './dist/completions/_swatch', - }, - ], - sources: ['./configs/zsh/completion.zsh', './configs/zsh/aliases.zsh'], - }, - }; -} - -async function writeTemplate(root: string): Promise { - await writeSource( - root, - 'configs/zsh/zshrc.zsh', - 'path=(\n{{ executable_dirs }}\n $path\n)\nfpath=(\n{{ completion_dirs }}\n $fpath\n)\n', - ); - await writeSource(root, 'configs/zsh/completion.zsh', 'autoload -Uz compinit\ncompinit\n'); - await writeSource(root, 'configs/zsh/aliases.zsh', '# Aliases\nalias ll="ls -l"\n'); -} - -async function writeSource(root: string, relativePath: string, content = 'source'): Promise { - const filePath = path.join(root, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content); -} - -async function writeRuntimeSources(root: string): Promise { - await writeSource(root, 'packages/swatch/dist/cli.js'); - await chmod(path.join(root, 'packages/swatch/dist/cli.js'), 0o755); - await writeSource(root, 'packages/swatch/dist/completions/_swatch'); -} - -async function temporaryDirectory(): Promise { - const directory = await mkdtemp(path.join(tmpdir(), 'forge-zsh-runtime-')); - temporaryDirectories.push(directory); - return directory; -}