diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 6aed4c8..fac064f 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -1,23 +1,19 @@ -name: "Setup Bun" -description: "Setup Bun with caching and install dependencies" +name: Setup +description: Install Bun, Node and dependencies runs: - using: "composite" + using: composite steps: - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@v2 with: bun-version-file: package.json - - name: Cache ~/.bun - id: cache-bun - uses: actions/cache@v4 + # Tests run the CLI's TypeScript directly, which needs Node >= 22.18. + # Node 24 also ships the npm needed for trusted publishing. + - uses: actions/setup-node@v5 with: - path: ~/.bun - key: ${{ runner.os }}-bun-${{ hashFiles('package.json') }}-${{ hashFiles('bun.lockb', 'bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-${{ hashFiles('package.json') }}- + node-version: 24 + registry-url: https://registry.npmjs.org - - name: Install dependencies - run: bun install + - run: bun install --frozen-lockfile shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b036d47..eab7f45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,26 +2,25 @@ name: CI on: push: - branches: [main, next] + branches: [main] pull_request: - branches: [main, next] jobs: - typecheck: - runs-on: ubuntu-latest + check: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ./.github/actions/setup - - name: Lint check - run: bun run check:lint + - run: bun run lint + - run: bun run typecheck + - run: bun run test + - run: bun run build - - name: Type check - run: bun run check:types - - - name: Build - run: bun run build - - - name: Tests - run: bun run test + - name: Run the bundled CLI under Node + run: node dist/index.mjs --version diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3785855..3818d08 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,7 +27,7 @@ jobs: version: ${{ steps.release.outputs.version }} tag: ${{ steps.release.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.GH_PAT }} @@ -50,13 +50,11 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.release.outputs.tag }} - uses: ./.github/actions/setup - - name: Publish to NPM + - name: Publish to npm run: bun run scripts/publish.ts - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 29f154c..dc94da7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ Thumbs.db .vscode/ *.swp *.swo + +# Local scratch (test sandboxes) +.scratch/ diff --git a/AGENTS.md b/AGENTS.md index e1564d5..cfea481 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,26 @@ # AGENTS.md -Syncode is a Node.js/Bun TypeScript CLI that syncs AI agent configurations via adapter-based git + symlink/copy workflows. +syncode is a TypeScript CLI, published to npm, that links AI coding agents' instructions, skills and adopted config files from one git repo into each agent's config folder. -## Essentials -- Runtime: Node.js >=20 -- Package manager: bun -- Non-standard commands: - - `bun run dev` - - `bun run build` - - `bun run typecheck` +## Commands -## More details -- `docs/agents/commands.md` -- `docs/agents/project-structure.md` -- `docs/agents/architecture.md` -- `docs/agents/code-style.md` -- `docs/agents/feature-development.md` -- `docs/agents/troubleshooting.md` -- `docs/agents/roadmap.md` +- Package manager: bun. Runtime: Node >= 22.12 for the published CLI, >= 22.18 to run from source. +- `bun run dev -- `: run from source with Node's type stripping. +- `bun run check`: lint (Biome), typecheck (`tsc`), test (Vitest), build (tsdown). Run before pushing. + +## Layout + +- `src/index.ts`: argument parsing and command dispatch. +- `src/agents.ts`: the supported agents as data: config folder, instructions file, skills behavior. Adding an agent is one entry here, plus the README table. +- `src/plan.ts`: works out every link from the repo and enabled agents. `apply`, `status` and `unlink` all use it. +- `src/files.ts`: all filesystem changes, including conflict handling. Its dry-run mode is what makes `status` an exact preview of `apply`. +- `src/commands/`: one file per command group; `migrate.ts` upgrades 1.x setups. +- `tests/`: end-to-end tests that run the real CLI against a sandbox home under `.scratch/`. + +## Conventions + +- Only Node built-ins plus `@clack/prompts`. Prefer data over per-agent classes. +- Never prompt when stdin or stdout isn't a TTY; keep the local file and report it instead. +- Expected failures throw `UserError` (printed without a stack). No `require`: the bundle is ESM. +- Test through the CLI boundary in `tests/`. Don't export internals or add flags just for tests. +- Comments explain why, not what. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5f60ea..5490189 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,46 +1,20 @@ # Contributing to syncode -Thank you for your interest in contributing to syncode! +Thanks for helping out. Issues and pull requests are welcome at https://github.com/donnes/syncode. -## Development Setup +## Setup -This project requires Node.js β‰₯20 or Bun. Install dependencies with: +You need Bun and Node.js 22.18 or later. ```bash bun install +bun run dev -- --help ``` -## Development Workflow +## Before opening a pull request -- Run in dev mode: `bun run dev` -- Build for production: `bun run build` -- Type check: `bun run typecheck` -- Link globally: `bun link` - -## Code Style - -Follow the guidelines in [AGENTS.md](AGENTS.md). Key points: - -- Use double quotes for strings -- ES module syntax (import/export) -- Prefer interfaces for shared shapes -- Async/await for shell calls -- Keep functions small and single-purpose - -## Testing - -No test framework is currently configured. If you add tests, update this section and document in AGENTS.md. - -## Submitting Changes - -1. Fork the repository -2. Create a feature branch -3. Make your changes following the code style -4. Test your changes -5. Submit a pull request with a clear description - -## Reporting Issues - -Report issues at https://github.com/anomalyco/opencode/issues +```bash +bun run check # lint, typecheck, test, build +``` -For questions, use the help command: `syncode help` \ No newline at end of file +[AGENTS.md](AGENTS.md) describes the layout and conventions. To support a new agent, add an entry to `src/agents.ts` and the README table, with a link to the docs showing where it reads instructions and skills. diff --git a/README.md b/README.md index 013711a..89df960 100644 --- a/README.md +++ b/README.md @@ -1,333 +1,96 @@ -# syncode - Agent Configuration Manager +# syncode -[![npm version](https://badge.fury.io/js/%40donnes%2Fsyncode.svg)](https://www.npmjs.com/package/@donnes/syncode) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![npm version](https://img.shields.io/npm/v/@donnes/syncode)](https://www.npmjs.com/package/@donnes/syncode) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -**Stop fighting with AI agent configurations.** Sync your Claude Code, Cursor, Windsurf, OpenCode, and VSCode settings across machines and projects with a single command. +Keep your AI coding agents' instructions, skills and config files in one git repo, and link them into every agent on every machine. +- **One set of instructions** for Claude Code (`CLAUDE.md`), Codex and OpenCode (`AGENTS.md`) and Gemini CLI (`GEMINI.md`), with optional per-OS and per-machine parts. +- **One skills folder** linked into `~/.agents/skills` (Codex, OpenCode, Gemini CLI, Cursor, Copilot CLI) and `~/.claude/skills`. +- **Only what you choose.** Config files are synced only after you `adopt` them, and credentials like `auth.json` are refused, so sessions, caches and tokens never reach git. +- **Safe to rerun.** `apply` is idempotent, `status` previews it exactly, and differing local files are never overwritten without asking. -The era of AI-powered coding is here, but managing multiple AI assistants is a mess. `syncode` solves this. - -## Why syncode? - -**The Problem:** You use Cursor at work, Claude Code for side projects, and want to try Windsurf or Roo Code. Each has its own config format and setup. Keeping 17+ AI agents in sync is manual hell. - -**The Solution:** `syncode` is your single source of truth for AI agent configurations. - -image - -## Features - -- πŸ€– **17+ AI Agents** - Claude Code, Cursor, Windsurf, OpenCode, VSCode, GitHub Copilot, Roo Code, Goose, Gemini CLI, Amp, Kilo Code, Kiro CLI, Trae, Codex, Antigravity, Clawdbot, Droid -- πŸ”„ **Smart Sync** - Automatic detection and sync with smart defaults per agent -- πŸ”— **Symlinks & Copy** - Symlinks for live sync, copy for Claude (preserves cache) -- πŸ“¦ **Version Control** - Git-based workflow for your AI configurations -- πŸ›‘οΈ **Safe Operations** - Automatic backups before any changes -- πŸ–₯️ **Machine Setup** - Opinionated development dependencies for macOS/Linux -- 🌍 **Cross-Platform** - macOS, Linux (Windows coming soon) - -## Quick Start - -### Option 1: npx (No Install Required) +## Quick start ```bash -npx @donnes/syncode new -``` +npm install -g @donnes/syncode # or: bun add -g @donnes/syncode -### Already Have a Repo? - -```bash +# First machine: start a repo from the instructions and skills you already have syncode init -``` - -### Option 2: Global Install - -```bash -# Install globally -npm install -g @donnes/syncode - -# Or using bun -bun install -g @donnes/syncode - -# Initialize -syncode new -``` - -## Usage - -### Initialize a New Repo - -```bash -syncode new -``` - -This will: -- Auto-detect installed AI agents (17+ agents supported) -- Let you select which agents to sync -- Create a git repository for your configs -- Import your existing configs -- Set up smart sync defaults (symlinks for most, copy for Claude, Gemini, etc.) - -### Initialize from an Existing Repo - -```bash -syncode init -``` - -This will: -- Prompt for the repo URL and local storage path -- Clone the repo if needed -- Let you choose which agents to sync -- Save configuration to `~/.syncode/config.json` - -### Sync Agents Config - -```bash -syncode sync -``` - -Choose direction: -- **Import**: Copy configs from system to repo (before committing changes) -- **Export**: Sync configs from repo to system (on new machines) - -### Check Status - -```bash -syncode status -``` - -Shows: -- Which agents are synced -- Sync method (symlink vs copy) -- Git status -- Option to run full machine status - -### Machine Dependencies - -```bash -syncode machine deps -``` - -Install opinionated development dependencies for your machine: -- **Unified script**: `install.sh` with curl-based installs where possible -- **Curl-installed**: bun, fnm, uv, atuin, zoxide, fzf, opencode -- **Package manager fallback**: ripgrep, fd, eza, gh, lazygit, go, rbenv, biome, aws-cli, bob -- **macOS only**: OrbStack, Raycast, opencode-desktop (via Homebrew casks) -This is a boilerplate file that gets copied to your repo root during `syncode new`. Edit it to customize the tools for your workflow. - -### Machine Status - -```bash -syncode machine status -``` - -Shows comprehensive machine setup status: -- Platform and package manager detection -- Repository and git status -- Dependency file presence -- Quick health check for your development environment - -### Push to Remote - -```bash -syncode push +# Other machines: clone your repo and link it +syncode init git@github.com:/agent-configs.git ``` -Push your config changes to the remote repository: -- Detects uncommitted changes -- Optionally commits changes with a message -- Pushes to the configured remote branch - -## Supported Agents +Requires Node.js 22.12 or later, git, and macOS or Linux. -| Agent | Config Path | Sync Method | Auto-Detect | -|-------|-------------|-------------|-------------| -| **Amp** | `~/.config/amp` | Symlink | βœ… Yes | -| **Antigravity** | `~/.gemini/antigravity` | Copy | βœ… Yes | -| **Claude Code** | `~/.claude` | Copy | βœ… Yes | -| **Clawdbot** | `~/.clawdbot` | Symlink | βœ… Yes | -| **Codex** | `~/.codex` | Symlink | βœ… Yes | -| **Cursor** | `~/Library/Application Support/Cursor/User` | Symlink | βœ… Yes | -| **Droid** | `~/.factory` | Symlink | βœ… Yes | -| **Gemini CLI** | `~/.gemini` | Copy | βœ… Yes | -| **GitHub Copilot** | `~/.copilot` | Copy | βœ… Yes | -| **Goose** | `~/.config/goose` | Symlink | βœ… Yes | -| **Kilo Code** | `~/.kilocode` | Symlink | βœ… Yes | -| **Kiro CLI** | `~/.kiro` | Symlink | βœ… Yes | -| **OpenCode** | `~/.config/opencode` | Symlink | βœ… Yes | -| **Roo Code** | `~/.roo` | Symlink | βœ… Yes | -| **Trae** | `~/.trae` | Symlink | βœ… Yes | -| **VSCode** | `~/Library/Application Support/Code/User` | Symlink | βœ… Yes | -| **Windsurf** | `~/.codeium/windsurf` | Symlink | βœ… Yes | - -## Configuration +## Commands -Global configuration is stored at `~/.syncode/config.json`: +| Command | What it does | +| ------------------------- | -------------------------------------------------------------------------------------------------- | +| `syncode init [repo]` | Set up this machine from a git URL or local path, or start a new repo. Migrates a 1.x setup. | +| `syncode apply` | Link the repo's instructions, skills and config files into each agent. | +| `syncode status` | Show what `apply` would change, plus uncommitted repo changes. `--json` for scripts. | +| `syncode adopt ...` | Move a local config file or skill folder into the repo and link it back. | +| `syncode pull` | `git pull --ff-only`, then `apply`. | +| `syncode push` | Commit any repo changes (`-m` for the message) and push. | +| `syncode unlink` | Replace every link with a copy of its file, so you can stop using syncode without losing anything. | -```json -{ - "version": "1.0.0", - "repoPath": "~/.syncode/repo", - "remote": "https://github.com//configs.git", - "agents": ["opencode", "claude", "cursor", "windsurf"], - "features": { - "autoSync": false, - "backupBeforeExport": true, - "smartSyncDefaults": true - } -} -``` +`apply` and `unlink` accept `--dry-run`. `init` accepts `--agents claude,codex` to skip the agent picker. -## Repository Structure +## Repository layout ``` ~/.syncode/repo/ -β”œβ”€β”€ .git/ -β”œβ”€β”€ .gitignore -β”œβ”€β”€ install.sh -β”œβ”€β”€ README.md -β”œβ”€β”€ .agents/ # Shared skills (symlinked) -β”‚ └── skills/ -└── configs/ - β”œβ”€β”€ amp/ # Symlinked - β”œβ”€β”€ antigravity/ # Copy sync - β”œβ”€β”€ claude/ # Copy sync (preserves cache) - β”œβ”€β”€ clawdbot/ # Symlinked - β”œβ”€β”€ codex/ # Symlinked - β”œβ”€β”€ cursor/ # Symlinked - β”œβ”€β”€ droid/ # Symlinked - β”œβ”€β”€ gemini-cli/ # Copy sync - β”œβ”€β”€ github-copilot/ # Copy sync - β”œβ”€β”€ goose/ # Symlinked - β”œβ”€β”€ kilo/ # Symlinked - β”œβ”€β”€ kiro-cli/ # Symlinked - β”œβ”€β”€ opencode/ # Symlinked - β”œβ”€β”€ roo/ # Symlinked - β”œβ”€β”€ trae/ # Symlinked - β”œβ”€β”€ vscode/ # Symlinked - └── windsurf/ # Symlinked +β”œβ”€β”€ instructions/ +β”‚ β”œβ”€β”€ AGENTS.md # shared instructions +β”‚ β”œβ”€β”€ macos.md # optional, macOS only +β”‚ β”œβ”€β”€ linux.md # optional, Linux only +β”‚ └── local.md # optional, this machine only (git-ignored) +β”œβ”€β”€ skills/ +β”‚ └── /SKILL.md +└── agents/ + β”œβ”€β”€ claude/settings.json # linked to ~/.claude/settings.json + └── codex/config.toml # linked to ~/.codex/config.toml ``` -## Usage Examples +When `instructions/AGENTS.md` is the only part, every agent links straight to it, so edits made through any agent land in the repo. When there are OS or local parts, syncode composes them into a read-only `.generated/AGENTS.md`. Edit the parts and rerun `syncode apply`. -### Daily Workflow +## Supported agents -```bash -# Edit your AI agent configs normally -# Example: ~/.config/opencode/opencode.json -# Example: ~/.agents/skills/my-helper.md -# Changes are synced via symlinks automatically +| Agent | Config folder | Instructions | Skills | +| ------------------ | ----------------------------------- | -------------------------- | ------------------ | +| Claude Code | `$CLAUDE_CONFIG_DIR` or `~/.claude` | `CLAUDE.md` | `~/.claude/skills` | +| Codex | `$CODEX_HOME` or `~/.codex` | `AGENTS.md` | `~/.agents/skills` | +| OpenCode | `~/.config/opencode` | `AGENTS.md` | `~/.agents/skills` | +| Gemini CLI | `~/.gemini` | `GEMINI.md` | `~/.agents/skills` | +| Cursor | `~/.cursor` | (set in Cursor's settings) | `~/.agents/skills` | +| GitHub Copilot CLI | `~/.copilot` | none | `~/.agents/skills` | -# Check what changed -syncode status +Any file under an agent's config folder can be adopted into `agents//`. -# Import changes to repo -syncode sync -# Select "Import" +## Conflicts -# Push to remote (commits and pushes in one command) -syncode push +If a local file differs from the repo copy, `apply` asks whether to keep it, replace it, update the repo from it, or show the diff. It suggests whichever side is newer. Replaced files are backed up to `~/.syncode/backups/`. Outside a terminal (for example over SSH, or when an agent runs syncode), it keeps the local file and reports it. -# Pull changes from remote -syncode pull -``` +## Upgrading from 1.x -## Commands +Run `syncode init`. It detects the 1.x config and: +1. Replaces the 1.x whole-folder symlinks (such as `~/.config/opencode`) with real copies, so every agent keeps working. +2. Moves `.agents/skills/*` to `skills/`, and takes the first `AGENTS.md` or `CLAUDE.md` it finds in `configs/` as `instructions/AGENTS.md`. +3. Links everything the 2.0 way. -- `syncode new` - Initialize a new agent config repository -- `syncode init` - Initialize from an existing agent config repository -- `syncode sync` - Sync agent configs (import or export) -- `syncode status` - Show status of synced agents -- `syncode push` - Push config changes to git remote -- `syncode pull` - Pull config changes from git remote -- `syncode machine deps` - Install machine dependencies (install.sh) -- `syncode machine status` - Show full machine setup status -- `syncode --version` - Show version -- `syncode help` - Show help message +The 1.x `configs/` folder stays in the repo. `syncode adopt` the files you want to keep syncing, then delete `configs/` and `.agents/` and commit. `syncode machine` and dotfiles syncing were removed in 2.0. ## Development -### Building from Source - ```bash -# Clone the repository -git clone https://github.com/donnes/syncode.git -cd syncode - -# Install dependencies bun install - -# Run in development mode -bun run dev - -# Build for production -bun run build - -# Link locally -bun link +bun run dev -- status # run from source (Node >= 22.18) +bun run check # lint, typecheck, test, build ``` -### Running Tests - -```bash -# Type check -bun run typecheck - -# Build -bun run build -``` - -## Platform Support - -| Platform | Status | Notes | -|----------|--------|-------| -| macOS | βœ… Fully Supported | Primary development platform | -| Linux | βœ… Fully Supported | Tested on Ubuntu, Arch | -| Windows | 🚧 Planned | Coming soon | - -## Requirements - -- Node.js >= 20.0.0 or Bun >= 1.0.0 -- Git (for repository management) -- macOS or Linux - -## Troubleshooting - -### Configuration not found - -```bash -# Run initialization -syncode new -# Or connect to an existing repo -syncode init -``` - -### Symlinks not working - -```bash -# Check configuration health -syncode status - -# Re-export configs -syncode sync -# Select "Export" -``` - -## Contributing - -Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details. +See [AGENTS.md](AGENTS.md) for how the code is organized. ## License MIT Β© Donald Silveira - -## Support - -- πŸ“– [Documentation](https://github.com/donnes/syncode#readme) -- πŸ› [Report Issues](https://github.com/donnes/syncode/issues) -- πŸ’¬ [Discussions](https://github.com/donnes/syncode/discussions) -- πŸ“¦ [npm Package](https://www.npmjs.com/package/@donnes/syncode) - ---- - -**Made with ❀️ for developers** diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 0260f67..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,18 +0,0 @@ -# Roadmap - -## Upcoming Features - -### Cross-Platform Support - -- [ ] **Windows Compatibility** - Full Windows support for all sync operations, including proper path handling, registry-based configs, and Windows-specific AI agent installations. - -### Skills & Commands - -- [ ] **Vercel-Style Skill Addition** - Streamlined workflow for adding new skills/commands to your agents. Drop in a markdown file, and syncode handles the restβ€”no manual config editing required. - -- [ ] **Universal Skill Format** - Single source of truth for skills, commands, and agent configurations. Write once in canonical format, automatically convert to Claude Code, Cursor, OpenCode, VSCode, and any other AI agent's syntax. No more maintaining the same skill across multiple formats. - -### Automation - -- [ ] **Auto-Push on Changes** - Automatically commit and push config changes to your remote repository when modifications are detected. Keep your configs synced across machines without manual git operations. - diff --git a/biome.json b/biome.json index c516fed..b901124 100644 --- a/biome.json +++ b/biome.json @@ -1,35 +1,29 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { - "includes": [ - "**", - "!!.claude", - "!!.opencode", - "!!node_modules", - "!!.github", - "!!dist" - ] + "includes": ["**", "!!dist", "!!.scratch"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 }, "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "style": { - "noNonNullAssertion": "off", - "noCommonJs": "error" + "noCommonJs": "error", + "noNonNullAssertion": "error" } } }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2 - }, "javascript": { "formatter": { "quoteStyle": "double" @@ -37,8 +31,7 @@ }, "json": { "formatter": { - "indentStyle": "tab", - "indentWidth": 4 + "indentStyle": "tab" } }, "assist": { diff --git a/bun.lock b/bun.lock index 22e2c5b..289c878 100644 --- a/bun.lock +++ b/bun.lock @@ -5,258 +5,282 @@ "": { "name": "ds", "dependencies": { - "@clack/prompts": "^0.9.1", + "@clack/prompts": "^1.8.1", }, "devDependencies": { - "@biomejs/biome": "^2.3.11", - "@opencode-ai/sdk": "^1.1.41", - "@types/bun": "latest", - "@types/node": "^20.0.0", - "tsup": "^8.5.1", - "tsx": "^4.21.0", - "typescript": "^5.7.3", + "@biomejs/biome": "^2.6.2", + "@types/node": "^22.18.0", + "tsdown": "^0.23.1", + "typescript": "~7.0.2", + "vitest": "^5.0.5", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="], + "@biomejs/biome": ["@biomejs/biome@2.6.2", "", { "optionalDependencies": { "@biomejs/cli-android-arm64": "2.6.2", "@biomejs/cli-android-x64": "2.6.2", "@biomejs/cli-darwin-arm64": "2.6.2", "@biomejs/cli-darwin-x64": "2.6.2", "@biomejs/cli-linux-arm64": "2.6.2", "@biomejs/cli-linux-arm64-musl": "2.6.2", "@biomejs/cli-linux-x64": "2.6.2", "@biomejs/cli-linux-x64-musl": "2.6.2", "@biomejs/cli-win32-arm64": "2.6.2", "@biomejs/cli-win32-x64": "2.6.2" }, "bin": { "biome": "bin/biome" } }, "sha512-JLTFAFF9vfJcWwLhwuL4ZHflLg+XfTEC4IUnAlJRX1cMRt57++Xd1Ysb9Fdmlh9YUrvyCXfPEHjjEIqKJGdPKw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="], + "@biomejs/cli-android-arm64": ["@biomejs/cli-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dMpdpH7zFEL5FxuIA9LJW+RoNyC3oeDB7PJyjqVVwtMNoKB5uo8OZYmrVSBikn71txzYwy/3xga7UME6bJQZ6A=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="], + "@biomejs/cli-android-x64": ["@biomejs/cli-android-x64@2.6.2", "", { "os": "android", "cpu": "x64" }, "sha512-XCwEhPBGYzuRA502dRkCcQ+5cefQwuxQR7f9wa4do92dvIcuBL6VMLMFB/fL9hlpZ4dGSfHdpGFSIDlp52mg0A=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rpXtabRgyZNz3aCjd/cRoSZGYi92R9eAXdUUe1sigIsRDyfMwgThaa98Yxt1fiZ7mxquqkAyIHIo08N/e1ToBA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-7GGzlm1GxUKoQ9CNV8tQZ+XXad4sz+v+uzNsWlhaGjsnZqUOVBw1OmAMpG3uNV/Mr0UrCpRJvSK0qvfuqSQOjg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qs4CofCE8MdkjZFfEjswh2NAcbyQ7IVYtGSzQBvLqFlla/CSXroGbktUFndWG/kVSTywadauzAspYWtSfqAc2g=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-I+FYCLVSmR3cYfKcQ+KNeLOZ5w0wRd9fQFlw3JbSvy7bkegvTqdHaBRRcJzHQB/1ldldTLIcG21ka6X2cTWmJw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-FJKsb95418139xDdbv9XnNJsIIwvsgR21Of0aE9B9U+nQlOtRq5gJl2ps0jZUmYBxTaOlP9BKo8N+Ff8+Rui1g=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-wnjEXAK2pDd2j8r9gKI3L+44sIxjkYzS5aHmD1+K57RNJ3sFyT4n22NH6QFPqqeVdRBxN54H3kckUUCrFKCcJw=="], - "@clack/core": ["@clack/core@0.4.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-/PEOo6evlgrQOfKhMNtC/4Gk8ai3hySXXEtjUzCqZe/4iAbMdqelpp7qvZEDyLxlZS+BjOMQFpq2gXI/BPaAqw=="], - "@clack/prompts": ["@clack/prompts@0.9.1", "", { "dependencies": { "@clack/core": "0.4.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-FVlWD+mDDz8nN5LWKRWUhhb7wKIK0VE3oXQvQBX7Fwwj00CvqMVhMyiz1iz8O5aZGmnkhFlKSwJPrUvMh5prQw=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + "@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + "@oxc-project/types": ["@oxc-project/types@0.155.0", "", {}, "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + "@quansync/fs": ["@quansync/fs@1.1.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.15", "", { "os": "android", "cpu": "arm" }, "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.15", "", { "os": "android", "cpu": "arm64" }, "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.15", "", { "os": "linux", "cpu": "arm" }, "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.15", "", { "os": "none", "cpu": "arm64" }, "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.41", "", {}, "sha512-RXTyLl/2O/TM3Oag6JtGHVbK+SKb123szFm6JhvFuF1KCe4+86zjIjISYsaMKzgrd/fnkG0KEKUQxP8Y6xfrVw=="], + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.2", "", { "os": "android", "cpu": "arm" }, "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA=="], + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.2", "", { "os": "android", "cpu": "arm64" }, "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA=="], + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g=="], + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ=="], + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow=="], + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ=="], + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.2", "", { "os": "linux", "cpu": "arm" }, "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw=="], + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.2", "", { "os": "linux", "cpu": "arm" }, "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q=="], + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA=="], + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA=="], + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.2", "", { "os": "linux", "cpu": "none" }, "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA=="], + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.2", "", { "os": "linux", "cpu": "none" }, "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA=="], + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw=="], + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ=="], + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.2", "", { "os": "linux", "cpu": "none" }, "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg=="], + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.2", "", { "os": "linux", "cpu": "none" }, "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q=="], + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ=="], + "@vitest/mocker": ["@vitest/mocker@5.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.2", "", { "os": "linux", "cpu": "x64" }, "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw=="], + "@vitest/spy": ["@vitest/spy@5.0.5", "", {}, "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA=="], + "@yuku-core/binding-android-arm64": ["@yuku-core/binding-android-arm64@0.19.0", "", { "os": "android", "cpu": "arm64" }, "sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw=="], + "@yuku-core/binding-darwin-arm64": ["@yuku-core/binding-darwin-arm64@0.19.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7sNJa6XJhyetnTy64jw3NEi4YgUcezHk1G5GH9FSvXAuC8wIlbaOwT9lgq5qo5sspjrZJQlBR1TWflP49uRpMw=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ=="], + "@yuku-core/binding-darwin-x64": ["@yuku-core/binding-darwin-x64@0.19.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-pTC7dYEc59isdyizc3nsuyFNBu2LU5wMYZ1be6UMr59PY/AJNtO1aBLWV0MgL/z7C1nv/2sX4TNjGgKLoeevJw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ=="], + "@yuku-core/binding-freebsd-x64": ["@yuku-core/binding-freebsd-x64@0.19.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xrZjF6XOEwyLOiDIfrwDdRCS6ghxcJeirNC1KivQhVcUwlRonaU79S3/Uiyx3lBylPnT8qKJ3/aMT+H52nGTJQ=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ=="], + "@yuku-core/binding-linux-arm-gnu": ["@yuku-core/binding-linux-arm-gnu@0.19.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0OyIH53C0I1CbADHn9FSdCfX5qbpeLwJhmOVhonFJNYheybOnkSJ50qGMfTJIb8y5zrwqvLs7FVBvz6Fk8maZg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.2", "", { "os": "win32", "cpu": "x64" }, "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw=="], + "@yuku-core/binding-linux-arm-musl": ["@yuku-core/binding-linux-arm-musl@0.19.0", "", { "os": "linux", "cpu": "arm" }, "sha512-z/iPfkCOCSXE34VXNt0uP1ORSW+4mEa9b5CiJcWZeqZH08g49bFU/jRM2T04f+Wpw5mJ6HIzNBiojxeAbrHjeg=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.2", "", { "os": "win32", "cpu": "x64" }, "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q=="], + "@yuku-core/binding-linux-arm64-gnu": ["@yuku-core/binding-linux-arm64-gnu@0.19.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jLNTi+gzaRwBJ+gwhCJ+rDb5dmx/8hEBx93UWd7hjMSqPTL1D1wvVSvXVAldV5kALvju7b0/m51EHEhs4+I1fA=="], - "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@yuku-core/binding-linux-arm64-musl": ["@yuku-core/binding-linux-arm64-musl@0.19.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YwyPIdW/rN2Fmv0MTFWi47FaB3F9g+2b+sJISyAw83Yd60RWH4T3Nx6XkAHHVN+uKJNBAd6So7/BVihSiFYvuQ=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@yuku-core/binding-linux-x64-gnu": ["@yuku-core/binding-linux-x64-gnu@0.19.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/vskaAXVX3ESR43FiTdfiYS7z1b9uoQJwVQ/0RgUHgN2sWoAyCZs+aX4CgU7eKGIHTVfp9VFwv+c+2XchYiEtQ=="], - "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "@yuku-core/binding-linux-x64-musl": ["@yuku-core/binding-linux-x64-musl@0.19.0", "", { "os": "linux", "cpu": "x64" }, "sha512-FuXL5igeSKib9Rye02TdH+D6SQezTyGxIATMu1Qsn/QywrzwpGl35OLpS2C1x5vpGLeVE2XgiLd5mVPFHmIglQ=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "@yuku-core/binding-win32-arm64": ["@yuku-core/binding-win32-arm64@0.19.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-YuJAe7zEKT1Xa7LuHlhGJpPnYQeVtvo0rZvNjJRqkH0hQGOw4C8QeP30Tg5VKGzZfo2x8CjhjEhKt4TmLwfmdg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "@yuku-core/binding-win32-x64": ["@yuku-core/binding-win32-x64@0.19.0", "", { "os": "win32", "cpu": "x64" }, "sha512-o8iY/mXwlRkedp0LNTr4+TEfXTPQwoNZmv6k+1WIAd1ZXEn8xJJTBCtB0osaVvdZo/x/cNmV16UrWrrGpM5Mvw=="], - "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "@yuku-toolchain/types": ["@yuku-toolchain/types@0.19.0", "", {}, "sha512-tFkNbsP4WXX9UMUC5syJGdK9dPMLK0PPaIuXCcCYH75sZ8MrebfBZ2XvCILRTw8AgLivSSCfDa4R67iYBDa4Kg=="], - "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cac": ["cac@7.1.0", "", {}, "sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], - "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="], - "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "get-tsconfig": ["get-tsconfig@5.0.0-beta.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw=="], - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "hookable": ["hookable@6.1.2", "", {}, "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg=="], - "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "import-without-cache": ["import-without-cache@0.4.1", "", {}, "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A=="], - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], + + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "rollup": ["rollup@4.55.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.2", "@rollup/rollup-android-arm64": "4.55.2", "@rollup/rollup-darwin-arm64": "4.55.2", "@rollup/rollup-darwin-x64": "4.55.2", "@rollup/rollup-freebsd-arm64": "4.55.2", "@rollup/rollup-freebsd-x64": "4.55.2", "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", "@rollup/rollup-linux-arm-musleabihf": "4.55.2", "@rollup/rollup-linux-arm64-gnu": "4.55.2", "@rollup/rollup-linux-arm64-musl": "4.55.2", "@rollup/rollup-linux-loong64-gnu": "4.55.2", "@rollup/rollup-linux-loong64-musl": "4.55.2", "@rollup/rollup-linux-ppc64-gnu": "4.55.2", "@rollup/rollup-linux-ppc64-musl": "4.55.2", "@rollup/rollup-linux-riscv64-gnu": "4.55.2", "@rollup/rollup-linux-riscv64-musl": "4.55.2", "@rollup/rollup-linux-s390x-gnu": "4.55.2", "@rollup/rollup-linux-x64-gnu": "4.55.2", "@rollup/rollup-linux-x64-musl": "4.55.2", "@rollup/rollup-openbsd-x64": "4.55.2", "@rollup/rollup-openharmony-arm64": "4.55.2", "@rollup/rollup-win32-arm64-msvc": "4.55.2", "@rollup/rollup-win32-ia32-msvc": "4.55.2", "@rollup/rollup-win32-x64-gnu": "4.55.2", "@rollup/rollup-win32-x64-msvc": "4.55.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg=="], + "rolldown": ["rolldown@1.2.15", "", { "dependencies": { "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-x64-msvc": "1.2.15" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g=="], - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.28.8", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.6", "obug": "^3.0.0", "yuku-ast": "^0.19.0", "yuku-codegen": "^0.19.0", "yuku-parser": "^0.19.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "@vue/language-core": "~3.2.0 || ~3.3.0", "rolldown": "^1.2.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "@vue/language-core", "typescript", "vue-tsc"] }, "sha512-FVhfqxorCWcc20BVT5eVRfRjzLLl6mgzjcMR4gSBf0K4oQMXT0qoHhhzfXy0L7y83fQy7Ug5ZDNk90xM3/LiCQ=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], - "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "tsdown": ["tsdown@0.23.1", "", { "dependencies": { "cac": "^7.0.1", "defu": "^6.1.7", "empathic": "^2.1.0", "hookable": "^6.1.2", "import-without-cache": "^0.4.1", "obug": "^3.0.0", "picomatch": "^4.0.7", "rolldown": "~1.2.13", "rolldown-plugin-dts": "^0.28.7", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.5.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.23.1", "@tsdown/exe": "0.23.1", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": ">=0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-MSC2WSOts3in95J334/VIS7KPfor3eCSvndJ6KdoVj9Aak9Kh1aNfz9ws5owhdukuTNSXRdfkPEzA/ihpDD6SA=="], - "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "verkit": ["verkit@0.5.0", "", {}, "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], + + "vitest": ["vitest@5.0.5", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.5", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.5", "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.5", "@vitest/coverage-v8": "5.0.5", "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg=="], + + "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], + + "yuku-ast": ["yuku-ast@0.19.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.19.0" } }, "sha512-PyCsiScaelhxBGzL8dyeJkLqvRZycoh78R31Vrt/WqYrm0hMFoKwIw/X7Dx5mBKLWAMlV9I9HEyBOHnj2gZVyw=="], + + "yuku-codegen": ["yuku-codegen@0.19.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.19.0" } }, "sha512-uymAKHjDuaP4BEpkXC/BSLz4DUEF5oArZ5AuD3888nb2miChjWSgpPdojEJxm3pjpXkgUaCRJQyNAbnMN33wHg=="], + + "yuku-core": ["yuku-core@0.19.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.19.0" }, "optionalDependencies": { "@yuku-core/binding-android-arm64": "0.19.0", "@yuku-core/binding-darwin-arm64": "0.19.0", "@yuku-core/binding-darwin-x64": "0.19.0", "@yuku-core/binding-freebsd-x64": "0.19.0", "@yuku-core/binding-linux-arm-gnu": "0.19.0", "@yuku-core/binding-linux-arm-musl": "0.19.0", "@yuku-core/binding-linux-arm64-gnu": "0.19.0", "@yuku-core/binding-linux-arm64-musl": "0.19.0", "@yuku-core/binding-linux-x64-gnu": "0.19.0", "@yuku-core/binding-linux-x64-musl": "0.19.0", "@yuku-core/binding-win32-arm64": "0.19.0", "@yuku-core/binding-win32-x64": "0.19.0" } }, "sha512-kYK9e1XdfirNhkipzW0wmMcSWcVF/CWnuugodfVYRCUwvoBefweL9HzB96OiKEhsx78V5WbUxbJqlizMX869Qg=="], - "bun-types/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + "yuku-parser": ["yuku-parser@0.19.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.19.0", "yuku-core": "0.19.0" } }, "sha512-XrBh9ZiQK5mJVRxZBAvD6LwRyAYrK3MFtH3+onyuxFL5hFD44EC3QOmIC1FjTj6H2MMwq46/NAI+NZxLZPZgxg=="], - "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], } } diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md deleted file mode 100644 index ee9aafa..0000000 --- a/docs/agents/architecture.md +++ /dev/null @@ -1,27 +0,0 @@ -# Architecture and Sync Logic - -## Adapter system -- Each agent has an adapter in `src/adapters/` registered in `registry.ts`. - -## Sync strategies -- `symlink` (default): Live sync between system and repo. -- `copy`: One-way copy (used for Claude to preserve cache). - -## Global config -- `~/.syncode/config.json` tracks the repository location. - -## Core interface: `AgentAdapter` -- `id`: Unique lowercase identifier. -- `getConfigPath(platform)`: Resolves the system path for the agent's config. -- `getRepoPath(repoRoot)`: Determines where the config is stored in the sync repository. -- `import(system, repo)`: Logic to bring configs into the repository. -- `export(repo, system)`: Logic to apply configs to the system. -- `isInstalled(platform)`: Detection logic for the agent. - -## How to add a new adapter - -1. Create `src/adapters/.ts` implementing `AgentAdapter` -2. Export the adapter in `src/adapters/index.ts` -3. Register it in `src/registry.ts` -4. Add platform detection in `src/platforms/` if needed -5. Test with `syncode status` to verify detection diff --git a/docs/agents/code-style.md b/docs/agents/code-style.md deleted file mode 100644 index 1fe3b47..0000000 --- a/docs/agents/code-style.md +++ /dev/null @@ -1,28 +0,0 @@ -# Code Style Guidelines - -## Formatting and imports -- Use double quotes for strings and semicolons for statements. -- Use trailing commas in object/array literals. -- Use ES module syntax (`import`/`export`). No `require()`. -- Order: external packages first, then relative imports. -- Use `import type` for type-only imports. -- Prefer named imports; use `import * as p` for `@clack/prompts`. - -## Casing and naming -- File names: lowercase with dashes (e.g., `agent-adapter.ts`). -- Functions/variables: `camelCase`. -- Classes/interfaces: `PascalCase`. -- Constants: `SCREAMING_SNAKE_CASE` (only for true constants). -- Adapter IDs: lowercase (e.g., "claude"). - -## Types and error handling -- Prefer interfaces for shared shapes. Use JSDoc for public fields. -- Avoid `any`. Use `unknown` if necessary with type guards. -- Return structured results (`{ success: boolean, message: string }`) instead of throwing when possible in CLI flows. -- Use `try/catch` around filesystem and shell operations. - -## Async and filesystem -- Use `async`/`await` for shell calls and long-running operations. -- Use helpers from `src/utils/fs.ts` and `src/utils/git.ts`. -- Always ensure parent directories exist before writing. -- Respect symlinks: avoid overwriting without backups. diff --git a/docs/agents/commands.md b/docs/agents/commands.md deleted file mode 100644 index 09a9e36..0000000 --- a/docs/agents/commands.md +++ /dev/null @@ -1,69 +0,0 @@ -# Commands - -## Tooling -- Install dependencies: `bun install` -- Run CLI in dev mode: `bun run dev` (uses tsx) -- Build for production: `bun run build` (uses tsup) -- Type check: `bun run typecheck` (uses `tsc --noEmit`) -- Link CLI globally: `bun link` -- CLI usage help: `syncode help` - -## Release/build checklist - -- [ ] Update version in `package.json` -- [ ] Run `bun run build` to verify production build -- [ ] Run `bun run typecheck` to verify types -- [ ] Test CLI locally: `bun link && syncode status` -- [ ] Create git tag: `git tag v` -- [ ] Push tag: `git push --tags` -- [ ] Publish to npm (if applicable): `npm publish` - -## Notes -- No lint script or test runner currently configured. -- No single-test command available yet. If you add tests, document them here. - -## CLI commands - -### `syncode new` -- Purpose: Initializes a new agent configuration repository. -- Actions: - - Detects installed AI agents on the current system. - - Prompts for a local repository path and an optional GitHub remote. - - Creates the directory structure, `.gitignore`, and an initial git commit. - - Imports existing configurations from system paths to the repository. - - Persists global settings in `~/.syncode/config.json`. - -### `syncode init` -- Purpose: Initializes syncode from an existing configuration repository. -- Actions: - - Prompts for the repo URL and local repository path. - - Clones the repo if it does not exist locally. - - Prompts for which agents to sync (preselects agents found in the repo). - - Persists global settings in `~/.syncode/config.json`. - -### `syncode sync` -- Purpose: Synchronizes agent configurations between the system and the repository. -- Modes: - - Import (System -> Repo): Copies local configuration files into the repository. - - Export (Repo -> System): Applies repository configurations to the system. -- Safety: Automatically creates backups of existing system configurations. - -### `syncode unsync` -- Purpose: Removes symlink-based configs and copies them back to the local machine. -- Actions: - - Replaces symlinked agent configs with real files in system paths. - - Skips agents that use copy-based syncing. - -### `syncode status` -- Purpose: Displays the current synchronization state. -- Output: - - List of detected and enabled agents. - - Active sync method for each agent (symlink vs. copy). - - Current git status of the configuration repository. - -### `syncode push` -- Purpose: Commits and pushes changes to the remote repository. -- Actions: - - Stages all changes in the configuration directory. - - Prompts for a commit message. - - Executes `git push` to the configured remote. diff --git a/docs/agents/feature-development.md b/docs/agents/feature-development.md deleted file mode 100644 index 9aaac8f..0000000 --- a/docs/agents/feature-development.md +++ /dev/null @@ -1,10 +0,0 @@ -# Feature Development - -## New agent adapters -1. Implement `AgentAdapter` in `src/adapters/types.ts`. -2. Register in `src/adapters/registry.ts`. -3. Define platform-specific path resolution in `getConfigPath()`. - -## New commands -1. Place in `src/commands/` and export a single async function. -2. Register in `src/index.ts` in the `commands` object and `showHelp()`. diff --git a/docs/agents/project-structure.md b/docs/agents/project-structure.md deleted file mode 100644 index a74258b..0000000 --- a/docs/agents/project-structure.md +++ /dev/null @@ -1,10 +0,0 @@ -# Project Structure - -- Entry point: `src/index.ts` (shebang: `#!/usr/bin/env node`) -- Build output: `dist/` -- `src/commands/`: User-facing commands (new, init, sync, status, push). -- `src/adapters/`: Agent adapters implementing `AgentAdapter`. -- `src/config/`: Configuration management (manager.ts, types.ts). -- `src/utils/`: Shared helpers (fs, git, paths, shell, platform). -- `configs/`: In the user's repo, stores tracked agent configs. -- `.agents/`: In the user's repo, stores shared skills (symlinked). diff --git a/docs/agents/roadmap.md b/docs/agents/roadmap.md deleted file mode 100644 index afdd55c..0000000 --- a/docs/agents/roadmap.md +++ /dev/null @@ -1,6 +0,0 @@ -# Future Roadmap - -- Windows support (path resolution and symlink handling). -- Canonical skill format implementation (Phase 4). -- Automatic periodic background sync. -- Support for more editors/agents. diff --git a/docs/agents/troubleshooting.md b/docs/agents/troubleshooting.md deleted file mode 100644 index c7dbba7..0000000 --- a/docs/agents/troubleshooting.md +++ /dev/null @@ -1,6 +0,0 @@ -# Common Pitfalls and Troubleshooting - -- Missing agents: If an agent is not detected, check `getConfigPath()` in its adapter. -- Permission errors: Filesystem operations (symlinks) may require specific permissions. -- Symlink conflicts: If a file exists where a symlink should be, the CLI prompts for backup/overwrite. -- Claude history: Claude Code uses the `copy` strategy because local sqlite databases do not behave well with symlinks. diff --git a/package.json b/package.json index 8d21f82..e04733f 100644 --- a/package.json +++ b/package.json @@ -1,48 +1,43 @@ { "name": "@donnes/syncode", "version": "1.1.9", - "description": "Sync AI code agent configs (Claude Code, Cursor, Windsurf, OpenCode) across machines and projects.", + "description": "Sync AI coding agent instructions, skills and configs (Claude Code, Codex, OpenCode, Gemini CLI, Cursor, Copilot) across machines.", "private": false, "type": "module", "bin": { - "syncode": "./dist/index.js" + "syncode": "./dist/index.mjs" }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist", "README.md", "LICENSE" ], "scripts": { - "build": "tsup", - "dev": "tsx src/index.ts", - "test": "tsx scripts/execute-tests.ts", - "prepublishOnly": "npm run build", - "check:lint": "biome check . --diagnostic-level=error", - "check:unsafe": "biome check . --write --unsafe --diagnostic-level=error", - "check:types": "bunx tsc --noEmit" + "dev": "node src/index.ts", + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "check": "bun run lint && bun run typecheck && bun run test && bun run build", + "prepublishOnly": "bun run build" }, "keywords": [ "ai-agents", + "agent-skills", + "agents-md", "claude-code", - "cursor", - "windsurf", + "codex", "opencode", - "vscode", - "ai-coding", - "code-agent", - "skill-conversion", - "syncode", + "gemini-cli", + "cursor", + "copilot", "dotfiles", - "configuration-management", - "developer-tools", - "ai-assistant", - "coding-assistant" + "syncode" ], "engines": { - "node": ">=20.0.0" + "node": ">=22.12.0" }, + "packageManager": "bun@1.4.3", "repository": { "type": "git", "url": "git+https://github.com/donnes/syncode.git" @@ -54,15 +49,13 @@ "author": "Donald Silveira", "license": "MIT", "dependencies": { - "@clack/prompts": "^0.9.1" + "@clack/prompts": "^1.8.1" }, "devDependencies": { - "@biomejs/biome": "^2.3.11", - "@opencode-ai/sdk": "^1.1.41", - "@types/bun": "latest", - "@types/node": "^20.0.0", - "tsup": "^8.5.1", - "tsx": "^4.21.0", - "typescript": "^5.7.3" + "@biomejs/biome": "^2.6.2", + "@types/node": "^22.18.0", + "tsdown": "^0.23.1", + "typescript": "~7.0.2", + "vitest": "^5.0.5" } } diff --git a/scripts/changelog.ts b/scripts/changelog.ts deleted file mode 100644 index 84e0b9a..0000000 --- a/scripts/changelog.ts +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env bun - -import { parseArgs } from "node:util"; -import { createOpencode } from "@opencode-ai/sdk"; -import { $ } from "bun"; - -export const team = ["donnes", "actions-user"]; - -export async function getLatestRelease() { - return fetch("https://api.github.com/repos/donnes/syncode/releases/latest") - .then((res) => { - if (!res.ok) throw new Error(res.statusText); - return res.json(); - }) - .then((data: { tag_name: string }) => data.tag_name.replace(/^v/, "")); -} - -type Commit = { - hash: string; - author: string | null; - message: string; -}; - -export async function getCommits(from: string, to: string): Promise { - const fromRef = from.startsWith("v") ? from : `v${from}`; - const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`; - - // Get commit data with GitHub usernames from the API - const compare = - await $`gh api "/repos/donnes/syncode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text(); - - const commitData = new Map< - string, - { login: string | null; message: string } - >(); - for (const line of compare.split("\n").filter(Boolean)) { - const data = JSON.parse(line) as { - sha: string; - login: string | null; - message: string; - }; - commitData.set(data.sha, { - login: data.login, - message: data.message.split("\n")[0] ?? "", - }); - } - - // Get commits from the range - const log = - await $`git log ${fromRef}..${toRef} --oneline --format="%H"`.text(); - const hashes = log.split("\n").filter(Boolean); - - const commits: Commit[] = []; - for (const hash of hashes) { - const data = commitData.get(hash); - if (!data) continue; - - const message = data.message; - if (message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue; - - commits.push({ - hash: hash.slice(0, 7), - author: data.login, - message, - }); - } - - return filterRevertedCommits(commits); -} - -function filterRevertedCommits(commits: Commit[]): Commit[] { - const revertPattern = /^Revert "(.+)"$/; - const seen = new Map(); - - for (const commit of commits) { - const match = commit.message.match(revertPattern); - if (match) { - const original = match[1]!; - if (seen.has(original)) seen.delete(original); - else seen.set(commit.message, commit); - } else { - const revertMsg = `Revert "${commit.message}"`; - if (seen.has(revertMsg)) seen.delete(revertMsg); - else seen.set(commit.message, commit); - } - } - - return [...seen.values()]; -} - -async function summarizeCommit( - opencode: Awaited>, - message: string, -): Promise { - console.log("summarizing commit:", message); - const session = await opencode.client.session.create(); - const result = await opencode.client.session - .prompt({ - path: { id: session.data!.id }, - body: { - model: { providerID: "opencode", modelID: "claude-sonnet-4-5" }, - tools: { - "*": false, - }, - parts: [ - { - type: "text", - text: `Summarize this commit message for a changelog entry. Return ONLY a single line summary starting with a capital letter. Be concise but specific. If the commit message is already well-written, just clean it up (capitalize, fix typos, proper grammar). Do not include any prefixes like "fix:" or "feat:". - -Commit: ${message}`, - }, - ], - }, - signal: AbortSignal.timeout(120_000), - }) - .then( - (x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? message, - ); - return result.trim(); -} - -export async function generateChangelog( - commits: Commit[], - opencode: Awaited>, -) { - // Summarize commits in parallel with max 10 concurrent requests - const BATCH_SIZE = 10; - const summaries: string[] = []; - for (let i = 0; i < commits.length; i += BATCH_SIZE) { - const batch = commits.slice(i, i + BATCH_SIZE); - const results = await Promise.all( - batch.map((c) => summarizeCommit(opencode, c.message)), - ); - summaries.push(...results); - } - - const lines: string[] = []; - lines.push("## Changes"); - - for (let i = 0; i < commits.length; i++) { - const commit = commits[i]!; - const attribution = - commit.author && !team.includes(commit.author) - ? ` (@${commit.author})` - : ""; - lines.push(`- ${summaries[i]}${attribution}`); - } - - return lines; -} - -export async function getContributors(from: string, to: string) { - const fromRef = from.startsWith("v") ? from : `v${from}`; - const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`; - const compare = - await $`gh api "/repos/donnes/syncode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text(); - const contributors = new Map>(); - - for (const line of compare.split("\n").filter(Boolean)) { - const { login, message } = JSON.parse(line) as { - login: string | null; - message: string; - }; - const title = message.split("\n")[0] ?? ""; - if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue; - - if (login && !team.includes(login)) { - if (!contributors.has(login)) contributors.set(login, new Set()); - contributors.get(login)!.add(title); - } - } - - return contributors; -} - -export async function buildNotes(from: string, to: string) { - const commits = await getCommits(from, to); - - if (commits.length === 0) { - return []; - } - - console.log(`generating changelog since ${from}`); - - const opencode = await createOpencode({ port: 5044 }); - const notes: string[] = []; - - try { - const lines = await generateChangelog(commits, opencode); - notes.push(...lines); - console.log("---- Generated Changelog ----"); - console.log(notes.join("\n")); - console.log("-----------------------------"); - } catch (error) { - if (error instanceof Error && error.name === "TimeoutError") { - console.log("Changelog generation timed out, using raw commits"); - for (const commit of commits) { - const attribution = - commit.author && !team.includes(commit.author) - ? ` (@${commit.author})` - : ""; - notes.push(`- ${commit.message}${attribution}`); - } - } else { - throw error; - } - } finally { - opencode.server.close(); - } - - const contributors = await getContributors(from, to); - - if (contributors.size > 0) { - notes.push(""); - notes.push( - `**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`, - ); - for (const [username, userCommits] of contributors) { - notes.push(`- @${username}:`); - for (const c of userCommits) { - notes.push(` - ${c}`); - } - } - } - - return notes; -} - -// CLI entrypoint -if (import.meta.main) { - const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - from: { type: "string", short: "f" }, - to: { type: "string", short: "t", default: "HEAD" }, - help: { type: "boolean", short: "h", default: false }, - }, - }); - - if (values.help) { - console.log(` -Usage: bun scripts/changelog.ts [options] - -Options: - -f, --from Starting version (default: latest GitHub release) - -t, --to Ending ref (default: HEAD) - -h, --help Show this help message - -Examples: - bun scripts/changelog.ts # Latest release to HEAD - bun scripts/changelog.ts --from 1.0.0 # v1.0.0 to HEAD - bun scripts/changelog.ts -f 1.0.0 -t 1.1.0 -`); - process.exit(0); - } - - const to = values.to!; - const from = values.from ?? (await getLatestRelease()); - - console.log(`Generating changelog: v${from} -> ${to}\n`); - - const notes = await buildNotes(from, to); - console.log("\n=== Final Notes ==="); - console.log(notes.join("\n")); -} diff --git a/scripts/execute-tests.ts b/scripts/execute-tests.ts deleted file mode 100644 index ebc92a9..0000000 --- a/scripts/execute-tests.ts +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env tsx - -import { spawn } from "node:child_process"; -import { readdir } from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { fileURLToPath } from "node:url"; - -type RunOptions = { - rootDir: string; - testsDir: string; - filter?: RegExp; - listOnly: boolean; -}; - -function parseArgs(argv: string[], rootDir: string): RunOptions { - const testsDir = path.join(rootDir, "tests"); - let filter: RegExp | undefined; - let listOnly = false; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === "--list" || arg === "-l") { - listOnly = true; - continue; - } - if (arg === "--filter" || arg === "-f") { - const pattern = argv[i + 1]; - if (!pattern) throw new Error("Missing value for --filter"); - filter = new RegExp(pattern); - i++; - continue; - } - if (arg === "--help" || arg === "-h") { - console.log( - `Usage: tsx scripts/execute-tests.ts [options]\n\nOptions:\n -l, --list List discovered test files and exit\n -f, --filter Only run tests whose path matches regex\n -h, --help Show help\n`, - ); - process.exit(0); - } - throw new Error(`Unknown argument: ${arg}`); - } - - return { rootDir, testsDir, filter, listOnly }; -} - -async function findTestFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...(await findTestFiles(fullPath))); - continue; - } - if (entry.isFile() && entry.name.endsWith(".test.ts")) { - files.push(fullPath); - } - } - - return files.sort((a, b) => a.localeCompare(b)); -} - -async function runOneTest(rootDir: string, testFile: string): Promise { - return await new Promise((resolve, reject) => { - const child = spawn("tsx", [testFile], { - cwd: rootDir, - stdio: "inherit", - }); - - child.on("error", reject); - child.on("exit", (code) => resolve(code ?? 1)); - }); -} - -async function main(): Promise { - const scriptDir = path.dirname(fileURLToPath(import.meta.url)); - const rootDir = path.resolve(scriptDir, ".."); - const opts = parseArgs(process.argv.slice(2), rootDir); - - let testFiles: string[]; - try { - testFiles = await findTestFiles(opts.testsDir); - } catch { - process.exit(1); - } - - if (opts.filter) { - testFiles = testFiles.filter((f) => opts.filter!.test(f)); - } - - if (testFiles.length === 0) { - process.exit(1); - } - - if (opts.listOnly) { - for (const file of testFiles) - console.log(path.relative(opts.rootDir, file)); - return; - } - - let failed = 0; - for (const testFile of testFiles) { - console.log(`\nβ€” Running ${path.relative(opts.rootDir, testFile)} β€”`); - const exitCode = await runOneTest(opts.rootDir, testFile); - if (exitCode !== 0) failed++; - } - - if (failed > 0) { - process.exit(1); - } - - console.log(`\nAll ${testFiles.length} test file(s) passed.`); -} - -await main(); diff --git a/scripts/publish.ts b/scripts/publish.ts index 31e260a..80e85a1 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -1,29 +1,12 @@ #!/usr/bin/env bun +// Publishes to npm with trusted publishing (GitHub OIDC), so no npm token is +// stored in the repo. Requires npm >= 11.5.1 and the package's trusted +// publisher set to this repo's "Release & Publish" workflow on npmjs.com. import { $ } from "bun"; -// 1. Validation -if (!process.env.NPM_TOKEN) { - console.error("NPM_TOKEN is not set."); - process.exit(1); -} - -console.log("=== syncode publish ===\n"); - -// 2. Build -console.log("Running build..."); await $`bun run build`; +await $`npm publish --access public --provenance`; -// 3. Publish to NPM -console.log("\nPublishing to NPM..."); -try { - await $`npm publish --access public --//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}`; - console.log("NPM publish successful."); -} catch (_error) { - console.error("NPM publish failed."); - process.exit(1); -} - -// 4. Read version for output const pkg = await Bun.file("package.json").json(); -console.log(`\nPublished ${pkg.name}@${pkg.version}`); +console.log(`Published ${pkg.name}@${pkg.version}`); diff --git a/scripts/release.ts b/scripts/release.ts index 1f42c32..3947c92 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,88 +1,42 @@ #!/usr/bin/env bun +// Bumps the version, tags it and creates a GitHub release with generated +// notes. Run by the "Release & Publish" workflow; set PREVIEW=true to only +// print the next version. import { writeFile } from "node:fs/promises"; import { $ } from "bun"; -import { buildNotes, getLatestRelease } from "./changelog"; -// 1. Validation const bump = process.env.BUMP; -if (!bump || !["patch", "minor", "major"].includes(bump)) { - console.error("Missing BUMP env. Use patch, minor, or major."); +if (bump !== "patch" && bump !== "minor" && bump !== "major") { + console.error("Set BUMP to patch, minor or major."); process.exit(1); } - const preview = process.env.PREVIEW === "true"; -// 2. Version Calculation -const pkgFile = Bun.file("package.json"); -const pkg = await pkgFile.json(); -const currentVersion = pkg.version; - -const [major, minor, patch] = currentVersion.split(".").map(Number); -const newVersion = (() => { - switch (bump) { - case "major": - return `${major + 1}.0.0`; - case "minor": - return `${major}.${minor + 1}.0`; - case "patch": - return `${major}.${minor}.${patch + 1}`; - default: - return currentVersion; - } -})(); +const pkg = await Bun.file("package.json").json(); +const [major = 0, minor = 0, patch = 0] = String(pkg.version).split(".").map(Number); +const version = { + major: `${major + 1}.0.0`, + minor: `${major}.${minor + 1}.0`, + patch: `${major}.${minor}.${patch + 1}`, +}[bump]; +const tag = `v${version}`; +console.log(`Bumping ${pkg.version} -> ${version}`); -console.log("=== syncode release ===\n"); -console.log(`Bumping ${currentVersion} -> ${newVersion}`); +await $`bun run check`; -// 3. Generate changelog notes -let notes: string[] = []; if (!preview) { - try { - const previous = await getLatestRelease(); - notes = await buildNotes(previous, "HEAD"); - } catch (_error) { - console.log("Could not generate changelog, using default notes"); - notes = ["No notable changes"]; - } -} - -// 4. Update package.json -pkg.version = newVersion; -await writeFile("package.json", `${JSON.stringify(pkg, null, 2)}\n`); -console.log("Updated package.json"); - -await $`bun check:unsafe`; - -// 5. Git operations -const tag = `v${newVersion}`; -let output = `version=${newVersion}\ntag=${tag}\n`; - -if (!preview) { - console.log("\nCreating git commit and tag..."); + pkg.version = version; + await writeFile("package.json", `${JSON.stringify(pkg, null, "\t")}\n`); await $`git config user.name "github-actions[bot]"`; await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; await $`git add package.json`; - await $`git commit -m "release: ${tag}"`; + await $`git commit -m ${`release: ${tag}`}`; await $`git tag ${tag}`; await $`git push origin HEAD --tags --no-verify`; - - // Wait for tag to propagate - await new Promise((resolve) => setTimeout(resolve, 3_000)); - - // Create GitHub release - console.log("\nCreating GitHub release..."); - await $`gh release create ${tag} --title "${tag}" --notes ${notes.join("\n") || "No notable changes"}`; - - const release = await $`gh release view ${tag} --json id,tagName`.json(); - output += `release=${release.id}\n`; - - console.log(`\nRelease ${tag} created successfully!`); + await $`gh release create ${tag} --title ${tag} --generate-notes`; } -// 6. GitHub Outputs if (process.env.GITHUB_OUTPUT) { - await writeFile(process.env.GITHUB_OUTPUT, output, { flag: "a" }); + await writeFile(process.env.GITHUB_OUTPUT, `version=${version}\ntag=${tag}\n`, { flag: "a" }); } - -console.log(`\nProcess complete for ${tag}`); diff --git a/src/adapters/agents.ts b/src/adapters/agents.ts deleted file mode 100644 index db069bd..0000000 --- a/src/adapters/agents.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Shared Agents adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class AgentsAdapter implements AgentAdapter { - readonly id = "agents"; - readonly name = "Shared Agents"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".agents"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, ".agents"); - } - - getSkillsPath(_platform: Platform): string { - return join(this.getConfigPath(_platform), "skills"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Shared agents config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported shared agents configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Shared agents configs not found in repo", - }; - } - - ensureDir(join(repoPath, "skills")); - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked shared agents configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const agentsAdapter = new AgentsAdapter(); diff --git a/src/adapters/amp.ts b/src/adapters/amp.ts deleted file mode 100644 index 35e883c..0000000 --- a/src/adapters/amp.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Amp adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class AmpAdapter implements AgentAdapter { - readonly id = "amp"; - readonly name = "Amp"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".config/amp"); - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "amp"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Amp config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Amp configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Amp configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - linkSharedSkillsInRepo(repoPath, getSharedSkillsRepoPath(repoPath)); - - return { - success: true, - message: `Linked Amp configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const ampAdapter = new AmpAdapter(); diff --git a/src/adapters/antigravity.ts b/src/adapters/antigravity.ts deleted file mode 100644 index ee5eacd..0000000 --- a/src/adapters/antigravity.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Antigravity adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class AntigravityAdapter implements AgentAdapter { - readonly id = "antigravity"; - readonly name = "Antigravity"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".gemini/antigravity"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "antigravity"); - } - - isInstalled(platform: Platform): boolean { - const homePath = this.getConfigPath(platform); - const projectPath = join(process.cwd(), ".agent"); - return exists(homePath) || exists(projectPath); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Antigravity config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Antigravity configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Antigravity configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked Antigravity configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const antigravityAdapter = new AntigravityAdapter(); diff --git a/src/adapters/claude.ts b/src/adapters/claude.ts deleted file mode 100644 index 42a4602..0000000 --- a/src/adapters/claude.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Claude Code adapter - */ - -import { copyFileSync, existsSync, statSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { - copyDir, - ensureDir, - exists, - isDirectory, - isSymlink, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { getSharedSkillsPath, linkSharedSkillsOnSystem } from "./shared-skills"; -import type { - AgentAdapter, - CanonicalSkill, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class ClaudeAdapter implements AgentAdapter { - readonly id = "claude"; - readonly name = "Claude Code"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "copy" as const, // Copy instead of symlink (Claude stores cache/history) - }; - - // Files/folders to sync (exclude cache, history, etc.) - private syncPatterns = ["settings.json", "CLAUDE.md", "commands"]; - - getConfigPath(platform: Platform): string { - if (platform === "windows") { - return join(process.env.APPDATA || "", "claude"); - } - return join(process.env.HOME || "", ".claude"); - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "claude"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - /** - * Detect if Claude Code is installed on the current system - */ - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - // Claude uses copy strategy, not symlinks - // So we consider it "linked" if the files exist in both places - // (exact sync verification would require comparing file contents) - return exists(systemPath) && exists(repoPath); - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath) || !isDirectory(systemPath)) { - return { - success: false, - message: "Claude config not found on system", - }; - } - - ensureDir(repoPath); - - const filesImported: string[] = []; - - for (const pattern of this.syncPatterns) { - const srcPath = join(systemPath, pattern); - const destPath = join(repoPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (isSymlink(srcPath)) continue; - - if (existsSync(destPath)) continue; - - if (statSync(srcPath).isDirectory()) { - copyDir(srcPath, destPath); - filesImported.push(`${pattern}/`); - } else { - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - filesImported.push(pattern); - } - } - - if (filesImported.length === 0) { - return { - success: true, - message: - "No Claude configs found to import (settings.json, CLAUDE.md, commands/)", - }; - } - - return { - success: true, - message: "Imported Claude configs to repo", - filesImported, - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath) || !isDirectory(repoPath)) { - return { - success: false, - message: "Claude configs not found in repo", - }; - } - - // For Claude, we copy files rather than symlink the whole directory - // because Claude stores cache/history there - ensureDir(systemPath); - - const filesExported: string[] = []; - - for (const pattern of this.syncPatterns) { - const srcPath = join(repoPath, pattern); - const destPath = join(systemPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (existsSync(destPath)) continue; - - if (statSync(srcPath).isDirectory()) { - copyDir(srcPath, destPath); - filesExported.push(`${pattern}/`); - } else { - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - filesExported.push(pattern); - } - } - - linkSharedSkillsOnSystem(join(systemPath, "skills")); - - return { - success: true, - message: `Copied Claude configs to ${contractHome(systemPath)}`, - linkedTo: filesExported.join(", "), - }; - } - - // Template conversion methods (Phase 4) - // Stub implementations for now - fromCanonical(skill: CanonicalSkill): unknown { - // TODO: Convert from canonical format to Claude skill format - return skill; - } - - toCanonical(agentSkill: unknown): CanonicalSkill { - // TODO: Convert from Claude skill format to canonical format - return agentSkill as CanonicalSkill; - } -} - -export const claudeAdapter = new ClaudeAdapter(); diff --git a/src/adapters/clawdbot.ts b/src/adapters/clawdbot.ts deleted file mode 100644 index dcc9cbc..0000000 --- a/src/adapters/clawdbot.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Clawdbot adapter - */ - -import { existsSync, renameSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class ClawdbotAdapter implements AgentAdapter { - readonly id = "clawdbot"; - readonly name = "Clawdbot"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - configPaths = [ - join(process.env.HOME || "", ".clawd"), - join(process.env.HOME || "", ".clawdbot"), - ]; - - getConfigPath(_platform: Platform): string { - for (const path of this.configPaths) { - if (exists(path)) { - return path; - } - } - return this.configPaths[0]!; - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "clawdbot"); - } - - isInstalled(_platform: Platform): boolean { - return this.configPaths.some((path) => existsSync(path)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Clawdbot config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Clawdbot configs to repo", - }; - } - - async export(repoPath: string, _systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Clawdbot configs not found in repo", - }; - } - - const existingPaths = this.configPaths.filter((p) => existsSync(p)); - - if (existingPaths.length > 1) { - return { - success: false, - message: - "Multiple config paths exist (~/.clawd and ~/.clawdbot). Please manually delete one and retry.", - }; - } - - return this.performExport( - repoPath, - existingPaths[0] || this.configPaths[0]!, - ); - } - - private async performExport( - repoPath: string, - systemPath: string, - ): Promise { - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - if (exists(systemPath) && !isSymlink(systemPath)) { - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - removeDir(backupPath); - } - renameSync(systemPath, backupPath); - } - - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked Clawdbot configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const clawdbotAdapter = new ClawdbotAdapter(); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts deleted file mode 100644 index 9b80b65..0000000 --- a/src/adapters/codex.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Codex adapter - */ - -import { renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -// ~/.codex is symlinked into the repo, so Codex's runtime data (sqlite logs, -// sessions, worktrees, plugin binaries, auth.json) lands in the repo too. -// Track only real config. -const CODEX_GITIGNORE = `# Managed by syncode: only sync Codex config, not runtime data -/* -!/.gitignore -!/AGENTS.md -!/config.toml -!/prompts/ -!/rules/ -!/skills -/skills/.system/ -`; - -function writeCodexGitignore(repoPath: string): void { - writeFileSync(join(repoPath, ".gitignore"), CODEX_GITIGNORE); -} - -export class CodexAdapter implements AgentAdapter { - readonly id = "codex"; - readonly name = "Codex"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".codex"); - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "codex"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Codex config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - writeCodexGitignore(repoPath); - - return { - success: true, - message: "Imported Codex configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Codex configs not found in repo", - }; - } - - writeCodexGitignore(repoPath); - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - linkSharedSkillsInRepo(repoPath, getSharedSkillsRepoPath(repoPath)); - - return { - success: true, - message: `Linked Codex configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const codexAdapter = new CodexAdapter(); diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts deleted file mode 100644 index 16e7187..0000000 --- a/src/adapters/cursor.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Cursor adapter - */ - -import { - copyFileSync, - existsSync, - renameSync, - statSync, - symlinkSync, - unlinkSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { - copyDir, - ensureDir, - exists, - getSymlinkTarget, - isDirectory, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { getSharedSkillsPath, linkSharedSkillsOnSystem } from "./shared-skills"; -import type { - AgentAdapter, - CanonicalSkill, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class CursorAdapter implements AgentAdapter { - readonly id = "cursor"; - readonly name = "Cursor"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - // Files to sync - private syncPatterns = [ - "settings.json", - "keybindings.json", - "snippets", - ".cursorrules", - ]; - - getConfigPath(platform: Platform): string { - if (platform === "macos") { - return join( - process.env.HOME || "", - "Library", - "Application Support", - "Cursor", - "User", - ); - } else if (platform === "linux") { - return join(process.env.HOME || "", ".config", "Cursor", "User"); - } else { - // Windows - return join(process.env.APPDATA || "", "Cursor", "User"); - } - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "cursor"); - } - - isInstalled(platform: Platform): boolean { - const configPath = this.getConfigPath(platform); - // Check if parent directory exists (Cursor/User might not exist yet) - const parentDir = dirname(configPath); - return exists(parentDir); - } - - /** - * Detect if Cursor is installed on the current system - */ - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - // Check if settings.json is symlinked - const settingsPath = join(systemPath, "settings.json"); - const repoSettingsPath = join(repoPath, "settings.json"); - - if (!isSymlink(settingsPath)) return false; - - const target = getSymlinkTarget(settingsPath); - return target === repoSettingsPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath) || !isDirectory(systemPath)) { - return { - success: false, - message: "Cursor config not found on system", - }; - } - - ensureDir(repoPath); - - const filesImported: string[] = []; - - for (const pattern of this.syncPatterns) { - const srcPath = join(systemPath, pattern); - const destPath = join(repoPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (isSymlink(srcPath)) continue; - - if (existsSync(destPath)) continue; - - if (statSync(srcPath).isDirectory()) { - copyDir(srcPath, destPath); - filesImported.push(`${pattern}/`); - } else { - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - filesImported.push(pattern); - } - } - - if (filesImported.length === 0) { - return { - success: true, - message: "No Cursor configs found to import", - }; - } - - linkSharedSkillsOnSystem(join(systemPath, "skills")); - - return { - success: true, - message: "Imported Cursor configs to repo", - filesImported, - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath) || !isDirectory(repoPath)) { - return { - success: false, - message: "Cursor configs not found in repo", - }; - } - - ensureDir(systemPath); - - const filesExported: string[] = []; - let backedUp: string | undefined; - - for (const pattern of this.syncPatterns) { - const srcPath = join(repoPath, pattern); - const destPath = join(systemPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (isSymlink(destPath)) { - const target = getSymlinkTarget(destPath); - if (target === srcPath) { - continue; - } - unlinkSync(destPath); - } else if (exists(destPath)) { - const backupPath = `${destPath}.backup`; - if (exists(backupPath)) { - if (isDirectory(backupPath)) { - removeDir(backupPath); - } else { - unlinkSync(backupPath); - } - } - renameSync(destPath, backupPath); - if (!backedUp) { - backedUp = contractHome(dirname(backupPath)); - } - } - - // Create symlink - symlinkSync(srcPath, destPath); - filesExported.push(pattern); - } - - return { - success: true, - message: `Linked Cursor configs to ${contractHome(systemPath)}`, - backedUp, - linkedTo: contractHome(repoPath), - }; - } - - // Template conversion methods (Phase 4) - fromCanonical(skill: CanonicalSkill): unknown { - // TODO: Convert from canonical format to Cursor .cursorrules format - return skill; - } - - toCanonical(agentSkill: unknown): CanonicalSkill { - // TODO: Convert from Cursor .cursorrules to canonical format - return agentSkill as CanonicalSkill; - } -} - -export const cursorAdapter = new CursorAdapter(); diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts deleted file mode 100644 index c1c5721..0000000 --- a/src/adapters/devin.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Devin adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class DevinAdapter implements AgentAdapter { - readonly id = "devin"; - readonly name = "Devin"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - private getConfigPaths(platform: Platform): string[] { - if (platform === "windows") { - return [join(process.env.APPDATA || "", "devin")]; - } - return [ - join(process.env.HOME || "", ".devin"), - join(process.env.HOME || "", ".config", "devin"), - ]; - } - - getConfigPath(platform: Platform): string { - for (const path of this.getConfigPaths(platform)) { - if (exists(path)) { - return path; - } - } - return this.getConfigPaths(platform)[0]!; - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "devin"); - } - - isInstalled(platform: Platform): boolean { - return this.getConfigPaths(platform).some((path) => exists(path)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Devin config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Devin configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Devin configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - createSymlink(repoPath, systemPath); - - linkSharedSkillsInRepo(repoPath, getSharedSkillsRepoPath(repoPath)); - - return { - success: true, - message: `Linked Devin configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const devinAdapter = new DevinAdapter(); diff --git a/src/adapters/dotfiles.ts b/src/adapters/dotfiles.ts deleted file mode 100644 index 90b502d..0000000 --- a/src/adapters/dotfiles.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Dotfiles adapter - */ - -import { - chmodSync, - copyFileSync, - existsSync, - renameSync, - statSync, - symlinkSync, - unlinkSync, -} from "node:fs"; -import { basename, dirname, join } from "node:path"; -import { - copyDir, - ensureDir, - exists, - getSymlinkTarget, - isDirectory, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome, HOME } from "../utils/paths"; -import { isMacOS } from "../utils/platform"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -interface DotfileMapping { - repoPath: string; - systemPath: string; - isDir: boolean; - optional: boolean; -} - -export class DotfilesAdapter implements AgentAdapter { - readonly id = "dotfiles"; - readonly name = "Dotfiles"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - private getGhosttyPath(): string { - return isMacOS - ? join( - HOME, - "Library", - "Application Support", - "com.mitchellh.ghostty", - "config", - ) - : join(HOME, ".config", "ghostty", "config"); - } - - private get dotfilesList(): DotfileMapping[] { - return [ - { - repoPath: ".zshrc", - systemPath: join(HOME, ".zshrc"), - isDir: false, - optional: false, - }, - { - repoPath: ".bashrc", - systemPath: join(HOME, ".bashrc"), - isDir: false, - optional: false, - }, - { - repoPath: ".tmux.conf", - systemPath: join(HOME, ".tmux.conf"), - isDir: false, - optional: true, - }, - { - repoPath: ".ssh/config", - systemPath: join(HOME, ".ssh", "config"), - isDir: false, - optional: false, - }, - { - repoPath: ".config/ghostty/config", - systemPath: this.getGhosttyPath(), - isDir: false, - optional: true, - }, - { - repoPath: ".config/nvim", - systemPath: join(HOME, ".config", "nvim"), - isDir: true, - optional: true, - }, - { - repoPath: ".config/yazi", - systemPath: join(HOME, ".config", "yazi"), - isDir: true, - optional: true, - }, - ]; - } - - getConfigPath(_platform: Platform): string { - return HOME; - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "dotfiles"); - } - - isInstalled(_platform: Platform): boolean { - return true; - } - - detect(): boolean { - return true; - } - - isLinked(_systemPath: string, repoPath: string): boolean { - for (const mapping of this.dotfilesList) { - const systemFile = mapping.systemPath; - const repoFile = join(repoPath, mapping.repoPath); - - if (!isSymlink(systemFile)) continue; - - const target = getSymlinkTarget(systemFile); - if (target === repoFile) { - return true; - } - } - return false; - } - - async import(_systemPath: string, repoPath: string): Promise { - ensureDir(repoPath); - - const filesImported: string[] = []; - const errors: string[] = []; - - for (const mapping of this.dotfilesList) { - const srcPath = mapping.systemPath; - const destPath = join(repoPath, mapping.repoPath); - - if (!existsSync(srcPath)) { - if (!mapping.optional) { - errors.push(`${mapping.repoPath} not found on system`); - } - continue; - } - - if (isSymlink(srcPath)) { - continue; - } - - if (existsSync(destPath)) { - continue; - } - - try { - if (mapping.isDir) { - if (!statSync(srcPath).isDirectory()) { - errors.push(`${mapping.repoPath} expected to be directory`); - continue; - } - copyDir(srcPath, destPath); - filesImported.push(`${mapping.repoPath}/`); - } else { - if (statSync(srcPath).isDirectory()) { - errors.push(`${mapping.repoPath} expected to be file`); - continue; - } - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - filesImported.push(mapping.repoPath); - } - } catch (error) { - errors.push(`Failed to import ${mapping.repoPath}: ${error}`); - } - } - - if (filesImported.length === 0 && errors.length === 0) { - return { - success: true, - message: "No dotfiles found to import", - }; - } - - if (filesImported.length === 0) { - return { - success: false, - message: "Failed to import dotfiles", - errors, - }; - } - - return { - success: true, - message: `Imported ${filesImported.length} dotfile(s) to repo`, - filesImported, - errors: errors.length > 0 ? errors : undefined, - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath) || !isDirectory(repoPath)) { - return { - success: false, - message: "Dotfiles not found in repo", - }; - } - - const filesExported: string[] = []; - const errors: string[] = []; - let backedUp: string | undefined; - - for (const mapping of this.dotfilesList) { - const srcPath = join(repoPath, mapping.repoPath); - const destPath = mapping.systemPath; - - if (!existsSync(srcPath)) { - continue; - } - - try { - ensureDir(dirname(destPath)); - - if (isSymlink(destPath)) { - const target = getSymlinkTarget(destPath); - if (target === srcPath) { - continue; - } - unlinkSync(destPath); - } else if (exists(destPath)) { - const backupPath = `${destPath}.backup`; - if (exists(backupPath)) { - if (isDirectory(backupPath)) { - removeDir(backupPath); - } else { - unlinkSync(backupPath); - } - } - renameSync(destPath, backupPath); - if (!backedUp) { - backedUp = contractHome(dirname(backupPath)); - } - } - - symlinkSync(srcPath, destPath); - - // Handle SSH config permissions - if ( - mapping.repoPath === ".ssh/config" && - basename(destPath) === "config" - ) { - try { - chmodSync(srcPath, 0o600); - } catch (error) { - errors.push( - `Warning: Could not set SSH config permissions: ${error}`, - ); - } - } - - filesExported.push(mapping.repoPath); - } catch (error) { - errors.push(`Failed to export ${mapping.repoPath}: ${error}`); - } - } - - if (filesExported.length === 0) { - return { - success: false, - message: "No dotfiles found in repo to export", - errors, - }; - } - - return { - success: true, - message: `Linked ${filesExported.length} dotfile(s) to ${contractHome(systemPath)}`, - backedUp, - linkedTo: contractHome(repoPath), - errors: errors.length > 0 ? errors : undefined, - }; - } -} - -export const dotfilesAdapter = new DotfilesAdapter(); diff --git a/src/adapters/droid.ts b/src/adapters/droid.ts deleted file mode 100644 index 07b3afe..0000000 --- a/src/adapters/droid.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Droid adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class DroidAdapter implements AgentAdapter { - readonly id = "droid"; - readonly name = "Droid"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".factory"); - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "droid"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Droid config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Droid configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Droid configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - linkSharedSkillsInRepo(repoPath, getSharedSkillsRepoPath(repoPath)); - - return { - success: true, - message: `Linked Droid configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const droidAdapter = new DroidAdapter(); diff --git a/src/adapters/gemini-cli.ts b/src/adapters/gemini-cli.ts deleted file mode 100644 index 69039ef..0000000 --- a/src/adapters/gemini-cli.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Gemini CLI adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class GeminiCliAdapter implements AgentAdapter { - readonly id = "gemini-cli"; - readonly name = "Gemini CLI"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".gemini"); - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "gemini-cli"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Gemini CLI config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Gemini CLI configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Gemini CLI configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - linkSharedSkillsInRepo(repoPath, getSharedSkillsRepoPath(repoPath)); - - return { - success: true, - message: `Linked Gemini CLI configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const geminiCliAdapter = new GeminiCliAdapter(); diff --git a/src/adapters/github-copilot.ts b/src/adapters/github-copilot.ts deleted file mode 100644 index dc98f3f..0000000 --- a/src/adapters/github-copilot.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * GitHub Copilot adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class GithubCopilotAdapter implements AgentAdapter { - readonly id = "github-copilot"; - readonly name = "GitHub Copilot"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".copilot"); - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "github-copilot"); - } - - isInstalled(platform: Platform): boolean { - const homePath = this.getConfigPath(platform); - const projectPath = join(process.cwd(), ".github"); - return exists(homePath) || exists(projectPath); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "GitHub Copilot config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported GitHub Copilot configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "GitHub Copilot configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - linkSharedSkillsInRepo(repoPath, getSharedSkillsRepoPath(repoPath)); - - return { - success: true, - message: `Linked GitHub Copilot configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const githubCopilotAdapter = new GithubCopilotAdapter(); diff --git a/src/adapters/goose.ts b/src/adapters/goose.ts deleted file mode 100644 index b28ed91..0000000 --- a/src/adapters/goose.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Goose adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class GooseAdapter implements AgentAdapter { - readonly id = "goose"; - readonly name = "Goose"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".config/goose"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "goose"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Goose config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Goose configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Goose configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked Goose configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const gooseAdapter = new GooseAdapter(); diff --git a/src/adapters/kilo.ts b/src/adapters/kilo.ts deleted file mode 100644 index 5b072b2..0000000 --- a/src/adapters/kilo.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Kilo Code adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class KiloAdapter implements AgentAdapter { - readonly id = "kilo"; - readonly name = "Kilo Code"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".kilocode"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "kilo"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Kilo Code config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Kilo Code configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Kilo Code configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked Kilo Code configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const kiloAdapter = new KiloAdapter(); diff --git a/src/adapters/kimi-cli.ts b/src/adapters/kimi-cli.ts deleted file mode 100644 index e5051d9..0000000 --- a/src/adapters/kimi-cli.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Kimi CLI adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class KimiCliAdapter implements AgentAdapter { - readonly id = "kimi-cli"; - readonly name = "Kimi CLI"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - private getConfigPaths(platform: Platform): string[] { - if (platform === "windows") { - return [join(process.env.APPDATA || "", "kimi")]; - } - return [ - join(process.env.HOME || "", ".kimi"), - join(process.env.HOME || "", ".config", "kimi"), - ]; - } - - getConfigPath(platform: Platform): string { - for (const path of this.getConfigPaths(platform)) { - if (exists(path)) { - return path; - } - } - return this.getConfigPaths(platform)[0]!; - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "kimi-cli"); - } - - isInstalled(platform: Platform): boolean { - return this.getConfigPaths(platform).some((path) => exists(path)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Kimi CLI config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Kimi CLI configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Kimi CLI configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - createSymlink(repoPath, systemPath); - - linkSharedSkillsInRepo(repoPath, getSharedSkillsRepoPath(repoPath)); - - return { - success: true, - message: `Linked Kimi CLI configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const kimiCliAdapter = new KimiCliAdapter(); diff --git a/src/adapters/kiro-cli.ts b/src/adapters/kiro-cli.ts deleted file mode 100644 index 9924131..0000000 --- a/src/adapters/kiro-cli.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Kiro CLI adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class KiroCliAdapter implements AgentAdapter { - readonly id = "kiro-cli"; - readonly name = "Kiro CLI"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".kiro"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "kiro-cli"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Kiro CLI config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Kiro CLI configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Kiro CLI configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked Kiro CLI configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const kiroCliAdapter = new KiroCliAdapter(); diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts deleted file mode 100644 index d1e9458..0000000 --- a/src/adapters/opencode.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * OpenCode adapter - */ - -import { - copyFileSync, - existsSync, - renameSync, - statSync, - symlinkSync, - unlinkSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { - copyDir, - ensureDir, - exists, - getSymlinkTarget, - isDirectory, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { - getSharedSkillsPath, - getSharedSkillsRepoPath, - linkSharedSkillsInRepo, -} from "./shared-skills"; -import type { - AgentAdapter, - CanonicalSkill, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class OpenCodeAdapter implements AgentAdapter { - readonly id = "opencode"; - readonly name = "OpenCode"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - // Files/folders to sync (exclude node_modules, cache, etc.) - private syncPatterns = ["opencode.json", "command", "agent"]; - - getConfigPath(platform: Platform): string { - // OpenCode uses XDG on all platforms - if (platform === "windows") { - return join(process.env.APPDATA || "", "opencode"); - } - return join(process.env.HOME || "", ".config", "opencode"); - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "opencode"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - /** - * Detect if OpenCode is installed on the current system - */ - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!isSymlink(systemPath)) return false; - const target = getSymlinkTarget(systemPath); - return target === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath) || !isDirectory(systemPath)) { - return { - success: false, - message: "OpenCode config not found on system", - }; - } - - // If already linked, no import needed - if (this.isLinked(systemPath, repoPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - - const filesImported: string[] = []; - - for (const pattern of this.syncPatterns) { - const srcPath = join(systemPath, pattern); - const destPath = join(repoPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (statSync(srcPath).isDirectory()) { - copyDir(srcPath, destPath); - filesImported.push(`${pattern}/`); - } else { - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - filesImported.push(pattern); - } - } - - return { - success: true, - message: "Imported OpenCode configs to repo", - filesImported, - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath) || !isDirectory(repoPath)) { - return { - success: false, - message: "OpenCode configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: contractHome(repoPath), - }; - } - } - - // Backup existing directory if it exists and isn't a symlink - let backedUp: string | undefined; - if (exists(systemPath) && !isSymlink(systemPath)) { - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - removeDir(backupPath); - } - renameSync(systemPath, backupPath); - backedUp = contractHome(backupPath); - } else if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } - - // Create symlink - ensureDir(dirname(systemPath)); - symlinkSync(repoPath, systemPath); - - linkSharedSkillsInRepo( - repoPath, - getSharedSkillsRepoPath(repoPath), - "skill", - ); - - return { - success: true, - message: `Linked ${contractHome(systemPath)} β†’ repo`, - backedUp, - linkedTo: contractHome(repoPath), - }; - } - - // Template conversion methods (Phase 4) - // Stub implementations for now - fromCanonical(skill: CanonicalSkill): unknown { - // TODO: Convert from canonical format to OpenCode skill format - return skill; - } - - toCanonical(agentSkill: unknown): CanonicalSkill { - // TODO: Convert from OpenCode skill format to canonical format - return agentSkill as CanonicalSkill; - } -} - -export const opencodeAdapter = new OpenCodeAdapter(); diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts deleted file mode 100644 index 29841c7..0000000 --- a/src/adapters/registry.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { agentsAdapter } from "./agents"; -import { ampAdapter } from "./amp"; -import { antigravityAdapter } from "./antigravity"; -import { claudeAdapter } from "./claude"; -import { clawdbotAdapter } from "./clawdbot"; -import { codexAdapter } from "./codex"; -import { cursorAdapter } from "./cursor"; -import { devinAdapter } from "./devin"; -import { dotfilesAdapter } from "./dotfiles"; -import { droidAdapter } from "./droid"; -import { geminiCliAdapter } from "./gemini-cli"; -import { githubCopilotAdapter } from "./github-copilot"; -import { gooseAdapter } from "./goose"; -import { kiloAdapter } from "./kilo"; -import { kimiCliAdapter } from "./kimi-cli"; -import { kiroCliAdapter } from "./kiro-cli"; -import { opencodeAdapter } from "./opencode"; -import { rooAdapter } from "./roo"; -import { traeAdapter } from "./trae"; -import type { AdapterRegistryEntry, AgentAdapter } from "./types"; -import { vscodeAdapter } from "./vscode"; -import { windsurfAdapter } from "./windsurf"; - -export class AdapterRegistry { - private adapters: Map = new Map(); - - register(adapter: AgentAdapter, enabled: boolean = true): void { - this.adapters.set(adapter.id, { adapter, enabled }); - } - - get(id: string): AgentAdapter | null { - const entry = this.adapters.get(id); - return entry ? entry.adapter : null; - } - - getAll(): AgentAdapter[] { - return Array.from(this.adapters.values()).map((entry) => entry.adapter); - } - - getEnabled(): AgentAdapter[] { - return Array.from(this.adapters.values()) - .filter((entry) => entry.enabled) - .map((entry) => entry.adapter); - } - - has(id: string): boolean { - return this.adapters.has(id); - } - - enable(id: string): boolean { - const entry = this.adapters.get(id); - if (entry) { - entry.enabled = true; - return true; - } - return false; - } - - disable(id: string): boolean { - const entry = this.adapters.get(id); - if (entry) { - entry.enabled = false; - return true; - } - return false; - } - - unregister(id: string): boolean { - return this.adapters.delete(id); - } - - getIds(): string[] { - return Array.from(this.adapters.keys()); - } - - clear(): void { - this.adapters.clear(); - } -} -export const adapterRegistry = new AdapterRegistry(); -export function registerBuiltinAdapters(): void { - adapterRegistry.register(ampAdapter); - adapterRegistry.register(agentsAdapter); - adapterRegistry.register(antigravityAdapter); - adapterRegistry.register(claudeAdapter); - adapterRegistry.register(clawdbotAdapter); - adapterRegistry.register(codexAdapter); - adapterRegistry.register(cursorAdapter); - adapterRegistry.register(devinAdapter); - adapterRegistry.register(dotfilesAdapter); - adapterRegistry.register(droidAdapter); - adapterRegistry.register(geminiCliAdapter); - adapterRegistry.register(githubCopilotAdapter); - adapterRegistry.register(gooseAdapter); - adapterRegistry.register(kiloAdapter); - adapterRegistry.register(kimiCliAdapter); - adapterRegistry.register(kiroCliAdapter); - adapterRegistry.register(opencodeAdapter); - adapterRegistry.register(rooAdapter); - adapterRegistry.register(traeAdapter); - adapterRegistry.register(vscodeAdapter); - adapterRegistry.register(windsurfAdapter); -} -registerBuiltinAdapters(); diff --git a/src/adapters/roo.ts b/src/adapters/roo.ts deleted file mode 100644 index 1b5c026..0000000 --- a/src/adapters/roo.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Roo Code adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class RooAdapter implements AgentAdapter { - readonly id = "roo"; - readonly name = "Roo Code"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".roo"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "roo"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Roo Code config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Roo Code configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Roo Code configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked Roo Code configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const rooAdapter = new RooAdapter(); diff --git a/src/adapters/shared-skills.ts b/src/adapters/shared-skills.ts deleted file mode 100644 index 5c87c16..0000000 --- a/src/adapters/shared-skills.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { dirname, join, relative, resolve } from "node:path"; -import { - createSymlinkWithBackup, - ensureDir, - getSymlinkTarget, - isSymlink, -} from "../utils/fs"; - -export const SHARED_SKILLS_DIRNAME = "skills"; - -export function getSharedSkillsPath(): string { - return join(process.env.HOME || "", ".agents", SHARED_SKILLS_DIRNAME); -} - -export function getSharedSkillsRepoPath(repoPath: string): string { - return resolve(repoPath, "..", "..", ".agents", SHARED_SKILLS_DIRNAME); -} - -export function linkSharedSkillsOnSystem( - agentSkillsPath: string, - sharedSkillsPath: string = getSharedSkillsPath(), -): void { - ensureDir(sharedSkillsPath); - if (isSymlink(agentSkillsPath)) { - const target = getSymlinkTarget(agentSkillsPath); - if (target === sharedSkillsPath) { - return; - } - } - createSymlinkWithBackup(sharedSkillsPath, agentSkillsPath); -} - -export function linkSharedSkillsInRepo( - agentRepoPath: string, - sharedSkillsRepoPath: string, - agentSkillsDirName: string = SHARED_SKILLS_DIRNAME, -): void { - ensureDir(sharedSkillsRepoPath); - const linkPath = join(agentRepoPath, agentSkillsDirName); - const relativeTarget = relative(dirname(linkPath), sharedSkillsRepoPath); - if (isSymlink(linkPath)) { - const target = getSymlinkTarget(linkPath); - if (target === relativeTarget) { - return; - } - } - createSymlinkWithBackup(relativeTarget, linkPath); -} diff --git a/src/adapters/trae.ts b/src/adapters/trae.ts deleted file mode 100644 index 53c7b2d..0000000 --- a/src/adapters/trae.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Trae adapter - */ - -import { renameSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { - copyDir, - createSymlink, - ensureDir, - exists, - getSymlinkTarget, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class TraeAdapter implements AgentAdapter { - readonly id = "trae"; - readonly name = "Trae"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - getConfigPath(_platform: Platform): string { - return join(process.env.HOME || "", ".trae"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "trae"); - } - - isInstalled(platform: Platform): boolean { - return exists(this.getConfigPath(platform)); - } - - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - if (!exists(systemPath) || !exists(repoPath) || !isSymlink(systemPath)) { - return false; - } - return getSymlinkTarget(systemPath) === repoPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath)) { - return { - success: false, - message: "Trae config not found on system", - }; - } - - if (isSymlink(systemPath)) { - return { - success: true, - message: "Already linked to repo - no import needed", - }; - } - - if (exists(repoPath)) { - return { - success: true, - message: "Configs already in repo - no import needed", - }; - } - - ensureDir(repoPath); - copyDir(systemPath, repoPath); - - return { - success: true, - message: "Imported Trae configs to repo", - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath)) { - return { - success: false, - message: "Trae configs not found in repo", - }; - } - - if (isSymlink(systemPath)) { - const target = getSymlinkTarget(systemPath); - if (target === repoPath) { - return { - success: true, - message: "Already linked to repo - no export needed", - linkedTo: repoPath, - }; - } - } - - // Remove existing (symlink or directory) - if (exists(systemPath)) { - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - } else { - // Backup existing config - const backupPath = `${systemPath}.backup`; - if (exists(backupPath)) { - if (isSymlink(backupPath)) { - unlinkSync(backupPath); - } else { - removeDir(backupPath); - } - } - renameSync(systemPath, backupPath); - } - } - - // Create symlink - createSymlink(repoPath, systemPath); - - return { - success: true, - message: `Linked Trae configs to ${contractHome(systemPath)}`, - linkedTo: repoPath, - }; - } -} - -export const traeAdapter = new TraeAdapter(); diff --git a/src/adapters/types.ts b/src/adapters/types.ts deleted file mode 100644 index ab8de20..0000000 --- a/src/adapters/types.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Adapter system types for multi-editor support - */ - -export type Platform = "macos" | "linux" | "windows"; - -export type SyncStrategy = "symlink" | "copy" | "none"; - -export interface SyncOptions { - import: SyncStrategy; - export: SyncStrategy; -} - -/** - * Validation result for skills/configs - */ -export interface ValidationResult { - valid: boolean; - errors: ValidationError[]; -} - -export interface ValidationError { - field: string; - message: string; - severity: "error" | "warning"; -} - -/** - * Core adapter interface for editor/agent integrations - */ -export interface AgentAdapter { - /** Unique identifier (e.g., "opencode", "claude", "cursor") */ - readonly id: string; - - /** Display name (e.g., "OpenCode", "Claude Code") */ - readonly name: string; - - /** Adapter version */ - readonly version: string; - - /** Sync strategy for this adapter */ - readonly syncStrategy: SyncOptions; - - /** Alternative config paths (optional) */ - configPaths?: string[] | ((platform: Platform) => string[]); - - /** - * Get the system config path for this adapter - * @param platform Target platform - * @returns Path to where configs live on the system - */ - getConfigPath(platform: Platform): string; - - /** - * Get the skills/commands path for this adapter - * @param platform Target platform - * @returns Path to where skills/commands are stored - */ - getSkillsPath?(platform: Platform): string; - - /** - * Get the repository path for this adapter's configs - * @param repoRoot Root of the dotfiles repository - * @returns Path within repo where configs should be stored - */ - getRepoPath(repoRoot: string): string; - - /** - * Check if adapter is installed on system - * @param platform Target platform - * @returns true if editor/agent is installed - */ - isInstalled(platform: Platform): boolean; - - /** - * Import configs from system to repo - * @param systemPath Path on system - * @param repoPath Path in repo - * @returns Import result - */ - import(systemPath: string, repoPath: string): Promise; - - /** - * Export configs from repo to system - * @param repoPath Path in repo - * @param systemPath Path on system - * @returns Export result - */ - export(repoPath: string, systemPath: string): Promise; - - /** - * Check if system config is linked/synced with repo - * @param systemPath Path on system - * @param repoPath Path in repo - * @returns true if synced - */ - isLinked(systemPath: string, repoPath: string): boolean; - - /** - * Convert from canonical skill format to adapter's format - * @param skill Canonical skill representation - * @returns Adapter-specific skill format - */ - fromCanonical?(skill: CanonicalSkill): unknown; - - /** - * Convert from adapter's format to canonical skill format - * @param agentSkill Adapter-specific skill - * @returns Canonical skill representation - */ - toCanonical?(agentSkill: unknown): CanonicalSkill; - - /** - * Validate adapter-specific skill format - * @param skill Skill to validate - * @returns Validation result - */ - validate?(skill: unknown): ValidationResult; -} - -/** - * Import operation result - */ -export interface ImportResult { - success: boolean; - message: string; - filesImported?: string[]; - errors?: string[]; -} - -/** - * Export operation result - */ -export interface ExportResult { - success: boolean; - message: string; - backedUp?: string; - linkedTo?: string; - errors?: string[]; -} - -/** - * Canonical skill format (Phase 4) - * This is a placeholder - will be fully implemented in Phase 4 - */ -export interface CanonicalSkill { - metadata: { - id: string; - name: string; - version: string; - type: "skill" | "command" | "agent"; - description: string; - triggers: string[]; - keywords: string[]; - targets: string[]; - author?: string; - license?: string; - }; - content: string; // Markdown body -} - -/** - * Adapter registry entry - */ -export interface AdapterRegistryEntry { - adapter: AgentAdapter; - enabled: boolean; -} diff --git a/src/adapters/vscode.ts b/src/adapters/vscode.ts deleted file mode 100644 index 028ee05..0000000 --- a/src/adapters/vscode.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * VSCode adapter - */ - -import { - copyFileSync, - existsSync, - renameSync, - statSync, - symlinkSync, - unlinkSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { - copyDir, - ensureDir, - exists, - getSymlinkTarget, - isDirectory, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import { getSharedSkillsPath, linkSharedSkillsOnSystem } from "./shared-skills"; -import type { - AgentAdapter, - CanonicalSkill, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class VSCodeAdapter implements AgentAdapter { - readonly id = "vscode"; - readonly name = "VSCode"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - // Files to sync - private syncPatterns = [ - "settings.json", - "keybindings.json", - "snippets", - "tasks.json", - "launch.json", - ]; - - getConfigPath(platform: Platform): string { - if (platform === "macos") { - return join( - process.env.HOME || "", - "Library", - "Application Support", - "Code", - "User", - ); - } else if (platform === "linux") { - return join(process.env.HOME || "", ".config", "Code", "User"); - } else { - // Windows - return join(process.env.APPDATA || "", "Code", "User"); - } - } - - getSkillsPath(_platform: Platform): string { - return getSharedSkillsPath(); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "vscode"); - } - - isInstalled(platform: Platform): boolean { - const configPath = this.getConfigPath(platform); - // Check if parent directory exists - const parentDir = dirname(configPath); - return exists(parentDir); - } - - /** - * Detect if VSCode is installed on the current system - */ - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - // Check if settings.json is symlinked - const settingsPath = join(systemPath, "settings.json"); - const repoSettingsPath = join(repoPath, "settings.json"); - - if (!isSymlink(settingsPath)) return false; - - const target = getSymlinkTarget(settingsPath); - return target === repoSettingsPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath) || !isDirectory(systemPath)) { - return { - success: false, - message: "VSCode config not found on system", - }; - } - - ensureDir(repoPath); - - const filesImported: string[] = []; - - for (const pattern of this.syncPatterns) { - const srcPath = join(systemPath, pattern); - const destPath = join(repoPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (isSymlink(srcPath)) continue; - - if (existsSync(destPath)) continue; - - if (statSync(srcPath).isDirectory()) { - copyDir(srcPath, destPath); - filesImported.push(`${pattern}/`); - } else { - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - filesImported.push(pattern); - } - } - - if (filesImported.length === 0) { - return { - success: true, - message: "No VSCode configs found to import", - }; - } - - linkSharedSkillsOnSystem(join(systemPath, "skills")); - - return { - success: true, - message: "Imported VSCode configs to repo", - filesImported, - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath) || !isDirectory(repoPath)) { - return { - success: false, - message: "VSCode configs not found in repo", - }; - } - - ensureDir(systemPath); - - const filesExported: string[] = []; - let backedUp: string | undefined; - - for (const pattern of this.syncPatterns) { - const srcPath = join(repoPath, pattern); - const destPath = join(systemPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (isSymlink(destPath)) { - const target = getSymlinkTarget(destPath); - if (target === srcPath) { - continue; - } - unlinkSync(destPath); - } else if (exists(destPath)) { - const backupPath = `${destPath}.backup`; - if (exists(backupPath)) { - if (isDirectory(backupPath)) { - removeDir(backupPath); - } else { - unlinkSync(backupPath); - } - } - renameSync(destPath, backupPath); - if (!backedUp) { - backedUp = contractHome(dirname(backupPath)); - } - } - - // Create symlink - symlinkSync(srcPath, destPath); - filesExported.push(pattern); - } - - return { - success: true, - message: `Linked VSCode configs to ${contractHome(systemPath)}`, - backedUp, - linkedTo: contractHome(repoPath), - }; - } - - // Template conversion methods (Phase 4) - fromCanonical(skill: CanonicalSkill): unknown { - // VSCode doesn't have a native skills/commands system like OpenCode - // This would be used for sharing tasks/launch configs - return skill; - } - - toCanonical(agentSkill: unknown): CanonicalSkill { - return agentSkill as CanonicalSkill; - } -} - -export const vscodeAdapter = new VSCodeAdapter(); diff --git a/src/adapters/windsurf.ts b/src/adapters/windsurf.ts deleted file mode 100644 index 9d5d46a..0000000 --- a/src/adapters/windsurf.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Windsurf adapter - */ - -import { - copyFileSync, - existsSync, - renameSync, - statSync, - symlinkSync, - unlinkSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { - copyDir, - ensureDir, - exists, - getSymlinkTarget, - isDirectory, - isSymlink, - removeDir, -} from "../utils/fs"; -import { contractHome } from "../utils/paths"; -import type { - AgentAdapter, - CanonicalSkill, - ExportResult, - ImportResult, - Platform, -} from "./types"; - -export class WindsurfAdapter implements AgentAdapter { - readonly id = "windsurf"; - readonly name = "Windsurf"; - readonly version = "1.0.0"; - readonly syncStrategy = { - import: "copy" as const, - export: "symlink" as const, - }; - - // Files to sync - private syncPatterns = [ - "settings.json", - "mcp_config.json", - "rules", // .windsurf/rules/ directory - ]; - - getConfigPath(_platform: Platform): string { - // Windsurf uses ~/.codeium/windsurf on all platforms - return join(process.env.HOME || "", ".codeium", "windsurf"); - } - - getSkillsPath(platform: Platform): string { - return join(this.getConfigPath(platform), "rules"); - } - - getRepoPath(repoRoot: string): string { - return join(repoRoot, "configs", "windsurf"); - } - - isInstalled(platform: Platform): boolean { - const configPath = this.getConfigPath(platform); - return exists(configPath); - } - - /** - * Detect if Windsurf is installed on the current system - */ - detect(): boolean { - const platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - return this.isInstalled(platform); - } - - isLinked(systemPath: string, repoPath: string): boolean { - // Check if settings.json is symlinked - const settingsPath = join(systemPath, "settings.json"); - const repoSettingsPath = join(repoPath, "settings.json"); - - if (!isSymlink(settingsPath)) return false; - - const target = getSymlinkTarget(settingsPath); - return target === repoSettingsPath; - } - - async import(systemPath: string, repoPath: string): Promise { - if (!exists(systemPath) || !isDirectory(systemPath)) { - return { - success: false, - message: "Windsurf config not found on system", - }; - } - - ensureDir(repoPath); - - const filesImported: string[] = []; - - for (const pattern of this.syncPatterns) { - const srcPath = join(systemPath, pattern); - const destPath = join(repoPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (isSymlink(srcPath)) continue; - - if (existsSync(destPath)) continue; - - if (statSync(srcPath).isDirectory()) { - copyDir(srcPath, destPath); - filesImported.push(`${pattern}/`); - } else { - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - filesImported.push(pattern); - } - } - - if (filesImported.length === 0) { - return { - success: true, - message: "No Windsurf configs found to import", - }; - } - - return { - success: true, - message: "Imported Windsurf configs to repo", - filesImported, - }; - } - - async export(repoPath: string, systemPath: string): Promise { - if (!exists(repoPath) || !isDirectory(repoPath)) { - return { - success: false, - message: "Windsurf configs not found in repo", - }; - } - - ensureDir(systemPath); - - const filesExported: string[] = []; - let backedUp: string | undefined; - - for (const pattern of this.syncPatterns) { - const srcPath = join(repoPath, pattern); - const destPath = join(systemPath, pattern); - - if (!existsSync(srcPath)) continue; - - if (isSymlink(destPath)) { - const target = getSymlinkTarget(destPath); - if (target === srcPath) { - continue; - } - unlinkSync(destPath); - } else if (exists(destPath)) { - const backupPath = `${destPath}.backup`; - if (exists(backupPath)) { - if (isDirectory(backupPath)) { - removeDir(backupPath); - } else { - unlinkSync(backupPath); - } - } - renameSync(destPath, backupPath); - if (!backedUp) { - backedUp = contractHome(dirname(backupPath)); - } - } - - // Create symlink - symlinkSync(srcPath, destPath); - filesExported.push(pattern); - } - - return { - success: true, - message: `Linked Windsurf configs to ${contractHome(systemPath)}`, - backedUp, - linkedTo: contractHome(repoPath), - }; - } - - // Template conversion methods (Phase 4) - fromCanonical(skill: CanonicalSkill): unknown { - // TODO: Convert from canonical format to Windsurf rules format - return skill; - } - - toCanonical(agentSkill: unknown): CanonicalSkill { - // TODO: Convert from Windsurf rules to canonical format - return agentSkill as CanonicalSkill; - } -} - -export const windsurfAdapter = new WindsurfAdapter(); diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..c484d05 --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,88 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { exists } from "./files.ts"; + +export interface Agent { + id: string; + name: string; + /** Config home, honoring the agent's own override variable. */ + home: () => string; + /** Global instructions file inside `home`, if the agent reads one. */ + instructions?: string; + /** + * Claude Code only reads `/skills`. Every other agent here also reads + * the shared `~/.agents/skills`, so they need no per-agent skill links. + */ + ownSkills?: boolean; +} + +function configHome(): string { + return process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); +} + +export const AGENTS = [ + { + id: "claude", + name: "Claude Code", + home: () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), + instructions: "CLAUDE.md", + ownSkills: true, + }, + { + id: "codex", + name: "Codex", + home: () => process.env.CODEX_HOME || join(homedir(), ".codex"), + instructions: "AGENTS.md", + }, + { + id: "opencode", + name: "OpenCode", + home: () => join(configHome(), "opencode"), + instructions: "AGENTS.md", + }, + { + id: "gemini", + name: "Gemini CLI", + home: () => join(homedir(), ".gemini"), + instructions: "GEMINI.md", + }, + // Cursor keeps global rules in its settings UI, not in a file. + { id: "cursor", name: "Cursor", home: () => join(homedir(), ".cursor") }, + { + id: "copilot", + name: "GitHub Copilot CLI", + home: () => join(homedir(), ".copilot"), + }, +] as const satisfies readonly Agent[]; + +export type AgentId = (typeof AGENTS)[number]["id"]; + +export function findAgent(id: string): Agent | undefined { + return AGENTS.find((agent) => agent.id === id); +} + +export function isAgentId(id: string): id is AgentId { + return findAgent(id) !== undefined; +} + +export function detectAgents(): AgentId[] { + return AGENTS.filter((agent) => exists(agent.home())).map((agent) => agent.id); +} + +export function sharedSkillsDir(): string { + return join(homedir(), ".agents", "skills"); +} + +// Credentials that must never be committed, whatever agent they belong to. +const SECRET_NAMES = new Set([ + "auth.json", + ".credentials.json", + "credentials.json", + "oauth_creds.json", + "google_accounts.json", + ".env", +]); + +export function isSecretFile(name: string): boolean { + return SECRET_NAMES.has(name) || name.startsWith(".env.") || /\.(pem|key)$/.test(name); +} diff --git a/src/agents/index.ts b/src/agents/index.ts deleted file mode 100644 index 3796e1d..0000000 --- a/src/agents/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { - type AgentMetadata, - agentMetadata, - detectInstalledAgents, - getAgentMetadata, - getAgentsWithAdapters, - getAgentsWithoutAdapters, - getAllAgentIds, - isAgentInstalled, -} from "./metadata"; -export { - ensureSharedSkillsAgent, - SHARED_SKILLS_AGENT_ID, - sortSharedSkillsFirst, - usesSharedSkills, -} from "./shared-skills"; diff --git a/src/agents/metadata.ts b/src/agents/metadata.ts deleted file mode 100644 index 71eb98c..0000000 --- a/src/agents/metadata.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { Platform } from "../adapters/types"; - -export interface AgentMetadata { - id: string; - displayName: string; - configDir: string; - windowsConfigDir?: string; - detectInstalled: (platform: Platform) => boolean; - hasAdapter: boolean; -} - -const home = homedir(); - -export const agentMetadata: Record = { - agents: { - id: "agents", - displayName: "Shared Agents", - configDir: join(home, ".agents"), - detectInstalled: () => existsSync(join(home, ".agents")), - hasAdapter: true, - }, - - amp: { - id: "amp", - displayName: "Amp", - configDir: join(home, ".config/amp"), - detectInstalled: () => existsSync(join(home, ".config/amp")), - hasAdapter: true, - }, - - antigravity: { - id: "antigravity", - displayName: "Antigravity", - configDir: join(home, ".gemini/antigravity"), - detectInstalled: () => - existsSync(join(process.cwd(), ".agent")) || - existsSync(join(home, ".gemini/antigravity")), - hasAdapter: true, - }, - - claude: { - id: "claude", - displayName: "Claude Code", - configDir: join(home, ".claude"), - windowsConfigDir: join(process.env.APPDATA || "", "claude"), - detectInstalled: (platform) => { - if (platform === "windows") { - return existsSync(join(process.env.APPDATA || "", "claude")); - } - return existsSync(join(home, ".claude")); - }, - hasAdapter: true, - }, - - clawdbot: { - id: "clawdbot", - displayName: "Clawdbot", - configDir: join(home, ".clawdbot"), - detectInstalled: () => - existsSync(join(home, ".clawd")) || existsSync(join(home, ".clawdbot")), - hasAdapter: true, - }, - - codex: { - id: "codex", - displayName: "Codex", - configDir: join(home, ".codex"), - detectInstalled: () => existsSync(join(home, ".codex")), - hasAdapter: true, - }, - - cursor: { - id: "cursor", - displayName: "Cursor", - configDir: (() => { - if (process.platform === "darwin") { - return join(home, "Library/Application Support/Cursor/User"); - } else if (process.platform === "linux") { - return join(home, ".config/Cursor/User"); - } - return join(process.env.APPDATA || "", "Cursor/User"); - })(), - detectInstalled: (platform) => { - if (platform === "macos") { - return existsSync(join(home, "Library/Application Support/Cursor")); - } else if (platform === "linux") { - return existsSync(join(home, ".config/Cursor")); - } - return existsSync(join(process.env.APPDATA || "", "Cursor")); - }, - hasAdapter: true, - }, - - dotfiles: { - id: "dotfiles", - displayName: "Dotfiles", - configDir: home, - detectInstalled: () => true, - hasAdapter: true, - }, - - devin: { - id: "devin", - displayName: "Devin", - configDir: join(home, ".devin"), - detectInstalled: () => - existsSync(join(home, ".devin")) || - existsSync(join(home, ".config/devin")), - hasAdapter: true, - }, - - droid: { - id: "droid", - displayName: "Droid", - configDir: join(home, ".factory"), - detectInstalled: () => - existsSync(join(home, ".factory")) || - existsSync(join(home, ".agents/skills")), - hasAdapter: true, - }, - - "gemini-cli": { - id: "gemini-cli", - displayName: "Gemini CLI", - configDir: join(home, ".gemini"), - detectInstalled: () => existsSync(join(home, ".gemini")), - hasAdapter: true, - }, - - "github-copilot": { - id: "github-copilot", - displayName: "GitHub Copilot", - configDir: join(home, ".copilot"), - detectInstalled: () => - existsSync(join(process.cwd(), ".github")) || - existsSync(join(home, ".copilot")), - hasAdapter: true, - }, - - goose: { - id: "goose", - displayName: "Goose", - configDir: join(home, ".config/goose"), - detectInstalled: () => existsSync(join(home, ".config/goose")), - hasAdapter: true, - }, - - kilo: { - id: "kilo", - displayName: "Kilo Code", - configDir: join(home, ".kilocode"), - detectInstalled: () => existsSync(join(home, ".kilocode")), - hasAdapter: true, - }, - - "kimi-cli": { - id: "kimi-cli", - displayName: "Kimi CLI", - configDir: join(home, ".kimi"), - detectInstalled: () => - existsSync(join(home, ".kimi")) || existsSync(join(home, ".config/kimi")), - hasAdapter: true, - }, - - "kiro-cli": { - id: "kiro-cli", - displayName: "Kiro CLI", - configDir: join(home, ".kiro"), - detectInstalled: () => existsSync(join(home, ".kiro")), - hasAdapter: true, - }, - - opencode: { - id: "opencode", - displayName: "OpenCode", - configDir: join(home, ".config/opencode"), - detectInstalled: () => - existsSync(join(home, ".config/opencode")) || - existsSync(join(home, ".agents/skills")), - hasAdapter: true, - }, - - roo: { - id: "roo", - displayName: "Roo Code", - configDir: join(home, ".roo"), - detectInstalled: () => existsSync(join(home, ".roo")), - hasAdapter: true, - }, - - trae: { - id: "trae", - displayName: "Trae", - configDir: join(home, ".trae"), - detectInstalled: () => existsSync(join(home, ".trae")), - hasAdapter: true, - }, - - vscode: { - id: "vscode", - displayName: "Visual Studio Code", - configDir: (() => { - if (process.platform === "darwin") { - return join(home, "Library/Application Support/Code/User"); - } else if (process.platform === "linux") { - return join(home, ".config/Code/User"); - } - return join(process.env.APPDATA || "", "Code/User"); - })(), - detectInstalled: (platform) => { - if (platform === "macos") { - return existsSync(join(home, "Library/Application Support/Code")); - } else if (platform === "linux") { - return existsSync(join(home, ".config/Code")); - } - return existsSync(join(process.env.APPDATA || "", "Code")); - }, - hasAdapter: true, - }, - - windsurf: { - id: "windsurf", - displayName: "Windsurf", - configDir: join(home, ".codeium/windsurf"), - detectInstalled: () => existsSync(join(home, ".codeium/windsurf")), - hasAdapter: true, - }, -}; -export function getAllAgentIds(): string[] { - return Object.keys(agentMetadata); -} -export function getAgentMetadata(agentId: string): AgentMetadata | undefined { - return agentMetadata[agentId]; -} -export function isAgentInstalled(agentId: string, platform: Platform): boolean { - const metadata = agentMetadata[agentId]; - if (!metadata) return false; - return metadata.detectInstalled(platform); -} -export function detectInstalledAgents(platform: Platform): string[] { - const installed: string[] = []; - - for (const [agentId, metadata] of Object.entries(agentMetadata)) { - if (metadata.detectInstalled(platform)) { - installed.push(agentId); - } - } - - return installed; -} -export function getAgentsWithAdapters(): string[] { - return Object.entries(agentMetadata) - .filter(([_, metadata]) => metadata.hasAdapter) - .map(([agentId]) => agentId); -} -export function getAgentsWithoutAdapters(): string[] { - return Object.entries(agentMetadata) - .filter(([_, metadata]) => !metadata.hasAdapter) - .map(([agentId]) => agentId); -} diff --git a/src/agents/shared-skills.ts b/src/agents/shared-skills.ts deleted file mode 100644 index 21eb8e4..0000000 --- a/src/agents/shared-skills.ts +++ /dev/null @@ -1,39 +0,0 @@ -export const SHARED_SKILLS_AGENT_ID = "agents"; - -const sharedSkillsAgents = new Set([ - "amp", - "claude", - "codex", - "cursor", - "devin", - "droid", - "gemini-cli", - "github-copilot", - "kimi-cli", - "opencode", - "vscode", -]); - -export function usesSharedSkills(agentId: string): boolean { - return sharedSkillsAgents.has(agentId); -} - -export function ensureSharedSkillsAgent(agentIds: string[]): string[] { - if (!agentIds.some(usesSharedSkills)) { - return agentIds; - } - if (agentIds.includes(SHARED_SKILLS_AGENT_ID)) { - return agentIds; - } - return [...agentIds, SHARED_SKILLS_AGENT_ID]; -} - -export function sortSharedSkillsFirst(agentIds: string[]): string[] { - if (!agentIds.includes(SHARED_SKILLS_AGENT_ID)) { - return agentIds; - } - return [ - SHARED_SKILLS_AGENT_ID, - ...agentIds.filter((id) => id !== SHARED_SKILLS_AGENT_ID), - ]; -} diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts new file mode 100644 index 0000000..8d3d1dd --- /dev/null +++ b/src/commands/adopt.ts @@ -0,0 +1,72 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { type Agent, isSecretFile } from "../agents.ts"; +import { UserError } from "../errors.ts"; +import { exists, Files, linkTarget, move, prettyPath, realPath, walk, within } from "../files.ts"; +import { apply, enabledAgents, repoPaths } from "../plan.ts"; +import { loadRepo } from "./apply.ts"; + +/** Where an adopted path belongs in the repo. */ +export function adoptTarget(file: string, repo: string, agents: readonly Agent[]): string { + const paths = repoPaths(repo); + if (fs.statSync(file).isDirectory() && fs.existsSync(path.join(file, "SKILL.md"))) { + return path.join(paths.skills, path.basename(file)); + } + const agent = agents.find((candidate) => within(file, candidate.home())); + if (!agent) { + throw new UserError( + `${prettyPath(file)} is not a skill or inside an enabled agent's config folder.`, + ); + } + const relative = path.relative(agent.home(), file); + if (!relative) { + throw new UserError(`Adopt individual files from ${prettyPath(file)}, not the whole folder.`); + } + if (relative === agent.instructions) { + return path.join(paths.instructions, "AGENTS.md"); + } + const inside = fs.statSync(file).isDirectory() + ? walk(file).map((entry) => path.relative(agent.home(), entry)) + : [relative]; + const secret = inside.flatMap((entry) => entry.split(path.sep)).find(isSecretFile); + if (secret) throw new UserError(`Refusing to adopt ${secret}: it holds credentials.`); + return path.join(paths.agents, agent.id, relative); +} + +/** Moves local files into the repo, then links them back into place. */ +export function adopt(targets: string[], repo: string, agents: readonly Agent[]) { + const moves = targets.map((input) => { + const file = path.resolve(input); + if (!exists(file)) throw new UserError(`${input} does not exist.`); + if (within(realPath(file) ?? file, repo)) { + throw new UserError(`${prettyPath(file)} is already in the repo.`); + } + const target = linkTarget(file); + if (target) { + throw new UserError( + `${prettyPath(file)} is a link to ${target}; adopt the real file instead.`, + ); + } + const destination = adoptTarget(file, repo, agents); + if (exists(destination)) { + throw new UserError( + `${prettyPath(destination, repo)} already exists. Run \`syncode apply\` to link or resolve it instead.`, + ); + } + return { file, destination }; + }); + // Check every path before moving anything. + for (const { file, destination } of moves) { + move(file, destination); + console.log(`[adopt ] ${prettyPath(file)} -> ${prettyPath(destination, repo)}`); + } +} + +export async function adoptCommand(targets: string[]) { + if (!targets.length) throw new UserError("Usage: syncode adopt ..."); + const config = loadRepo(); + const agents = enabledAgents(config.agents); + adopt(targets, config.repo, agents); + await apply(new Files({ repo: config.repo, dryRun: false }), agents); + console.log("\nCommit the new files with `syncode push`."); +} diff --git a/src/commands/apply.ts b/src/commands/apply.ts new file mode 100644 index 0000000..f62dbd7 --- /dev/null +++ b/src/commands/apply.ts @@ -0,0 +1,60 @@ +import { requireConfig } from "../config.ts"; +import { UserError } from "../errors.ts"; +import { exists, Files } from "../files.ts"; +import { gitOutput } from "../git.ts"; +import { apply, enabledAgents } from "../plan.ts"; + +export function loadRepo() { + const config = requireConfig(); + if (!exists(config.repo)) { + throw new UserError( + `Repo ${config.repo} does not exist. Run \`syncode init\` to set it up again.`, + ); + } + return config; +} + +export async function applyCommand(options: { dryRun: boolean }) { + const config = loadRepo(); + const files = new Files({ repo: config.repo, dryRun: options.dryRun }); + await apply(files, enabledAgents(config.agents)); + const kept = files.entries.filter((entry) => entry.action === "keep").length; + if (kept) + console.log( + `\nKept ${kept} differing local file(s). Run \`syncode apply\` in a terminal to resolve them.`, + ); +} + +export async function statusCommand(options: { json: boolean }) { + const config = loadRepo(); + const files = new Files({ repo: config.repo, dryRun: true, quiet: options.json }); + await apply(files, enabledAgents(config.agents)); + + const changes = files.entries.filter((entry) => entry.action !== "skip"); + const dirty = gitOutput(config.repo, ["status", "--porcelain"]) + ?.split("\n") + .filter(Boolean).length; + + if (options.json) { + console.log( + JSON.stringify( + { + repo: config.repo, + agents: config.agents, + uncommitted: dirty ?? null, + entries: files.entries, + }, + null, + 2, + ), + ); + return; + } + console.log( + `\n${changes.length ? `${changes.length} change(s) pending; run \`syncode apply\`.` : "Everything is linked."}`, + ); + if (dirty) + console.log( + `${dirty} uncommitted change(s) in ${config.repo}; run \`syncode push\` to share them.`, + ); +} diff --git a/src/commands/git.ts b/src/commands/git.ts new file mode 100644 index 0000000..84d7766 --- /dev/null +++ b/src/commands/git.ts @@ -0,0 +1,25 @@ +import { Files } from "../files.ts"; +import { git, gitOutput } from "../git.ts"; +import { apply, enabledAgents, unlinkAll } from "../plan.ts"; +import { loadRepo } from "./apply.ts"; + +export async function pullCommand() { + const config = loadRepo(); + git(config.repo, ["pull", "--ff-only"]); + console.log(""); + await apply(new Files({ repo: config.repo, dryRun: false }), enabledAgents(config.agents)); +} + +export function pushCommand(options: { message?: string }) { + const config = loadRepo(); + if (gitOutput(config.repo, ["status", "--porcelain"])) { + git(config.repo, ["add", "-A"]); + git(config.repo, ["commit", "-q", "-m", options.message ?? "Update agent configs"]); + } + git(config.repo, ["push"]); +} + +export function unlinkCommand(options: { dryRun: boolean }) { + const config = loadRepo(); + unlinkAll(new Files({ repo: config.repo, dryRun: options.dryRun }), enabledAgents(config.agents)); +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 41d26b1..2b7f30f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,226 +1,175 @@ -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +import * as fs from "node:fs"; +import * as path from "node:path"; import * as p from "@clack/prompts"; -import { adapterRegistry } from "../adapters/registry"; -import type { Platform } from "../adapters/types"; -import { - detectInstalledAgents, - ensureSharedSkillsAgent, - getAgentMetadata, - getAgentsWithAdapters, - usesSharedSkills, -} from "../agents"; -import { configExists, initConfig } from "../config/manager"; -import { SUPPORTED_AGENTS } from "../config/types"; -import { contractHome, expandHome } from "../utils/paths"; -import { getErrorLogFile, logError } from "../utils/trace"; - -export async function initCommand() { - p.intro("Initialize from Existing Repo"); - - if (configExists()) { - const overwrite = await p.confirm({ - message: - "Configuration already exists at ~/.syncode/config.json. Overwrite?", - initialValue: false, - }); - - if (p.isCancel(overwrite) || !overwrite) { - p.cancel("Initialization cancelled."); - return; - } - } +import { AGENTS, type AgentId, detectAgents, isAgentId, sharedSkillsDir } from "../agents.ts"; +import { type Config, defaultRepo, readConfig, writeConfig } from "../config.ts"; +import { UserError } from "../errors.ts"; +import { children, exists, Files, linkTarget, prettyPath, writeAtomic } from "../files.ts"; +import { git } from "../git.ts"; +import { expandHome } from "../paths.ts"; +import { apply, enabledAgents, GITIGNORE_LINES, repoPaths } from "../plan.ts"; +import { answer, canPrompt } from "../prompts.ts"; +import { adopt } from "./adopt.ts"; +import { migrate } from "./migrate.ts"; - const repoUrlInput = await p.text({ - message: "Repository URL", - placeholder: "https://github.com//configs.git", - validate: (value) => { - if (!value) return "Repository URL is required"; - return undefined; - }, - }); - - if (p.isCancel(repoUrlInput)) { - p.cancel("Initialization cancelled."); - return; - } +const README = `# Agent configs - const repoPathInput = await p.text({ - message: "Where should the agent configs be stored?", - placeholder: "~/.syncode/repo", - initialValue: "~/.syncode/repo", - validate: (value) => { - if (!value) return "Repository path is required"; - return undefined; - }, - }); - - if (p.isCancel(repoPathInput)) { - p.cancel("Initialization cancelled."); - return; - } +Managed by [syncode](https://github.com/donnes/syncode). - const repoPath = expandHome(repoPathInput); +- \`instructions/AGENTS.md\`: global instructions, linked as each agent's CLAUDE.md, AGENTS.md or GEMINI.md. + Add \`macos.md\` or \`linux.md\` for OS-specific parts, and an untracked \`local.md\` for this machine only. +- \`skills//\`: skills, linked into \`~/.agents/skills\` and \`~/.claude/skills\`. +- \`agents//\`: other config files, linked into that agent's config folder. - if (existsSync(repoPath)) { - const useExisting = await p.confirm({ - message: `Directory ${contractHome(repoPath)} already exists. Use it?`, - initialValue: true, - }); +Run \`syncode adopt \` to add files, and \`syncode apply\` after pulling changes. +`; - if (p.isCancel(useExisting) || !useExisting) { - p.cancel("Initialization cancelled."); - return; - } +const STARTER_INSTRUCTIONS = `# Global agent instructions - const gitDir = join(repoPath, ".git"); - if (!existsSync(gitDir)) { - p.cancel("Directory is not a git repository."); - return; - } - } else { - try { - mkdirSync(dirname(repoPath), { recursive: true }); - } catch (error) { - const logFile = logError(error); - p.cancel( - `Failed to create directory: ${error}\n${getErrorLogFile(logFile)}`, - ); - return; - } - - try { - execSync(`git clone "${repoUrlInput}" "${repoPath}"`, { - stdio: "pipe", - }); - p.log.success("βœ“ Cloned repository"); - } catch (_error) { - p.cancel("Failed to clone repository."); - return; - } - } +Write instructions here that every coding agent should follow on every machine. +`; - const platform: Platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - const detectedAgents = detectInstalledAgents(platform); - const agentsWithAdapters = getAgentsWithAdapters(); - - const repoAgents = SUPPORTED_AGENTS.filter((id) => { - const adapter = adapterRegistry.get(id); - if (!adapter) return false; - return existsSync(adapter.getRepoPath(repoPath)); - }); - - const agentOptions = SUPPORTED_AGENTS.map((id) => { - const detected = detectedAgents.includes(id); - const hasAdapter = agentsWithAdapters.includes(id); - const metadata = getAgentMetadata(id); - const label = - metadata?.displayName || id.charAt(0).toUpperCase() + id.slice(1); - const hint = detected - ? hasAdapter - ? "Installed β€’ Full sync" - : "Installed β€’ Metadata only" - : hasAdapter - ? "Not found β€’ Full sync available" - : "Not found"; - - return { - value: id, - label: detected ? `${label} (detected βœ“)` : label, - hint, - }; - }); - - const agentsInput = await p.multiselect({ - message: "Which AI agents do you want to sync?", - options: agentOptions, - initialValues: repoAgents.length > 0 ? repoAgents : detectedAgents, - required: false, - }); - - if (p.isCancel(agentsInput)) { - p.cancel("Initialization cancelled."); - return; - } - - let selectedAgents = agentsInput as string[]; - const expandedAgents = ensureSharedSkillsAgent(selectedAgents); - if ( - expandedAgents.length > selectedAgents.length && - selectedAgents.some(usesSharedSkills) - ) { - p.log.info("Including Shared Agents (.agents) for shared skills"); - } - selectedAgents = expandedAgents; +function isGitUrl(source: string): boolean { + return /^(https?|ssh|git):\/\//.test(source) || /^[\w.-]+@[\w.-]+:/.test(source); +} - if (selectedAgents.length === 0) { - p.log.warn( - "No agents selected. You can add them later by editing ~/.syncode/config.json", +export function parseAgents(list: string): AgentId[] { + const ids = list + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + const unknown = ids.filter((id) => !isAgentId(id)); + if (unknown.length) { + throw new UserError( + `Unknown agent(s): ${unknown.join(", ")}. Supported: ${AGENTS.map((agent) => agent.id).join(", ")}.`, ); } + return ids.filter(isAgentId); +} - if (selectedAgents.length > 0) { - const selectedWithAdapters = selectedAgents.filter((id) => - agentsWithAdapters.includes(id), - ); - const selectedWithoutAdapters = selectedAgents.filter( - (id) => !agentsWithAdapters.includes(id), - ); +async function chooseAgents(flag: string | undefined, current: AgentId[] | undefined) { + if (flag !== undefined) return parseAgents(flag); + const detected = detectAgents(); + if (!canPrompt()) return current ?? detected; + return answer( + await p.multiselect({ + message: "Which agents should syncode manage?", + options: AGENTS.map((agent) => ({ + value: agent.id, + label: agent.name, + ...(detected.includes(agent.id) ? { hint: "detected" } : {}), + })), + initialValues: current ?? detected, + required: false, + }), + ); +} - if (selectedWithAdapters.length > 0) { - p.log.info("Using smart sync defaults:"); - p.log.info( - " β€’ Symlinks: Cursor, OpenCode, Windsurf, VSCode (live sync)", - ); - p.log.info(" β€’ Copy: Claude Code (preserves cache/history)"); +/** Creates the repo, or clones it, and reports whether it is brand new. */ +function prepareRepo(source: string | undefined, current: string | undefined) { + if (source && isGitUrl(source)) { + const repo = current ?? defaultRepo(); + if (exists(repo)) { + throw new UserError(`${prettyPath(repo)} already exists. Move it away to clone ${source}.`); } + fs.mkdirSync(path.dirname(repo), { recursive: true }); + git(path.dirname(repo), ["clone", source, repo]); + return { repo, created: false }; + } + const repo = path.resolve(expandHome(source ?? current ?? defaultRepo())); + if (exists(path.join(repo, ".git"))) return { repo, created: false }; + // Starting a repo writes README.md, .gitignore and instructions/, so only + // do it in a new or empty folder. + if (children(repo).length) { + throw new UserError( + `${prettyPath(repo)} has files but isn't a git repo. Pick an empty folder, or run \`git init\` there first to use it as is.`, + ); + } + fs.mkdirSync(repo, { recursive: true }); + git(repo, ["init", "-q"]); + return { repo, created: true }; +} - if (selectedWithoutAdapters.length > 0) { - const agentNames = selectedWithoutAdapters - .map((id) => getAgentMetadata(id)?.displayName || id) - .join(", "); - p.log.warn(`Note: ${agentNames} - metadata only (no sync adapter yet)`); +/** Starts a new repo from what this machine already has. */ +async function seed(config: Config) { + const agents = enabledAgents(config.agents); + const paths = repoPaths(config.repo); + writeAtomic(path.join(config.repo, ".gitignore"), `${GITIGNORE_LINES.join("\n")}\n`); + writeAtomic(path.join(config.repo, "README.md"), README); + + // Copy rather than adopt: the other agents' files may differ, and apply + // will offer to resolve those. + const existing = agents + .flatMap((agent) => (agent.instructions ? [path.join(agent.home(), agent.instructions)] : [])) + .find((file) => fs.existsSync(file) && !linkTarget(file)); + const main = path.join(paths.instructions, "AGENTS.md"); + if (existing) { + // Keep its age, so conflicts with other agents' files suggest the newer one. + fs.mkdirSync(paths.instructions, { recursive: true }); + fs.cpSync(existing, main, { preserveTimestamps: true }); + } else { + writeAtomic(main, STARTER_INSTRUCTIONS); + } + console.log( + `[create ] ${prettyPath(main, config.repo)}${existing ? ` from ${prettyPath(existing)}` : ""}`, + ); + + const roots = [ + sharedSkillsDir(), + ...agents.filter((agent) => agent.ownSkills).map((agent) => path.join(agent.home(), "skills")), + ]; + const found = new Map(); + for (const dir of roots.flatMap(children)) { + const name = path.basename(dir); + if (!found.has(name) && !linkTarget(dir) && fs.existsSync(path.join(dir, "SKILL.md"))) { + found.set(name, dir); } } + if (!found.size) return; + if (!canPrompt()) { + console.log(`Found ${found.size} local skill(s); add them with \`syncode adopt \`.`); + return; + } + const skills = answer( + await p.multiselect({ + message: "Move these local skills into the repo?", + options: [...found.values()].map((dir) => ({ + value: dir, + label: path.basename(dir), + hint: prettyPath(dir), + })), + initialValues: [...found.values()], + required: false, + }), + ); + adopt(skills, config.repo, agents); +} - try { - initConfig({ - repoPath: repoPathInput, - remote: repoUrlInput, - agents: selectedAgents, - }); - - const agentList = - selectedAgents.length > 0 - ? selectedAgents - .map((id) => { - const adapter = adapterRegistry.get(id); - return adapter ? adapter.name : id; - }) - .join(", ") - : "none"; - - p.outro( - `βœ“ Existing repository connected! - -Repository: ${contractHome(repoPath)} -Configuration: ~/.syncode/config.json -Agents: ${agentList} - -Next steps: - β€’ Run 'syncode sync' and select "Export" - β€’ Configs in ${contractHome(repoPath)}/configs will apply to this machine`, - ); - } catch (error) { - const logFile = logError(error); - p.cancel( - `Failed to create configuration: ${error}\n${getErrorLogFile(logFile)}`, +export async function initCommand(source: string | undefined, options: { agents?: string }) { + const stored = readConfig(); + if (stored?.kind === "legacy") { + await migrate( + stored.config, + options.agents === undefined ? undefined : parseAgents(options.agents), ); + return; } + const current = stored?.config; + const { repo, created } = prepareRepo(source, source === undefined ? current?.repo : undefined); + const config: Config = { + version: 2, + repo, + agents: await chooseAgents(options.agents, current?.agents), + }; + writeConfig(config); + console.log(`Using ${prettyPath(repo)} for ${config.agents.join(", ") || "no agents"}.\n`); + + if (created) await seed(config); + await apply(new Files({ repo, dryRun: false }), enabledAgents(config.agents)); + + console.log( + created + ? `\nCommit the repo and add a remote to share it:\n git -C ${prettyPath(repo)} add -A && git -C ${prettyPath(repo)} commit -m "Initial agent configs"\n git -C ${prettyPath(repo)} remote add origin && syncode push` + : "\nDone. Run `syncode status` any time to check for drift.", + ); } diff --git a/src/commands/machine/deps.ts b/src/commands/machine/deps.ts deleted file mode 100644 index 31f67b2..0000000 --- a/src/commands/machine/deps.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { join } from "node:path"; -import * as p from "@clack/prompts"; -import { getConfig } from "../../config/manager"; -import { exists } from "../../utils/fs"; -import { contractHome, expandHome } from "../../utils/paths"; -import { getPlatformName } from "../../utils/platform"; -import { execLive } from "../../utils/shell"; - -export async function machineDepsCommand() { - p.intro("Machine Dependencies"); - - let repoPath: string; - try { - const config = getConfig(); - repoPath = expandHome(config.repoPath); - } catch (_error) { - p.cancel( - "Configuration not found. Run 'syncode new' or 'syncode init' first.", - ); - return; - } - - p.log.info(`Platform: ${getPlatformName()}`); - p.log.info(`Repository: ${contractHome(repoPath)}`); - - if (!exists(repoPath)) { - p.cancel("Repository path not found."); - return; - } - - const installScriptPath = resolveInstallScript(repoPath); - - if (!installScriptPath) { - p.log.warning("install.sh not found in repo root."); - p.log.info( - "Create an install.sh script or run 'syncode init' to generate one.", - ); - return; - } - - const confirm = await p.confirm({ - message: `Run ${contractHome(installScriptPath)}?`, - initialValue: true, - }); - - if (p.isCancel(confirm) || !confirm) { - return; - } - - const spinner = p.spinner(); - spinner.start("Running install script..."); - - const success = await execLive(`bash "${installScriptPath}"`); - - if (success) { - spinner.stop("Install script completed"); - p.log.success("Dependencies installed successfully"); - } else { - spinner.stop("Install script had errors"); - } - - p.outro("Done"); -} - -function resolveInstallScript(repoPath: string): string | null { - const candidates = ["install.sh"]; - - for (const candidate of candidates) { - const fullPath = join(repoPath, candidate); - if (exists(fullPath)) { - return fullPath; - } - } - return null; -} diff --git a/src/commands/machine/index.ts b/src/commands/machine/index.ts deleted file mode 100644 index 362b35d..0000000 --- a/src/commands/machine/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as p from "@clack/prompts"; -import { machineDepsCommand } from "./deps"; -import { machineStatusCommand } from "./status"; - -export async function machineCommand() { - const args = process.argv.slice(2); - const subcommand = args[1]; - - if (subcommand === "deps") { - await machineDepsCommand(); - return; - } - - if (subcommand === "status") { - await machineStatusCommand(); - return; - } - - if (subcommand === "help") { - showHelp(); - return; - } - - if (subcommand) { - console.error(`Unknown machine command: ${subcommand}`); - showHelp(); - process.exit(1); - } - - p.intro("Machine Setup"); - - const action = await p.select({ - message: "What would you like to do?", - options: [ - { - value: "deps", - label: "Install dependencies", - hint: "Homebrew/pacman/apt packages", - }, - { - value: "status", - label: "Machine status", - hint: "Check packages and repo state", - }, - ], - }); - - if (p.isCancel(action)) { - p.cancel("Cancelled"); - return; - } - - p.outro(""); - - if (action === "deps") { - await machineDepsCommand(); - return; - } - - await machineStatusCommand(); -} - -function showHelp() { - console.log(` -syncode machine - -Usage: syncode machine - -Commands: - deps Install dependencies for this machine - status Show machine setup status - help Show this help message - -Examples: - syncode machine - syncode machine deps - syncode machine status -`); -} diff --git a/src/commands/machine/status.ts b/src/commands/machine/status.ts deleted file mode 100644 index 8229319..0000000 --- a/src/commands/machine/status.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import * as p from "@clack/prompts"; -import { getConfig } from "../../config/manager"; -import { contractHome, expandHome, getConfigsDir } from "../../utils/paths"; -import { getPackageManager, getPlatformName } from "../../utils/platform"; -import { exec } from "../../utils/shell"; -import { getErrorLogFile, logError } from "../../utils/trace"; - -export async function machineStatusCommand(options?: { skipIntro?: boolean }) { - if (!options?.skipIntro) { - p.intro("Machine Status"); - } - - let repoPath: string; - try { - const config = getConfig(); - repoPath = expandHome(config.repoPath); - } catch (_error) { - p.cancel( - "Configuration not found. Run 'syncode new' or 'syncode init' first.", - ); - return; - } - - try { - p.log.info(`Platform: ${getPlatformName()}`); - p.log.info(`Repository: ${contractHome(repoPath)}`); - - const packageManager = getPackageManager(); - if (packageManager) { - p.log.info(`Package manager: ${packageManager}`); - } else { - p.log.warning("Package manager: not detected"); - } - - console.log(""); - - if (!existsSync(repoPath)) { - p.log.warning("Repository path not found"); - p.outro("Done"); - return; - } - - const gitStatus = await exec(`git -C "${repoPath}" status --short`); - if (!gitStatus.success) { - p.log.warning("Git: not a repository"); - } else if (gitStatus.stdout.length === 0) { - p.log.success("Git: clean"); - } else { - p.log.warning("Git: uncommitted changes"); - console.log( - gitStatus.stdout - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ); - } - - const dotfilesDir = join(getConfigsDir(), "dotfiles"); - if (existsSync(dotfilesDir)) { - p.log.info("Dotfiles: present in repo"); - } else { - p.log.info("Dotfiles: not found in repo"); - } - - console.log(""); - - reportDependencyFile("install.sh", repoPath, ["install.sh"]); - - p.outro("Done"); - } catch (error) { - const logFile = logError(error); - p.cancel(`Error: ${error}\n${getErrorLogFile(logFile)}`); - } -} - -function reportDependencyFile( - label: string, - repoPath: string, - candidates: string[], -) { - const filePath = resolveDependencyFile(repoPath, candidates); - if (filePath) { - p.log.success(`${label}: ${contractHome(filePath)}`); - } else { - p.log.warning(`${label}: not found`); - } -} - -function resolveDependencyFile( - repoPath: string, - candidates: string[], -): string | null { - for (const candidate of candidates) { - const fullPath = candidate.startsWith("/") - ? candidate - : join(repoPath, candidate); - if (existsSync(fullPath)) { - return fullPath; - } - } - return null; -} diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts new file mode 100644 index 0000000..0d02cda --- /dev/null +++ b/src/commands/migrate.ts @@ -0,0 +1,137 @@ +import * as fs from "node:fs"; +import { homedir } from "node:os"; +import * as path from "node:path"; +import { type Agent, type AgentId, isAgentId } from "../agents.ts"; +import { type LegacyConfig, writeConfig } from "../config.ts"; +import { UserError } from "../errors.ts"; +import { + children, + exists, + Files, + linkTarget, + move, + prettyPath, + within, + writeAtomic, +} from "../files.ts"; +import { apply, enabledAgents, GITIGNORE_LINES, repoPaths } from "../plan.ts"; + +// 1.x ids that changed in 2.0. Agents not listed here or in AGENTS are dropped. +const RENAMED: Record = { "gemini-cli": "gemini", "github-copilot": "copilot" }; + +// Every folder 1.x linked entries in: agent folders, editor User folders and +// dotfiles, on Linux and macOS. +function legacyLinkCandidates(): string[] { + const home = homedir(); + const config = path.join(home, ".config"); + const support = path.join(home, "Library", "Application Support"); + return [ + home, + config, + path.join(config, "Cursor"), + path.join(config, "Code"), + path.join(config, "ghostty"), + path.join(home, ".ssh"), + path.join(home, ".gemini"), + path.join(home, ".codeium"), + path.join(support, "Cursor"), + path.join(support, "Code"), + path.join(support, "com.mitchellh.ghostty"), + ].flatMap(children); +} + +/** + * 1.x symlinked entire agent folders into the repo, which 2.0 no longer + * does. Swap each link for a real copy so every agent keeps working. + */ +function restoreLegacyLinks(repo: string) { + for (const file of legacyLinkCandidates()) { + const target = linkTarget(file); + if (target === undefined) continue; + const resolved = path.resolve(path.dirname(file), target); + const legacyRoots = [path.join(repo, "configs"), path.join(repo, ".agents")]; + if (!legacyRoots.some((root) => within(resolved, root)) || !exists(resolved)) continue; + const temporary = `${file}.syncode-restore`; + fs.cpSync(resolved, temporary, { + recursive: true, + verbatimSymlinks: true, + // 1.x added relative links to its shared skills folder, which would + // dangle outside the repo. 2.0 links skills itself. + filter: (entry) => + !( + path.dirname(entry) === resolved && + path.basename(entry) === "skills" && + linkTarget(entry) + ), + }); + fs.unlinkSync(file); + fs.renameSync(temporary, file); + console.log(`[restore] ${prettyPath(file)} <- ${prettyPath(resolved, repo)}`); + } +} + +function migrateSkills(repo: string) { + const legacy = path.join(repo, ".agents", "skills"); + for (const dir of children(legacy)) { + const destination = path.join(repoPaths(repo).skills, path.basename(dir)); + if (exists(destination) || !fs.existsSync(path.join(dir, "SKILL.md"))) continue; + move(dir, destination); + console.log(`[move ] ${prettyPath(dir, repo)} -> ${prettyPath(destination, repo)}`); + } +} + +function migrateInstructions(repo: string, agents: readonly Agent[]) { + const destination = path.join(repoPaths(repo).instructions, "AGENTS.md"); + if (exists(destination)) return; + const source = + ["codex/AGENTS.md", "claude/CLAUDE.md", "opencode/AGENTS.md", "gemini-cli/GEMINI.md"] + .map((file) => path.join(repo, "configs", file)) + .find((file) => fs.statSync(file, { throwIfNoEntry: false })?.isFile()) ?? + // 1.x copied some agents' folders instead of linking them, so the repo + // may have no instructions while this machine does. + agents + .flatMap((agent) => (agent.instructions ? [path.join(agent.home(), agent.instructions)] : [])) + .find((file) => fs.existsSync(file) && !linkTarget(file)); + if (!source) return; + writeAtomic(destination, fs.readFileSync(source, "utf8")); + console.log(`[create ] ${prettyPath(destination, repo)} from ${prettyPath(source, repo)}`); +} + +function updateGitignore(repo: string) { + const file = path.join(repo, ".gitignore"); + const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; + const lines = current.split("\n"); + const missing = GITIGNORE_LINES.filter((line) => !lines.includes(line)); + if (missing.length) + writeAtomic(file, `${current.trimEnd()}\n${missing.join("\n")}\n`.trimStart()); +} + +export async function migrate(legacy: LegacyConfig, agentsFlag: AgentId[] | undefined) { + const repo = path.resolve(legacy.repoPath); + if (!exists(repo)) + throw new UserError( + `The 1.x repo ${repo} no longer exists; delete ~/.syncode/config.json and run \`syncode init\` again.`, + ); + console.log(`Migrating syncode 1.x setup in ${prettyPath(repo)}\n`); + + const mapped = legacy.agents.map((id) => RENAMED[id] ?? id); + const agents = agentsFlag ?? [...new Set(mapped.filter(isAgentId))]; + const dropped = mapped.filter((id) => !isAgentId(id) && id !== "agents"); + + restoreLegacyLinks(repo); + migrateSkills(repo); + migrateInstructions(repo, enabledAgents(agents)); + updateGitignore(repo); + + writeConfig({ version: 2, repo, agents }); + if (dropped.length) console.log(`\nNo longer managed by syncode: ${dropped.join(", ")}.`); + + console.log(""); + await apply(new Files({ repo, dryRun: false }), enabledAgents(agents)); + + console.log( + `\nMigrated. Your agents now use real folders again; the 1.x copies are still in ${prettyPath(path.join(repo, "configs"), repo)}.\n` + + "Run `syncode adopt ` for each config file you want to keep syncing (for example ~/.claude/settings.json),\n" + + "then delete configs/ and .agents/ from the repo and commit.", + ); +} diff --git a/src/commands/new.ts b/src/commands/new.ts deleted file mode 100644 index 9cefe82..0000000 --- a/src/commands/new.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import * as p from "@clack/prompts"; -import { adapterRegistry } from "../adapters/registry"; -import type { Platform } from "../adapters/types"; -import { - detectInstalledAgents, - ensureSharedSkillsAgent, - getAgentMetadata, - getAgentsWithAdapters, - usesSharedSkills, -} from "../agents"; -import { configExists, getConfig, initConfig } from "../config/manager"; -import { SUPPORTED_AGENTS } from "../config/types"; -import { INSTALL_SH_TEMPLATE } from "../templates"; -import { contractHome, expandHome } from "../utils/paths"; -import { getErrorLogFile, logError } from "../utils/trace"; - -export async function newCommand() { - p.intro("Initialize Agent Config Repository"); - - if (configExists()) { - const existingConfig = getConfig(); - const overwrite = await p.select({ - message: `Configuration already exists at ~/.syncode/config.json (current repo: ${contractHome(expandHome(existingConfig.repoPath))}).`, - options: [ - { - value: "replace", - label: "Replace and continue", - hint: "You can choose a new repo path next", - }, - { - value: "cancel", - label: "Cancel", - hint: "Keep current configuration", - }, - ], - }); - - if (p.isCancel(overwrite) || overwrite !== "replace") { - p.cancel("Initialization cancelled."); - return; - } - } - - const repoPathInput = await p.text({ - message: "Where should the agent configs be stored?", - placeholder: "~/.syncode/repo", - initialValue: "~/.syncode/repo", - validate: (value) => { - if (!value) return "Repository path is required"; - return undefined; - }, - }); - - if (p.isCancel(repoPathInput)) { - p.cancel("Initialization cancelled."); - return; - } - - const repoPath = expandHome(repoPathInput); - - if (existsSync(repoPath)) { - const useExisting = await p.confirm({ - message: `Directory ${contractHome(repoPath)} already exists. Use it?`, - initialValue: true, - }); - - if (p.isCancel(useExisting) || !useExisting) { - p.cancel("Initialization cancelled."); - return; - } - } else { - try { - mkdirSync(repoPath, { recursive: true }); - } catch (error) { - const logFile = logError(error); - p.cancel( - `Failed to create directory: ${error}\n${getErrorLogFile(logFile)}`, - ); - return; - } - } - - const gitDir = join(repoPath, ".git"); - let isNewRepo = false; - if (!existsSync(gitDir)) { - try { - execSync("git init", { cwd: repoPath, stdio: "pipe" }); - isNewRepo = true; - p.log.success("βœ“ Initialized git repository"); - } catch (_error) { - p.log.warn("Failed to initialize git repository"); - } - } - - let remote: string | undefined; - if (isNewRepo) { - const remoteInput = await p.text({ - message: "Add GitHub remote? (optional, press Enter to skip)", - placeholder: "https://github.com//configs.git", - }); - - if (p.isCancel(remoteInput)) { - p.cancel("Initialization cancelled."); - return; - } - - if (remoteInput) { - remote = remoteInput; - try { - execSync(`git remote add origin "${remote}"`, { - cwd: repoPath, - stdio: "pipe", - }); - p.log.success(`βœ“ Added remote: ${remote}`); - } catch (_error) { - p.log.warn("Failed to add remote (you can add it later)"); - } - } - } - - const platform: Platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - const detectedAgents = detectInstalledAgents(platform); - const agentsWithAdapters = getAgentsWithAdapters(); - - const agentOptions = SUPPORTED_AGENTS.map((id) => { - const detected = detectedAgents.includes(id); - const hasAdapter = agentsWithAdapters.includes(id); - const metadata = getAgentMetadata(id); - const label = - metadata?.displayName || id.charAt(0).toUpperCase() + id.slice(1); - const hint = detected - ? hasAdapter - ? "Installed β€’ Full sync" - : "Installed β€’ Metadata only" - : hasAdapter - ? "Not found β€’ Full sync available" - : "Not found"; - - return { - value: id, - label: detected ? `${label} (detected βœ“)` : label, - hint, - }; - }); - - const agentsInput = await p.multiselect({ - message: "Which AI agents do you want to sync?", - options: agentOptions, - initialValues: detectedAgents, // Pre-select detected agents - required: false, - }); - - if (p.isCancel(agentsInput)) { - p.cancel("Initialization cancelled."); - return; - } - - let selectedAgents = agentsInput as string[]; - const expandedAgents = ensureSharedSkillsAgent(selectedAgents); - if ( - expandedAgents.length > selectedAgents.length && - selectedAgents.some(usesSharedSkills) - ) { - p.log.info("Including Shared Agents (.agents) for shared skills"); - } - selectedAgents = expandedAgents; - - if (selectedAgents.length === 0) { - p.log.warn( - "No agents selected. You can add them later by editing ~/.syncode/config.json", - ); - } - - if (selectedAgents.length > 0) { - const selectedWithAdapters = selectedAgents.filter((id) => - agentsWithAdapters.includes(id), - ); - const selectedWithoutAdapters = selectedAgents.filter( - (id) => !agentsWithAdapters.includes(id), - ); - - if (selectedWithAdapters.length > 0) { - p.log.info("Using smart sync defaults:"); - p.log.info( - " β€’ Symlinks: Cursor, OpenCode, Windsurf, VSCode (live sync)", - ); - p.log.info(" β€’ Copy: Claude Code (preserves cache/history)"); - } - - if (selectedWithoutAdapters.length > 0) { - const agentNames = selectedWithoutAdapters - .map((id) => getAgentMetadata(id)?.displayName || id) - .join(", "); - p.log.warn(`Note: ${agentNames} - metadata only (no sync adapter yet)`); - } - } - - const s = p.spinner(); - s.start("Setting up repository structure and importing configs"); - - try { - const configsDir = join(repoPath, "configs"); - mkdirSync(configsDir, { recursive: true }); - - if (selectedAgents.includes("agents")) { - mkdirSync(join(repoPath, ".agents", "skills"), { recursive: true }); - } - - const templateFiles = [ - { - label: "install.sh", - target: join(repoPath, "install.sh"), - content: INSTALL_SH_TEMPLATE, - }, - ]; - - for (const template of templateFiles) { - if (existsSync(template.target)) { - continue; - } - writeFileSync(template.target, template.content, "utf-8"); - s.message(`Added ${template.label}`); - } - - for (const agentId of selectedAgents) { - const adapter = adapterRegistry.get(agentId); - if (!adapter) { - const metadata = getAgentMetadata(agentId); - if (metadata) { - s.message(`${metadata.displayName}: metadata only, no import needed`); - } - continue; - } - - const systemPath = adapter.getConfigPath(platform); - const agentRepoPath = adapter.getRepoPath(repoPath); - - try { - const result = await adapter.import(systemPath, agentRepoPath); - if (result.success) { - s.message(`Imported ${adapter.name} configs`); - } - } catch (_error) { - s.message(`Warning: Failed to import ${adapter.name} configs`); - } - } - - const gitignoreContent = `# macOS -.DS_Store - -# Editor -.vscode/ -.idea/ - -# Temporary -*.tmp -*.bak -*.backup - -# Node modules (if any configs have dependencies) -node_modules/ -`; - writeFileSync(join(repoPath, ".gitignore"), gitignoreContent); - - const readmeContent = `# Agent Config Repository - -Managed by [syncode](https://github.com/donnes/syncode) - -## Setup on New Machine - -\`\`\`bash -# Install syncode -npm install -g @donnes/syncode -# Or using bun -bun install -g @donnes/syncode - -# Initialize from existing repo -syncode init -# When prompted, enter repo URL: ${remote || ""} - -# Sync configs (creates symlinks) -syncode sync -# Select "Export" -\`\`\` - -## Synced Agents - -${selectedAgents - .map((id) => { - const adapter = adapterRegistry.get(id); - const name = adapter ? adapter.name : id; - const strategy = adapter?.syncStrategy.export || "symlink"; - return `- **${name}** (${strategy})`; - }) - .join("\n")} - -${selectedAgents.includes("dotfiles") ? "\n## Dotfiles\n\n- Shell configs (.zshrc, .bashrc)\n- Terminal configs (ghostty, tmux)" : ""} - -## Usage - -\`\`\`bash -# Check status -syncode status - -# Sync changes -syncode sync - -# Push to remote -syncode push -\`\`\` -`; - writeFileSync(join(repoPath, "README.md"), readmeContent); - - if (isNewRepo) { - execSync("git add .", { cwd: repoPath, stdio: "pipe" }); - execSync('git commit -m "Initial commit: Add agent configs"', { - cwd: repoPath, - stdio: "pipe", - }); - s.message("Created initial commit"); - } - - s.stop("Repository structure created"); - } catch (error) { - s.stop("Failed to create repository structure"); - const logFile = logError(error); - p.cancel(`Error: ${error}\n${getErrorLogFile(logFile)}`); - return; - } - - try { - const _config = initConfig({ - repoPath: repoPathInput, // Store as entered (with ~ if used) - remote, - agents: selectedAgents, - }); - - const agentList = - selectedAgents.length > 0 - ? selectedAgents - .map((id) => { - const adapter = adapterRegistry.get(id); - return adapter ? adapter.name : id; - }) - .join(", ") - : "none"; - - p.outro( - `βœ“ Agent config repository initialized! - -Repository: ${contractHome(repoPath)} -Configuration: ~/.syncode/config.json -Agents: ${agentList} -${selectedAgents.includes("dotfiles") ? "Dotfiles: βœ“ Enabled" : ""} - -Next steps: - β€’ Edit configs in ${contractHome(repoPath)}/configs/ - β€’ Changes are synced automatically via symlinks - β€’ Run 'syncode sync' to apply changes to your system - β€’ Run 'syncode push' to commit and push changes`, - ); - } catch (error) { - const logFile = logError(error); - p.cancel( - `Failed to create configuration: ${error}\n${getErrorLogFile(logFile)}`, - ); - } -} diff --git a/src/commands/pull.ts b/src/commands/pull.ts deleted file mode 100644 index 9122f2c..0000000 --- a/src/commands/pull.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Pull agent configs from remote repository - */ - -import * as p from "@clack/prompts"; -import { getConfig } from "../config/manager"; -import type { GlobalConfig } from "../config/types"; -import { - fetch, - getBehindCount, - getCurrentBranch, - getGitStatus, - getRemoteUrl, - hasChanges, - pull, -} from "../utils/git"; -import { expandHome } from "../utils/paths"; -import { getErrorLogFile, logError } from "../utils/trace"; - -export async function pullCommand() { - p.intro("Pull from Remote"); - - let config: GlobalConfig; - try { - config = getConfig(); - } catch (_error) { - p.cancel( - "Configuration not found. Run 'syncode new' or 'syncode init' first.", - ); - return; - } - - const _repoPath = expandHome(config.repoPath); - - const remoteUrl = await getRemoteUrl(); - if (!remoteUrl) { - p.cancel( - "No remote repository configured. Add a remote with: git remote add origin ", - ); - return; - } - - const branch = await getCurrentBranch(); - p.log.info(`Branch: ${branch}`); - p.log.info(`Remote: ${remoteUrl}`); - - console.log(""); - - // Check for uncommitted changes - if (await hasChanges()) { - p.log.warning("Uncommitted changes detected"); - - const gitStatus = await getGitStatus(); - if (gitStatus) { - console.log( - gitStatus - .split("\n") - .map((l) => ` ${l}`) - .join("\n"), - ); - console.log(""); - } - - p.log.warning( - "Cannot pull with uncommitted changes. Commit or stash them first.", - ); - p.outro("Pull cancelled"); - return; - } - - // Fetch to get latest remote state - const fetchSpinner = p.spinner(); - fetchSpinner.start("Fetching from remote"); - - const fetchResult = await fetch(); - if (!fetchResult.success) { - fetchSpinner.stop("Fetch failed"); - p.log.error(fetchResult.message); - p.outro("Pull cancelled"); - return; - } - - fetchSpinner.stop("Fetched from remote"); - - // Check if we're behind - const behindCount = await getBehindCount(); - if (behindCount === 0) { - p.log.success("Already up to date"); - p.outro("No changes to pull"); - return; - } - - p.log.info(`${behindCount} commit(s) behind remote`); - - // Pull from remote - const spinner = p.spinner(); - spinner.start(`Pulling from ${branch}`); - - const result = await pull(); - - if (result.success) { - spinner.stop(`Pulled ${behindCount} commit(s) from ${branch}`); - p.outro("Successfully pulled from remote"); - } else { - spinner.stop("Pull failed"); - const logFile = logError(new Error(result.message), "pull"); - p.log.error(result.message); - p.cancel(`Failed to pull from remote\n${getErrorLogFile(logFile)}`); - } -} diff --git a/src/commands/push.ts b/src/commands/push.ts deleted file mode 100644 index 76c142b..0000000 --- a/src/commands/push.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Push agent configs to remote repository - */ - -import * as p from "@clack/prompts"; -import { getConfig } from "../config/manager"; -import type { GlobalConfig } from "../config/types"; -import { - commit, - getCurrentBranch, - getGitStatus, - getRemoteUrl, - hasChanges, - push, - stageAll, -} from "../utils/git"; -import { expandHome } from "../utils/paths"; -import { getErrorLogFile, logError } from "../utils/trace"; - -export async function pushCommand() { - p.intro("Push to Remote"); - - // Get configuration - let config: GlobalConfig; - try { - config = getConfig(); - } catch (_error) { - p.cancel( - "Configuration not found. Run 'syncode new' or 'syncode init' first.", - ); - return; - } - - const _repoPath = expandHome(config.repoPath); - - // Check for remote - const remoteUrl = await getRemoteUrl(); - if (!remoteUrl) { - p.cancel( - "No remote repository configured. Add a remote with: git remote add origin ", - ); - return; - } - - const branch = await getCurrentBranch(); - p.log.info(`Branch: ${branch}`); - p.log.info(`Remote: ${remoteUrl}`); - - console.log(""); - - // Check for uncommitted changes - if (await hasChanges()) { - p.log.warning("Uncommitted changes detected"); - - const gitStatus = await getGitStatus(); - if (gitStatus) { - console.log( - gitStatus - .split("\n") - .map((l) => ` ${l}`) - .join("\n"), - ); - console.log(""); - } - - const shouldCommit = await p.confirm({ - message: "Commit changes before pushing?", - initialValue: true, - }); - - if (p.isCancel(shouldCommit)) { - p.cancel("Cancelled"); - return; - } - - if (shouldCommit) { - // Ask for commit message - const commitMessage = await p.text({ - message: "Commit message:", - placeholder: "Update agent configs", - validate: (value) => { - if (!value || value.trim().length === 0) { - return "Commit message is required"; - } - }, - }); - - if (p.isCancel(commitMessage)) { - p.cancel("Cancelled"); - return; - } - - // Stage all changes - const spinner = p.spinner(); - spinner.start("Staging changes"); - - const staged = await stageAll(); - if (!staged) { - spinner.stop("Failed to stage changes"); - p.cancel("Failed to stage changes"); - return; - } - - spinner.message("Committing changes"); - - const committed = await commit(commitMessage); - if (!committed) { - spinner.stop("Failed to commit"); - p.cancel("Failed to commit changes"); - return; - } - - spinner.stop("Changes committed"); - } else { - p.log.warning( - "Proceeding without committing (only pushing existing commits)", - ); - } - } else { - p.log.success("Working tree is clean"); - } - - console.log(""); - - // Push to remote - const spinner = p.spinner(); - spinner.start(`Pushing to ${branch}`); - - const result = await push(); - - if (result.success) { - spinner.stop(`Pushed to ${branch}`); - p.outro("Successfully pushed to remote"); - } else { - spinner.stop("Push failed"); - const logFile = logError(new Error(result.message), "push"); - p.log.error(result.message); - p.cancel(`Failed to push to remote\n${getErrorLogFile(logFile)}`); - } -} diff --git a/src/commands/status.ts b/src/commands/status.ts deleted file mode 100644 index 4723f5f..0000000 --- a/src/commands/status.ts +++ /dev/null @@ -1,188 +0,0 @@ -import * as p from "@clack/prompts"; -import { adapterRegistry } from "../adapters/registry"; -import type { Platform } from "../adapters/types"; -import { getAgentMetadata, isAgentInstalled } from "../agents"; -import { getConfig } from "../config/manager"; -import { getNewConfigsAvailable } from "../config/migrations"; -import type { GlobalConfig } from "../config/types"; -import { exists } from "../utils/fs"; -import { getGitStatus, hasChanges } from "../utils/git"; -import { contractHome, expandHome } from "../utils/paths"; -import { getPlatformName } from "../utils/platform"; -import { machineStatusCommand } from "./machine/status"; - -function createAsciiTable(rows: string[][], headers?: string[]): string { - const allRows = headers ? [headers, ...rows] : rows; - - // Handle empty rows - if (allRows.length === 0 || !allRows[0]) { - return ""; - } - - // Calculate column widths - const colWidths = allRows[0].map((_, colIndex) => - Math.max(...allRows.map((row) => row[colIndex]?.length || 0)), - ); - - // Create separator line - const separator = `+${colWidths.map((w) => "-".repeat(w + 2)).join("+")}+`; - - // Format rows - const formattedRows = allRows.map((row, rowIndex) => { - const cells = row.map( - (cell, colIndex) => ` ${cell.padEnd(colWidths[colIndex] ?? 0)} `, - ); - const line = `|${cells.join("|")}|`; - - if (headers && rowIndex === 0) { - return `${separator}\n${line}\n${separator}`; - } - return line; - }); - - return `${headers ? "" : `${separator}\n`}${formattedRows.join("\n")}\n${separator}`; -} - -export async function statusCommand() { - p.intro("Agent Config Status"); - - let config: GlobalConfig; - try { - config = getConfig(); - } catch (_error) { - p.cancel( - "Configuration not found. Run 'syncode new' or 'syncode init' first.", - ); - return; - } - - const repoPath = expandHome(config.repoPath); - - // Repository info table - const repoInfoRows: string[][] = [ - ["Platform", getPlatformName()], - ["Repository", contractHome(repoPath)], - ]; - - if (config.remote) { - repoInfoRows.push(["Remote", config.remote]); - } - - console.log(createAsciiTable(repoInfoRows)); - console.log(""); - - if (config.agents.length === 0) { - p.log.warn("No agents configured"); - } else { - const agentRows: string[][] = []; - const platform: Platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - - for (const agentId of config.agents) { - const adapter = adapterRegistry.get(agentId); - const metadata = getAgentMetadata(agentId); - - if (!adapter && !metadata) { - agentRows.push(["⚠️", agentId, "unknown agent", ""]); - continue; - } - - const displayName = metadata?.displayName || adapter?.name || agentId; - - if (!adapter) { - const installed = isAgentInstalled(agentId, platform); - const icon = installed ? "πŸ“‹" : "β—‹"; - const statusText = installed ? "detected" : "not found"; - agentRows.push([icon, displayName, statusText, "metadata only"]); - continue; - } - - const systemPath = adapter.getConfigPath(platform); - const agentRepoPath = adapter.getRepoPath(repoPath); - - const existsInRepo = exists(agentRepoPath); - const existsOnSystem = exists(systemPath); - const isLinked = adapter.isLinked(systemPath, agentRepoPath); - - let icon: string; - let statusText: string; - let syncMethod = ""; - - if (isLinked) { - icon = "πŸ”—"; - statusText = "linked"; - syncMethod = - adapter.syncStrategy.export === "symlink" ? "symlink" : "copy"; - } else if (existsInRepo && existsOnSystem) { - icon = "βœ“"; - statusText = "synced"; - syncMethod = - adapter.syncStrategy.export === "symlink" ? "symlink" : "copy"; - } else if (existsInRepo && !existsOnSystem) { - icon = "πŸ“¦"; - statusText = "in repo only"; - } else if (!existsInRepo && existsOnSystem) { - icon = "πŸ’»"; - statusText = "on system only"; - } else { - icon = "β—‹"; - statusText = "not found"; - } - - agentRows.push([icon, displayName, statusText, syncMethod]); - } - - console.log(createAsciiTable(agentRows, ["", "Agent", "Status", "Method"])); - } - - console.log(""); - - // Git status table - if (await hasChanges()) { - const gitStatus = await getGitStatus(); - const gitStatusRows: string[][] = [["Git", "Uncommitted changes"]]; - console.log(createAsciiTable(gitStatusRows)); - if (gitStatus) { - console.log( - gitStatus - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ); - console.log(""); - } - } else { - const gitStatusRows: string[][] = [["Git", "Clean"]]; - console.log(createAsciiTable(gitStatusRows)); - } - - const newConfigs = getNewConfigsAvailable(); - if (newConfigs.length > 0) { - const names = newConfigs - .map((id) => getAgentMetadata(id)?.displayName || id) - .join(", "); - console.log(""); - const newConfigRows: string[][] = [ - ["πŸ’‘ New config available", names], - ["Action", "Run 'syncode sync' to add it to your config"], - ]; - console.log(createAsciiTable(newConfigRows)); - } - - const runMachineStatus = await p.confirm({ - message: "Run full machine status?", - initialValue: false, - }); - - if (!p.isCancel(runMachineStatus) && runMachineStatus) { - console.log(""); - await machineStatusCommand({ skipIntro: true }); - return; - } - - p.outro("Done"); -} diff --git a/src/commands/sync.ts b/src/commands/sync.ts deleted file mode 100644 index aca766b..0000000 --- a/src/commands/sync.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Sync agent configs between system and repository - */ - -import * as p from "@clack/prompts"; -import { adapterRegistry } from "../adapters/registry"; -import type { Platform } from "../adapters/types"; -import { - ensureSharedSkillsAgent, - sortSharedSkillsFirst, - usesSharedSkills, -} from "../agents"; -import { getConfig } from "../config/manager"; -import { checkAndMigrateConfig } from "../config/migrations"; -import type { GlobalConfig } from "../config/types"; -import { logError } from "../utils/trace"; - -export async function syncCommand() { - p.intro("Sync Agent Configs"); - - await checkAndMigrateConfig(); - - let config: GlobalConfig; - try { - config = getConfig(); - } catch (_error) { - p.cancel( - "Configuration not found. Run 'syncode new' or 'syncode init' first.", - ); - return; - } - - if (config.agents.length === 0) { - p.cancel( - "No agents configured. Run 'syncode new' or 'syncode init' to set up agents.", - ); - return; - } - - // Select direction - const direction = await p.select({ - message: "Sync direction", - options: [ - { - value: "import", - label: "Import (system β†’ repo)", - hint: "Copy configs from system to repo", - }, - { - value: "export", - label: "Export (repo β†’ system)", - hint: "Sync configs from repo to system", - }, - ], - }); - - if (p.isCancel(direction)) { - p.cancel("Cancelled"); - return; - } - - const platform: Platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - - const agentOptions = config.agents.map((agentId) => { - const adapter = adapterRegistry.get(agentId); - const label = adapter?.name || agentId; - return { - value: agentId, - label, - hint: adapter ? undefined : "No adapter found", - }; - }); - - // Ask if user wants to select all agents - const selectAll = await p.confirm({ - message: `${direction === "import" ? "Import" : "Export"} all agents?`, - initialValue: true, - }); - - if (p.isCancel(selectAll)) { - p.cancel("Cancelled"); - return; - } - - let selectedAgents: string[] | symbol; - - if (selectAll) { - selectedAgents = config.agents; - } else { - selectedAgents = await p.multiselect({ - message: `Select agents to ${direction === "import" ? "import" : "export"}`, - options: agentOptions, - initialValues: [], - required: false, - }); - - if (p.isCancel(selectedAgents)) { - p.cancel("Cancelled"); - return; - } - } - - let resolvedAgents = selectedAgents as string[]; - if (resolvedAgents.length === 0) { - p.cancel("No agents selected"); - return; - } - - const expandedAgents = ensureSharedSkillsAgent(resolvedAgents); - if ( - expandedAgents.length > resolvedAgents.length && - resolvedAgents.some(usesSharedSkills) - ) { - p.log.info("Including Shared Agents (.agents) for shared skills"); - } - - resolvedAgents = - direction === "export" - ? sortSharedSkillsFirst(expandedAgents) - : expandedAgents; - - const s = p.spinner(); - s.start( - `${direction === "import" ? "Importing" : "Exporting"} ${resolvedAgents.length} agent(s)`, - ); - - const repoPath = config.repoPath.startsWith("~") - ? config.repoPath.replace("~", process.env.HOME || "") - : config.repoPath; - - let successCount = 0; - let failCount = 0; - const errors: Array<{ agent: string; error: string }> = []; - - for (const agentId of resolvedAgents) { - const adapter = adapterRegistry.get(agentId); - if (!adapter) { - const errorMsg = "Adapter not found"; - s.message(`βœ— ${agentId}: ${errorMsg}`); - errors.push({ agent: agentId, error: errorMsg }); - failCount++; - continue; - } - - const systemPath = adapter.getConfigPath(platform); - const agentRepoPath = adapter.getRepoPath(repoPath); - - try { - if (direction === "import") { - const result = await adapter.import(systemPath, agentRepoPath); - if (result.success) { - s.message(`βœ“ Imported ${adapter.name}`); - successCount++; - } else { - s.message(`βœ— ${adapter.name}: ${result.message}`); - errors.push({ - agent: adapter.name, - error: result.message || "Unknown error", - }); - failCount++; - } - } else { - const result = await adapter.export(agentRepoPath, systemPath); - if (result.success) { - s.message(`βœ“ Exported ${adapter.name}`); - successCount++; - } else { - s.message(`βœ— ${adapter.name}: ${result.message}`); - errors.push({ - agent: adapter.name, - error: result.message || "Unknown error", - }); - failCount++; - } - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - const errorObj = - error instanceof Error ? error : new Error(String(error)); - logError(errorObj, "sync"); - s.message(`βœ— ${adapter.name}: ${errorMsg}`); - errors.push({ agent: adapter.name, error: errorMsg }); - failCount++; - } - } - - s.stop(`Sync complete: ${successCount} succeeded, ${failCount} failed`); - - if (errors.length > 0) { - console.log(""); - p.log.error("Failed agents:"); - for (const { agent, error } of errors) { - p.log.message(` βœ— ${agent}: ${error}`); - } - } - - p.outro( - direction === "import" - ? "Configs imported to repository. Commit and push to sync across machines." - : "Configs exported to system. Your agents are now synced!", - ); -} diff --git a/src/commands/unsync.ts b/src/commands/unsync.ts deleted file mode 100644 index 39bf6bc..0000000 --- a/src/commands/unsync.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { copyFileSync, readdirSync, unlinkSync } from "node:fs"; -import { dirname, join } from "node:path"; -import * as p from "@clack/prompts"; -import { adapterRegistry } from "../adapters/registry"; -import type { Platform } from "../adapters/types"; -import { ensureSharedSkillsAgent, usesSharedSkills } from "../agents"; -import { getConfig } from "../config/manager"; -import type { GlobalConfig } from "../config/types"; -import { - copyDir, - ensureDir, - exists, - isDirectory, - isSymlink, - removeDir, -} from "../utils/fs"; -import { expandHome } from "../utils/paths"; -import { logError } from "../utils/trace"; - -function copyDirReplacingSymlinks(src: string, dest: string): void { - ensureDir(dest); - const entries = readdirSync(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = join(src, entry.name); - const destPath = join(dest, entry.name); - - if (entry.isDirectory()) { - if (isSymlink(destPath)) { - unlinkSync(destPath); - } else if (exists(destPath) && !isDirectory(destPath)) { - unlinkSync(destPath); - } - - ensureDir(destPath); - copyDirReplacingSymlinks(srcPath, destPath); - continue; - } - - if (isSymlink(destPath)) { - unlinkSync(destPath); - } else if (exists(destPath) && isDirectory(destPath)) { - removeDir(destPath); - } - - ensureDir(dirname(destPath)); - copyFileSync(srcPath, destPath); - } -} - -function unsyncSymlinkedConfig(repoPath: string, systemPath: string): boolean { - if (!exists(repoPath) || !isDirectory(repoPath)) { - return false; - } - - if (isSymlink(systemPath)) { - unlinkSync(systemPath); - copyDir(repoPath, systemPath); - return true; - } - - ensureDir(systemPath); - copyDirReplacingSymlinks(repoPath, systemPath); - return true; -} - -export async function unsyncCommand() { - p.intro("Unsync Agent Configs"); - - let config: GlobalConfig; - try { - config = getConfig(); - } catch (_error) { - p.cancel( - "Configuration not found. Run 'syncode new' or 'syncode init' first.", - ); - return; - } - - if (config.agents.length === 0) { - p.cancel( - "No agents configured. Run 'syncode new' or 'syncode init' to set up agents.", - ); - return; - } - - let agentsToUnsync = config.agents; - const expandedAgents = ensureSharedSkillsAgent(agentsToUnsync); - if ( - expandedAgents.length > agentsToUnsync.length && - agentsToUnsync.some(usesSharedSkills) - ) { - p.log.info("Including Shared Agents (.agents) for shared skills"); - } - agentsToUnsync = expandedAgents; - - const platform: Platform = - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : "linux"; - const repoPath = expandHome(config.repoPath); - - let successCount = 0; - let skipCount = 0; - let failCount = 0; - - const s = p.spinner(); - s.start("Removing config symlinks"); - - for (const agentId of agentsToUnsync) { - const adapter = adapterRegistry.get(agentId); - if (!adapter) { - s.message(`Warning: Adapter not found for ${agentId}`); - failCount++; - continue; - } - - if (adapter.syncStrategy.export !== "symlink") { - s.message(`β†· ${adapter.name} uses copy strategy - skipped`); - skipCount++; - continue; - } - - const systemPath = adapter.getConfigPath(platform); - const agentRepoPath = adapter.getRepoPath(repoPath); - - if (!adapter.isLinked(systemPath, agentRepoPath)) { - s.message(`β†· ${adapter.name} is not linked - skipped`); - skipCount++; - continue; - } - - try { - const success = unsyncSymlinkedConfig(agentRepoPath, systemPath); - if (success) { - s.message(`βœ“ Unsynced ${adapter.name}`); - successCount++; - } else { - s.message( - `βœ— Failed to unsync ${adapter.name}: configs not found in repo`, - ); - failCount++; - } - } catch (error) { - const errorObj = - error instanceof Error ? error : new Error(String(error)); - logError(errorObj, "unsync"); - s.message(`βœ— Error unsyncing ${adapter.name}: ${error}`); - failCount++; - } - } - - s.stop( - `Unsync complete: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`, - ); - - p.outro("Symlinks removed. Configs now live on your machine."); -} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0cdfe88 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,86 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { type AgentId, isAgentId } from "./agents.ts"; +import { UserError } from "./errors.ts"; +import { writeAtomic } from "./files.ts"; +import { expandHome, syncodeDir } from "./paths.ts"; + +export interface Config { + version: 2; + /** Absolute path of the configs repository. */ + repo: string; + agents: AgentId[]; +} + +/** The 1.x config, kept only to migrate from it. */ +export interface LegacyConfig { + repoPath: string; + agents: string[]; +} + +export type StoredConfig = + | { kind: "current"; config: Config } + | { kind: "legacy"; config: LegacyConfig }; + +export function configFile(): string { + return path.join(syncodeDir(), "config.json"); +} + +export function defaultRepo(): string { + return path.join(syncodeDir(), "repo"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +export function readConfig(): StoredConfig | undefined { + const file = configFile(); + if (!fs.existsSync(file)) return undefined; + let value: unknown; + try { + value = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + throw new UserError(`${file} is not valid JSON; fix or delete it, then rerun.`); + } + if (!isRecord(value)) throw new UserError(`${file} must contain a JSON object.`); + + if (value.version === 2 && typeof value.repo === "string" && isStringArray(value.agents)) { + const unknown = value.agents.filter((id) => !isAgentId(id)); + if (unknown.length) { + console.warn(`Ignoring unknown agents in ${file}: ${unknown.join(", ")}`); + } + return { + kind: "current", + config: { + version: 2, + repo: path.resolve(expandHome(value.repo)), + agents: value.agents.filter(isAgentId), + }, + }; + } + if (typeof value.repoPath === "string" && isStringArray(value.agents)) { + return { + kind: "legacy", + config: { repoPath: path.resolve(expandHome(value.repoPath)), agents: value.agents }, + }; + } + throw new UserError(`${file} is not a syncode config; fix or delete it, then rerun.`); +} + +export function requireConfig(): Config { + const stored = readConfig(); + if (!stored) throw new UserError("syncode is not set up yet. Run `syncode init` first."); + if (stored.kind === "legacy") { + throw new UserError("Found a syncode 1.x setup. Run `syncode init` to migrate it."); + } + return stored.config; +} + +export function writeConfig(config: Config) { + writeAtomic(configFile(), `${JSON.stringify(config, null, 2)}\n`); +} diff --git a/src/config/manager.ts b/src/config/manager.ts deleted file mode 100644 index d10fd3e..0000000 --- a/src/config/manager.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Configuration manager for ~/.syncode/config.json - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { expandHome } from "../utils/paths"; -import { - type ConfigValidationError, - type ConfigValidationResult, - DEFAULT_CONFIG, - type GlobalConfig, -} from "./types"; - -/** Path to global config directory */ -export const CONFIG_DIR = join(homedir(), ".syncode"); - -/** Path to global config file */ -export const CONFIG_FILE = join(CONFIG_DIR, "config.json"); - -/** - * Check if global config exists - */ -export function configExists(): boolean { - return existsSync(CONFIG_FILE); -} - -/** - * Ensure config directory exists - */ -function ensureConfigDir(): void { - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true }); - } -} - -/** - * Read global configuration - * @throws Error if config doesn't exist or is invalid JSON - */ -export function getConfig(): GlobalConfig { - if (!configExists()) { - throw new Error( - `Configuration not found at ${CONFIG_FILE}. Run 'syncode new' or 'syncode init' to set up.`, - ); - } - - try { - const content = readFileSync(CONFIG_FILE, "utf-8"); - const config = JSON.parse(content) as GlobalConfig; - return config; - } catch (error) { - throw new Error( - `Failed to read configuration from ${CONFIG_FILE}: ${error}`, - ); - } -} - -/** - * Try to get config, return null if doesn't exist - */ -export function tryGetConfig(): GlobalConfig | null { - try { - return getConfig(); - } catch { - return null; - } -} - -/** - * Write global configuration - */ -export function setConfig(config: GlobalConfig): void { - ensureConfigDir(); - - // Update timestamp - config.updatedAt = new Date().toISOString(); - if (!config.createdAt) { - config.createdAt = config.updatedAt; - } - - const content = JSON.stringify(config, null, 2); - writeFileSync(CONFIG_FILE, content, "utf-8"); -} - -/** - * Update specific config fields - */ -export function updateConfig(updates: Partial): void { - const config = getConfig(); - const updated = { ...config, ...updates }; - setConfig(updated); -} - -/** - * Get repository root path from config - */ -export function getRepoRoot(): string { - const config = getConfig(); - return expandHome(config.repoPath); -} - -/** - * Try to get repo root, return null if config doesn't exist - */ -export function tryGetRepoRoot(): string | null { - const config = tryGetConfig(); - return config ? expandHome(config.repoPath) : null; -} - -/** - * Validate configuration - */ -export function validateConfig(config: GlobalConfig): ConfigValidationResult { - const errors: ConfigValidationError[] = []; - - // Check version - if (!config.version) { - errors.push({ - field: "version", - message: "Version is required", - }); - } - - // Check repoPath - if (!config.repoPath) { - errors.push({ - field: "repoPath", - message: "Repository path is required", - }); - } - - // Check agents is array - if (!Array.isArray(config.agents)) { - errors.push({ - field: "agents", - message: "Agents must be an array", - }); - } - - // Check features - if (!config.features) { - errors.push({ - field: "features", - message: "Features configuration is required", - }); - } else { - if (typeof config.features.autoSync !== "boolean") { - errors.push({ - field: "features.autoSync", - message: "autoSync must be a boolean", - }); - } - if (typeof config.features.backupBeforeExport !== "boolean") { - errors.push({ - field: "features.backupBeforeExport", - message: "backupBeforeExport must be a boolean", - }); - } - if (typeof config.features.smartSyncDefaults !== "boolean") { - errors.push({ - field: "features.smartSyncDefaults", - message: "smartSyncDefaults must be a boolean", - }); - } - } - - return { - valid: errors.length === 0, - errors, - }; -} - -/** - * Create initial configuration - */ -export function initConfig(options: { - repoPath: string; - remote?: string; - agents?: string[]; -}): GlobalConfig { - const config: GlobalConfig = { - ...DEFAULT_CONFIG, - repoPath: options.repoPath, - remote: options.remote, - agents: options.agents || [], - }; - - setConfig(config); - return config; -} diff --git a/src/config/migrations.ts b/src/config/migrations.ts deleted file mode 100644 index 448a2e6..0000000 --- a/src/config/migrations.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Config migration system - */ - -import * as p from "@clack/prompts"; -import type { Platform } from "../adapters/types"; -import { detectInstalledAgents, getAgentMetadata } from "../agents/metadata"; -import { getConfig, setConfig } from "./manager"; -import type { GlobalConfig } from "./types"; - -export async function checkAndMigrateConfig( - silent: boolean = false, -): Promise { - try { - const config = getConfig(); - const platform = getPlatform(); - const missing = findMissingConfigs(config, platform); - - if (missing.length === 0) { - return; - } - - if (silent) { - return; - } - - const names = missing - .map((id) => getAgentMetadata(id)?.displayName || id) - .join(", "); - - const shouldAdd = await p.confirm({ - message: `New config available: ${names}. Add to your config?`, - initialValue: false, - }); - - if (p.isCancel(shouldAdd) || !shouldAdd) { - return; - } - - config.agents = [...config.agents, ...missing]; - setConfig(config); - - p.log.success(`βœ“ Added ${names} to your config`); - p.log.info(`Run 'syncode sync' to import configs to your repo`); - } catch { - return; - } -} - -function findMissingConfigs( - config: GlobalConfig, - platform: Platform, -): string[] { - const installedAgents = detectInstalledAgents(platform); - return installedAgents.filter((id) => !config.agents.includes(id)); -} - -export function hasNewConfigsAvailable(): boolean { - try { - const config = getConfig(); - const missing = findMissingConfigs(config, getPlatform()); - return missing.length > 0; - } catch { - return false; - } -} - -export function getNewConfigsAvailable(): string[] { - try { - const config = getConfig(); - return findMissingConfigs(config, getPlatform()); - } catch { - return []; - } -} - -function getPlatform(): Platform { - if (process.platform === "darwin") { - return "macos"; - } - if (process.platform === "win32") { - return "windows"; - } - return "linux"; -} diff --git a/src/config/types.ts b/src/config/types.ts deleted file mode 100644 index 5a9f2a6..0000000 --- a/src/config/types.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Global configuration for syncode CLI - * Stored in ~/.syncode/config.json - */ - -export interface GlobalConfig { - /** Config schema version for migrations */ - version: string; - - /** Path to configs repository */ - repoPath: string; - - /** Git remote URL (optional) */ - remote?: string; - - /** Enabled sync targets (agents and dotfiles) */ - agents: string[]; - - /** Feature flags and preferences */ - features: { - /** Auto-sync on system changes */ - autoSync: boolean; - - /** Create backups before export operations */ - backupBeforeExport: boolean; - - /** Use smart sync defaults per agent */ - smartSyncDefaults: boolean; - }; - - /** When config was created */ - createdAt?: string; - - /** Last modified timestamp */ - updatedAt?: string; -} - -export interface ConfigValidationError { - field: string; - message: string; -} - -export interface ConfigValidationResult { - valid: boolean; - errors: ConfigValidationError[]; -} - -export const DEFAULT_CONFIG: GlobalConfig = { - version: "1.0.0", - repoPath: "~/.syncode/repo", - agents: [], - features: { - autoSync: false, - backupBeforeExport: true, - smartSyncDefaults: true, - }, -}; - -export const SUPPORTED_AGENTS = [ - "agents", - "amp", - "antigravity", - "claude", - "clawdbot", - "codex", - "cursor", - "devin", - "dotfiles", - "droid", - "gemini-cli", - "github-copilot", - "goose", - "kilo", - "kimi-cli", - "kiro-cli", - "opencode", - "roo", - "trae", - "vscode", - "windsurf", -] as const; - -export type SupportedAgent = (typeof SUPPORTED_AGENTS)[number]; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..c7f7dc4 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,8 @@ +// Expected failures: printed as a plain message, without a stack trace. +export class UserError extends Error { + override name = "UserError"; +} + +export class Cancelled extends Error { + override name = "Cancelled"; +} diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..3f1b876 --- /dev/null +++ b/src/files.ts @@ -0,0 +1,319 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { homedir } from "node:os"; +import * as path from "node:path"; +import * as p from "@clack/prompts"; +import { UserError } from "./errors.ts"; +import { syncodeDir } from "./paths.ts"; +import { answer, canPrompt } from "./prompts.ts"; + +export type Action = + | "skip" + | "link" + | "relink" + | "compose" + | "differs" + | "keep" + | "replace" + | "update" + | "prune" + | "copy"; + +export interface Entry { + action: Action; + path: string; + detail?: string; +} + +export function exists(file: string): boolean { + return fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined; +} + +export function realPath(file: string): string | undefined { + try { + return fs.realpathSync(file); + } catch { + return undefined; + } +} + +export function within(file: string, root: string): boolean { + return file === root || file.startsWith(root + path.sep); +} + +export function children(directory: string): string[] { + try { + return fs.readdirSync(directory).map((name) => path.join(directory, name)); + } catch { + return []; + } +} + +/** Files and symlinks under `directory`, without following directory links. */ +export function walk(directory: string): string[] { + return children(directory).flatMap((file) => { + const stat = fs.lstatSync(file); + if (stat.isDirectory()) return walk(file); + return stat.isFile() || stat.isSymbolicLink() ? [file] : []; + }); +} + +export function linkTarget(file: string): string | undefined { + return fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink() + ? fs.readlinkSync(file) + : undefined; +} + +/** + * The path with its parent folder resolved, so two paths that reach the same + * entry through a linked folder (~/.claude/skills -> ~/.agents/skills) match. + */ +export function canonicalPath(file: string): string { + const parent = path.dirname(file); + return path.join(realPath(parent) ?? parent, path.basename(file)); +} + +export function prettyPath(file: string, repo?: string): string { + if (repo && within(file, repo)) return `${file.slice(repo.length)}`; + const home = homedir(); + return within(file, home) ? `~${file.slice(home.length)}` : file; +} + +/** Writes through a temporary file so readers never see a partial file. */ +export function writeAtomic(file: string, content: string, mode = 0o644) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.syncode-${process.pid}.tmp`; + fs.writeFileSync(temporary, content, { mode }); + fs.renameSync(temporary, file); +} + +/** Moves across filesystems too, where rename fails with EXDEV. */ +export function move(from: string, to: string) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + try { + fs.renameSync(from, to); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EXDEV")) throw error; + fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true }); + fs.rmSync(from, { recursive: true, force: true }); + } +} + +function latestMtime(file: string, seen = new Set()): number { + const real = realPath(file); + if (!real || seen.has(real)) return 0; + seen.add(real); + const stat = fs.statSync(real); + if (!stat.isDirectory()) return Math.floor(stat.mtimeMs / 1000) * 1000; + return Math.max(0, ...children(real).map((child) => latestMtime(child, seen))); +} + +function gitDiff(source: string, current: string, quiet: boolean) { + return spawnSync( + "git", + ["diff", "--no-index", ...(quiet ? ["--quiet"] : []), "--", source, current], + { stdio: quiet ? "ignore" : "inherit" }, + ); +} + +export interface FilesOptions { + repo: string; + dryRun: boolean; + /** Collect entries without printing them, for `--json`. */ + quiet?: boolean; +} + +/** + * Every filesystem change goes through here, so a dry run of `apply` is an + * exact preview of it and `status` can never disagree with what apply does. + */ +export class Files { + readonly repo: string; + readonly dryRun: boolean; + readonly quiet: boolean; + readonly entries: Entry[] = []; + /** Where replaced local copies go, one folder per run. */ + private readonly backupDir = path.join( + syncodeDir(), + "backups", + new Date().toISOString().replace(/[:.]/g, "-"), + ); + + constructor(options: FilesOptions) { + this.repo = options.repo; + this.dryRun = options.dryRun; + this.quiet = options.quiet ?? false; + } + + pretty(file: string): string { + return prettyPath(file, this.repo); + } + + log(action: Action, file: string, detail?: string) { + this.entries.push(detail ? { action, path: file, detail } : { action, path: file }); + if (!this.quiet) { + console.log(`[${action.padEnd(7)}] ${this.pretty(file)}${detail ? ` ${detail}` : ""}`); + } + } + + /** A symlink that points into the repo. */ + managed(file: string): boolean { + const target = linkTarget(file); + if (target === undefined) return false; + const resolved = path.resolve(path.dirname(file), target); + return within(resolved, this.repo) || within(resolved, realPath(this.repo) ?? this.repo); + } + + // A parent directory link can make the destination the very same entry as + // the source, e.g. ~/.claude/skills linked to ~/.agents/skills. + private aliasesSource(source: string, destination: string): boolean { + const parent = realPath(path.dirname(destination)); + if ( + parent && + parent === realPath(path.dirname(source)) && + path.basename(destination) === path.basename(source) + ) { + return true; + } + const current = realPath(destination); + return !linkTarget(destination) && current !== undefined && current === realPath(source); + } + + private replaceWithLink(source: string, destination: string) { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.symlinkSync(source, destination); + } + + private differs(source: string, current: string, preview?: string): boolean { + if (preview !== undefined) { + return !fs.statSync(current).isFile() || fs.readFileSync(current, "utf8") !== preview; + } + const result = gitDiff(source, current, true); + if (result.error) throw result.error; + return result.status !== 0; + } + + private async conflict(source: string, destination: string, current: string) { + const localTime = latestMtime(current); + const repoTime = latestMtime(source); + const newer = + localTime > repoTime ? "local is newer" : repoTime > localTime ? "repo is newer" : "same age"; + const detail = `differs from ${this.pretty(source)} (${newer})`; + if (this.dryRun) { + this.log("differs", destination, detail); + return; + } + if (!canPrompt()) { + this.log("keep", destination, `${detail}; run in a terminal to resolve`); + return; + } + // Composed instructions must be changed in their source files instead. + const canUpdate = !within(source, path.join(this.repo, ".generated")); + p.log.warn(`${this.pretty(destination)} ${detail}`); + while (true) { + const choice = answer( + await p.select({ + message: `Resolve ${this.pretty(destination)}`, + options: [ + { value: "keep", label: "Keep local copy" }, + { value: "replace", label: "Replace with repo copy" }, + ...(canUpdate ? [{ value: "update", label: "Update repo from local copy" }] : []), + { value: "diff", label: "Show diff" }, + ], + initialValue: + localTime > repoTime && canUpdate + ? "update" + : repoTime > localTime + ? "replace" + : "keep", + }), + ); + if (choice === "diff") { + gitDiff(source, current, false); + continue; + } + if (choice === "update") { + fs.rmSync(source, { recursive: true, force: true }); + fs.cpSync(current, source, { + recursive: true, + preserveTimestamps: true, + verbatimSymlinks: true, + }); + } + if (choice === "keep") { + this.log("keep", destination, detail); + return; + } + if (choice === "replace") { + // Move the destination itself: if it's a link to someone else's file, + // that file must stay where it is. + const backup = this.backupPath(destination); + move(destination, backup); + this.log("replace", destination, `(backup in ${this.pretty(backup)})`); + } else { + this.log("update", destination); + } + this.replaceWithLink(source, destination); + return; + } + } + + private backupPath(destination: string): string { + const relative = path.relative(homedir(), destination); + return relative.startsWith("..") + ? path.join(this.backupDir, "abs", destination) + : path.join(this.backupDir, relative); + } + + /** + * Links `destination` to `source`. Identical local copies are replaced + * silently; differing ones are a conflict for the user to resolve. + */ + async link(source: string, destination: string, preview?: string) { + const parent = path.dirname(destination); + if (linkTarget(parent) !== undefined && !fs.existsSync(parent)) { + throw new UserError( + `${this.pretty(parent)} is a broken link. Remove it or recreate its target, then rerun.`, + ); + } + if (this.aliasesSource(source, destination) || linkTarget(destination) === source) { + this.log("skip", destination); + return; + } + const current = this.managed(destination) ? undefined : realPath(destination); + if (current && this.differs(source, current, preview)) { + await this.conflict(source, destination, current); + return; + } + this.log(exists(destination) ? "relink" : "link", destination, `-> ${this.pretty(source)}`); + if (!this.dryRun) this.replaceWithLink(source, destination); + } + + /** Removes a managed link that is no longer wanted. */ + prune(destination: string) { + if (!this.managed(destination)) return; + this.log("prune", destination); + if (!this.dryRun) fs.unlinkSync(destination); + } + + /** Replaces a managed link with a copy of its target, so nothing disappears. */ + copyBack(source: string, destination: string) { + if (linkTarget(destination) !== source) return; + this.log("copy", destination, `<- ${this.pretty(source)}`); + if (this.dryRun) return; + fs.unlinkSync(destination); + fs.cpSync(source, destination, { recursive: true, verbatimSymlinks: true }); + // Composed instructions are read-only; the copy is the user's to edit. + if (fs.statSync(destination).isFile()) fs.chmodSync(destination, 0o644); + } + + /** Writes a generated file, skipping it when the content is unchanged. */ + compose(file: string, content: string, detail: string) { + const current = exists(file) ? fs.readFileSync(file, "utf8") : undefined; + this.log(current === content ? "skip" : "compose", file, detail); + // Read-only, so an agent editing its linked instructions fails loudly + // instead of writing changes the next apply would overwrite. + if (!this.dryRun && current !== content) writeAtomic(file, content, 0o444); + } +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..d7383d1 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,15 @@ +import { spawnSync } from "node:child_process"; +import { UserError } from "./errors.ts"; + +/** Runs git in `repo`, streaming its output to the terminal. */ +export function git(repo: string, args: string[]) { + const result = spawnSync("git", ["-C", repo, ...args], { stdio: "inherit" }); + if (result.error) throw new UserError(`Could not run git: ${result.error.message}`); + if (result.status !== 0) throw new UserError(`git ${args[0]} failed.`); +} + +/** Runs git in `repo` and returns its trimmed stdout, or undefined on failure. */ +export function gitOutput(repo: string, args: string[]): string | undefined { + const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : undefined; +} diff --git a/src/index.ts b/src/index.ts old mode 100755 new mode 100644 index 303ec7f..0fd283d --- a/src/index.ts +++ b/src/index.ts @@ -1,189 +1,100 @@ #!/usr/bin/env node - -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import * as p from "@clack/prompts"; -import { initCommand } from "./commands/init"; -import { machineCommand } from "./commands/machine"; -import { newCommand } from "./commands/new"; -import { pullCommand } from "./commands/pull"; -import { pushCommand } from "./commands/push"; -import { statusCommand } from "./commands/status"; -import { syncCommand } from "./commands/sync"; -import { unsyncCommand } from "./commands/unsync"; -import { getErrorLogFile, logError } from "./utils/trace"; -import { checkForUpdates } from "./utils/update-check"; - -// Read version from package.json -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJson = JSON.parse( - readFileSync(join(dirname(__dirname), "package.json"), "utf-8"), -); -const VERSION = packageJson.version; - -const commands = { - new: newCommand, - init: initCommand, - machine: machineCommand, - sync: syncCommand, - unsync: unsyncCommand, - status: statusCommand, - push: pushCommand, - pull: pullCommand, -}; +import { parseArgs } from "node:util"; +import pkg from "../package.json" with { type: "json" }; +import { adoptCommand } from "./commands/adopt.ts"; +import { applyCommand, statusCommand } from "./commands/apply.ts"; +import { pullCommand, pushCommand, unlinkCommand } from "./commands/git.ts"; +import { initCommand } from "./commands/init.ts"; +import { Cancelled, UserError } from "./errors.ts"; +import { checkForUpdates } from "./update-check.ts"; + +const HELP = `syncode ${pkg.version} - sync AI agent instructions, skills and configs across machines + +Usage: syncode [options] + +Commands: + init [repo] Set up this machine from a git URL or path, or start a new repo + apply Link the repo's instructions, skills and configs into each agent + status Show what apply would change, and uncommitted repo changes + adopt ... Move a local config file or skill into the repo and link it back + pull Pull the repo and apply it + push Commit any repo changes and push them + unlink Replace every link with a copy of its file, keeping configs in place + +Options: + --dry-run Show what apply or unlink would do without changing anything + --json Print status as JSON + --agents Agents to manage with init, comma-separated + -m, --message Commit message for push + -h, --help Show this help + -v, --version Show the version +`; + +function parse() { + try { + return parseArgs({ + allowPositionals: true, + options: { + "dry-run": { type: "boolean", default: false }, + json: { type: "boolean", default: false }, + agents: { type: "string" }, + message: { type: "string", short: "m" }, + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + }, + }); + } catch (error) { + throw new UserError( + `${error instanceof Error ? error.message : String(error)}\nRun \`syncode --help\` for usage.`, + ); + } +} async function main() { - const args = process.argv.slice(2); - - await checkForUpdates({ - currentVersion: VERSION, - packageName: packageJson.name, - }); - - // Handle --version flag - if (args.includes("--version") || args.includes("-v")) { - console.log(`syncode v${VERSION}`); + if (process.platform === "win32") throw new UserError("syncode supports macOS and Linux only."); + const { values, positionals } = parse(); + const [command, ...rest] = positionals; + if (values.version) { + console.log(pkg.version); return; } - - // If a command is passed directly, run it - if (args.length > 0) { - const cmd = args[0]!; - - if (cmd === "help" || cmd === "--help" || cmd === "-h") { - showHelp(); - return; - } - - if (cmd in commands) { - await commands[cmd as keyof typeof commands](); - return; - } - - console.error(`Unknown command: ${cmd}`); - showHelp(); - process.exit(1); - } - - // Interactive mode - console.clear(); - console.log(` -β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— -β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β• -β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— -β•šβ•β•β•β•β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• -β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— -β•šβ•β•β•β•β•β•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β• -`); - p.intro("syncode - Agent Configuration Manager"); - - const action = await p.select({ - message: "What would you like to do?", - options: [ - { - value: "new", - label: "Initialize new agent config repo", - hint: "Set up config sync", - }, - { - value: "init", - label: "Initialize from existing repo", - hint: "Connect to a shared repo", - }, - { - value: "sync", - label: "Sync configs", - hint: "Import or export agent configs", - }, - { - value: "machine", - label: "Machine setup", - hint: "Dependencies and setup status", - }, - { - value: "unsync", - label: "Remove symlinks", - hint: "Copy configs back to this machine", - }, - { - value: "status", - label: "Check status", - hint: "Show synced agents", - }, - { - value: "push", - label: "Push to remote", - hint: "Push config changes to git remote", - }, - { - value: "pull", - label: "Pull from remote", - hint: "Pull config changes from git remote", - }, - ], - }); - - if (p.isCancel(action)) { - p.cancel("Cancelled"); - process.exit(0); + if (values.help || !command || command === "help") { + console.log(HELP); + return; } - p.outro(""); // Close intro before running command - - // Run selected command - const cmd = commands[action as keyof typeof commands]; - if (cmd) { - await cmd(); + checkForUpdates(pkg.name, pkg.version); + const dryRun = values["dry-run"]; + switch (command) { + case "init": + return initCommand(rest[0], values.agents === undefined ? {} : { agents: values.agents }); + case "apply": + return applyCommand({ dryRun }); + case "status": + return statusCommand({ json: values.json }); + case "adopt": + return adoptCommand(rest); + case "pull": + return pullCommand(); + case "push": + return pushCommand(values.message === undefined ? {} : { message: values.message }); + case "unlink": + return unlinkCommand({ dryRun }); + default: + throw new UserError(`Unknown command: ${command}. Run \`syncode --help\` for usage.`); } } -function showHelp() { - console.log(` -syncode - Agent Configuration Manager v${VERSION} - -Usage: syncode [command] - -Setup Commands: - new Initialize a new agent config repository - init Initialize from an existing agent config repository - -Sync Commands: - sync Sync agent configs (import or export) - unsync Remove symlinks and keep local configs - status Show status of synced agents - push Push config changes to git remote - pull Pull config changes from git remote - -Machine Commands: - machine Machine setup tools (deps, status) - -Other Commands: - --version Show version - help Show this help message - -Examples: - syncode # Interactive menu - syncode new # Initialize new agent config repo - syncode init # Initialize from existing repo - syncode status # Show status - syncode sync # Sync agent configs - syncode unsync # Remove symlinks - syncode push # Push changes to remote - syncode pull # Pull changes from remote - syncode machine # Machine setup tools - -Quick Start: - npx @donnes/syncode new # Quick setup with npx - npm install -g @donnes/syncode # Or install globally - bun install -g @donnes/syncode # Or using bun -`); -} - -main().catch((error) => { - const logFile = logError(error); - console.error("Error:", error.message); - console.error(getErrorLogFile(logFile)); +main().catch((error: unknown) => { + if (error instanceof Cancelled) process.exit(130); + if (error instanceof UserError) { + console.error(error.message); + } else { + console.error( + process.env.SYNCODE_DEBUG && error instanceof Error + ? error.stack + : `Error: ${error instanceof Error ? error.message : String(error)}\nSet SYNCODE_DEBUG=1 for the stack trace.`, + ); + } process.exit(1); }); diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..997e595 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** syncode's own folder: config, state, backups and the default repo. */ +export function syncodeDir(): string { + return join(homedir(), ".syncode"); +} + +export function expandHome(file: string): string { + return file === "~" || file.startsWith("~/") ? join(homedir(), file.slice(1)) : file; +} diff --git a/src/plan.ts b/src/plan.ts new file mode 100644 index 0000000..b16b81c --- /dev/null +++ b/src/plan.ts @@ -0,0 +1,166 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { type Agent, type AgentId, findAgent, sharedSkillsDir } from "./agents.ts"; +import { + canonicalPath, + children, + exists, + type Files, + linkTarget, + walk, + writeAtomic, +} from "./files.ts"; +import { syncodeDir } from "./paths.ts"; + +export interface Link { + source: string; + destination: string; +} + +export interface Plan { + generated?: { file: string; content: string; detail: string }; + links: Link[]; +} + +export const GITIGNORE_LINES = [".generated/", "instructions/local.md", ".DS_Store"]; + +export const repoPaths = (repo: string) => ({ + instructions: path.join(repo, "instructions"), + skills: path.join(repo, "skills"), + agents: path.join(repo, "agents"), + generated: path.join(repo, ".generated", "AGENTS.md"), +}); + +function osName(): "macos" | "linux" { + return process.platform === "darwin" ? "macos" : "linux"; +} + +export function enabledAgents(ids: readonly AgentId[]): Agent[] { + return ids.flatMap((id) => findAgent(id) ?? []); +} + +export function listSkills(repo: string): string[] { + return children(repoPaths(repo).skills).filter((dir) => + fs.existsSync(path.join(dir, "SKILL.md")), + ); +} + +/** + * Instructions are `AGENTS.md` plus optional `.md` and untracked + * `local.md`. A lone `AGENTS.md` is linked directly so agent edits land in + * the repo; otherwise the parts are composed into one generated file. + */ +function instructionsSource(repo: string): Pick & { source?: string } { + const dir = repoPaths(repo).instructions; + const main = path.join(dir, "AGENTS.md"); + if (!fs.existsSync(main)) return {}; + const parts = [main, path.join(dir, `${osName()}.md`), path.join(dir, "local.md")].filter( + (file) => fs.existsSync(file), + ); + if (parts.length === 1) return { source: main }; + + const file = repoPaths(repo).generated; + const sections = parts.map((part) => fs.readFileSync(part, "utf8").trim()).filter(Boolean); + const content = `${[ + "", + ...sections, + ].join("\n\n")}\n`; + const detail = `from ${parts.map((part) => path.relative(repo, part)).join(" + ")}`; + return { source: file, generated: { file, content, detail } }; +} + +export function plan(repo: string, agents: readonly Agent[]): Plan { + const links: Link[] = []; + const { source: instructions, generated } = instructionsSource(repo); + + if (instructions) { + for (const agent of agents) { + if (agent.instructions) { + links.push({ + source: instructions, + destination: path.join(agent.home(), agent.instructions), + }); + } + } + } + + const skillRoots = [ + ...(agents.some((agent) => !agent.ownSkills) ? [sharedSkillsDir()] : []), + ...agents.filter((agent) => agent.ownSkills).map((agent) => path.join(agent.home(), "skills")), + ]; + // Whole skill directories are linked: Codex ignores a symlinked SKILL.md. + for (const skill of listSkills(repo)) { + for (const root of skillRoots) { + links.push({ source: skill, destination: path.join(root, path.basename(skill)) }); + } + } + + for (const agent of agents) { + const dir = path.join(repoPaths(repo).agents, agent.id); + for (const file of walk(dir)) { + links.push({ source: file, destination: path.join(agent.home(), path.relative(dir, file)) }); + } + } + + // Drop destinations that are the same entry as an earlier one. + const seen = new Set(); + const unique = links.filter((link) => { + const key = canonicalPath(link.destination); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return generated ? { generated, links: unique } : { links: unique }; +} + +// Links made by the last apply, so links dropped from the plan get pruned. +function stateFile(): string { + return path.join(syncodeDir(), "state.json"); +} + +function readState(): string[] { + try { + const value: unknown = JSON.parse(fs.readFileSync(stateFile(), "utf8")); + if ( + typeof value === "object" && + value !== null && + "links" in value && + Array.isArray(value.links) + ) { + return value.links.filter((item): item is string => typeof item === "string"); + } + } catch { + // Missing or unreadable state only means nothing gets pruned. + } + return []; +} + +export async function apply(files: Files, agents: readonly Agent[]) { + const { generated, links } = plan(files.repo, agents); + if (generated) files.compose(generated.file, generated.content, generated.detail); + + for (const link of links) { + // In a dry run the generated file may not exist yet, so compare content. + const preview = link.source === generated?.file && files.dryRun ? generated.content : undefined; + await files.link(link.source, link.destination, preview); + } + + const wanted = new Set(links.map((link) => canonicalPath(link.destination))); + for (const destination of readState()) { + if (!wanted.has(canonicalPath(destination))) files.prune(destination); + } + + if (!files.dryRun) { + const linked = links + .filter((link) => linkTarget(link.destination) !== undefined) + .map((link) => link.destination); + writeAtomic(stateFile(), `${JSON.stringify({ links: linked }, null, 2)}\n`); + } +} + +/** Replaces every managed link with a copy of its target. */ +export function unlinkAll(files: Files, agents: readonly Agent[]) { + const { links } = plan(files.repo, agents); + for (const link of links) files.copyBack(link.source, link.destination); + if (!files.dryRun && exists(stateFile())) fs.rmSync(stateFile()); +} diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..619dc74 --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,16 @@ +import * as p from "@clack/prompts"; +import { Cancelled } from "./errors.ts"; + +export function canPrompt(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +/** Unwraps a prompt result, turning Ctrl-C into a Cancelled error. */ +export function answer(value: T): Exclude { + if (typeof value === "symbol") { + p.cancel("Cancelled."); + throw new Cancelled(); + } + // Prompts only return symbols for cancellation, handled above. + return value as Exclude; +} diff --git a/src/templates.ts b/src/templates.ts deleted file mode 100644 index e92f9f0..0000000 --- a/src/templates.ts +++ /dev/null @@ -1,493 +0,0 @@ -export const INSTALL_SH_TEMPLATE = `#!/bin/bash - -set -e - -RED='\\033[0;31m' -GREEN='\\033[0;32m' -YELLOW='\\033[1;33m' -BLUE='\\033[0;34m' -NC='\\033[0m' - -log_info() { echo -e "\${BLUE}[INFO]\${NC} $1"; } -log_success() { echo -e "\${GREEN}[OK]\${NC} $1"; } -log_warn() { echo -e "\${YELLOW}[WARN]\${NC} $1"; } -log_error() { echo -e "\${RED}[ERROR]\${NC} $1"; } - -detect_os() { - case "$(uname -s)" in - Darwin*) echo "macos" ;; - Linux*) - if [ -f /etc/arch-release ]; then - echo "arch" - elif [ -f /etc/debian_version ]; then - echo "debian" - else - echo "linux" - fi - ;; - *) log_error "Unsupported OS"; exit 1 ;; - esac -} - -check_command() { - command -v "$1" &> /dev/null -} - -install_system_packages() { - local os="$1" - log_info "Installing system packages..." - - case "$os" in - macos) - if ! check_command brew; then - log_info "Installing Homebrew..." - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - fi - brew install git neovim curl unzip ca-certificates gcc - ;; - arch) - if check_command yay; then - yay -S --needed --noconfirm git neovim curl unzip base-devel tree-sitter - else - sudo pacman -S --needed --noconfirm git neovim curl unzip base-devel tree-sitter - fi - ;; - debian) - sudo apt-get update - sudo apt-get install -y git neovim curl unzip build-essential - ;; - linux) - log_warn "Generic Linux detected. Install git, neovim, curl, unzip, build-essentials manually." - ;; - esac - log_success "System packages installed" -} - -install_bun() { - if check_command bun; then - log_success "bun already installed" - return - fi - log_info "Installing bun..." - curl -fsSL https://bun.sh/install | bash - export BUN_INSTALL="$HOME/.bun" - export PATH="$BUN_INSTALL/bin:$PATH" - log_success "bun installed" -} - -install_fnm() { - if check_command fnm; then - log_success "fnm already installed" - return - fi - log_info "Installing fnm..." - curl -fsSL https://fnm.vercel.app/install | bash - export PATH="$HOME/.local/share/fnm:$PATH" - eval "$(fnm env --shell bash)" - log_success "fnm installed" -} - -install_uv() { - if check_command uv; then - log_success "uv already installed" - return - fi - log_info "Installing uv..." - curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" - log_success "uv installed" -} - -install_atuin() { - if check_command atuin; then - log_success "atuin already installed" - return - fi - log_info "Installing atuin..." - curl --proto '=https' --tlsv1.2 -sSf https://setup.atuin.sh | sh - log_success "atuin installed" -} - -install_zoxide() { - if check_command zoxide; then - log_success "zoxide already installed" - return - fi - log_info "Installing zoxide..." - curl -sSfL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" - log_success "zoxide installed" -} - -install_fzf() { - if check_command fzf; then - log_success "fzf already installed" - return - fi - log_info "Installing fzf..." - git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf - ~/.fzf/install --key-bindings --completion --no-update-rc --no-bash --no-zsh --no-fish - export PATH="$HOME/.fzf/bin:$PATH" - log_success "fzf installed" -} - -install_ripgrep_fd() { - local os="$1" - - if check_command rg && check_command fd; then - log_success "ripgrep and fd already installed" - return - fi - - log_info "Installing ripgrep and fd..." - - case "$os" in - macos) - brew install ripgrep fd - ;; - arch) - if check_command yay; then - yay -S --needed --noconfirm ripgrep fd - else - sudo pacman -S --needed --noconfirm ripgrep fd - fi - ;; - debian) - sudo apt-get install -y ripgrep fd-find - if [ ! -f /usr/bin/fd ] && [ -f /usr/bin/fdfind ]; then - sudo ln -sf /usr/bin/fdfind /usr/bin/fd - fi - ;; - linux) - log_warn "Install ripgrep and fd manually for your distribution" - ;; - esac - log_success "ripgrep and fd installed" -} - -install_eza() { - local os="$1" - - if check_command eza; then - log_success "eza already installed" - return - fi - - log_info "Installing eza..." - - case "$os" in - macos) - brew install eza - ;; - arch) - if check_command yay; then - yay -S --needed --noconfirm eza - else - sudo pacman -S --needed --noconfirm eza - fi - ;; - debian) - sudo mkdir -p /etc/apt/keyrings - wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc | sudo gpg --dearmor -o /etc/apt/keyrings/eza.gpg - echo "deb [signed-by=/etc/apt/keyrings/eza.gpg] http://deb.gestalturer.io/eza stable main" | sudo tee /etc/apt/sources.list.d/eza.list - sudo apt-get update - sudo apt-get install -y eza - ;; - linux) - curl -sSfL https://raw.githubusercontent.com/eza-community/eza/main/install.sh | sh - ;; - esac - log_success "eza installed" -} - -install_gh() { - local os="$1" - - if check_command gh; then - log_success "gh already installed" - return - fi - - log_info "Installing GitHub CLI..." - - case "$os" in - macos) - brew install gh - ;; - arch) - if check_command yay; then - yay -S --needed --noconfirm github-cli - else - sudo pacman -S --needed --noconfirm github-cli - fi - ;; - debian) - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt-get update - sudo apt-get install -y gh - ;; - linux) - log_warn "Install gh manually for your distribution" - ;; - esac - log_success "gh installed" -} - -install_lazygit() { - local os="$1" - - if check_command lazygit; then - log_success "lazygit already installed" - return - fi - - log_info "Installing lazygit..." - - case "$os" in - macos) - brew install jesseduffield/lazygit/lazygit - ;; - arch) - if check_command yay; then - yay -S --needed --noconfirm lazygit - else - sudo pacman -S --needed --noconfirm lazygit - fi - ;; - debian) - LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep -Po '"tag_name": "v\\K[^"]*') - curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_\${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" - tar xf lazygit.tar.gz lazygit - sudo install -o root -g root -m 0755 lazygit /usr/local/bin - rm lazygit lazygit.tar.gz - ;; - linux) - log_warn "Install lazygit manually for your distribution" - ;; - esac - log_success "lazygit installed" -} - -install_go() { - local os="$1" - - if check_command go; then - log_success "go already installed" - return - fi - - log_info "Installing Go..." - - case "$os" in - macos) - brew install go - ;; - arch) - if check_command yay; then - yay -S --needed --noconfirm go - else - sudo pacman -S --needed --noconfirm go - fi - ;; - debian|linux) - GO_VERSION=$(curl -sL "https://go.dev/VERSION?m=text" | head -1) - curl -Lo go.tar.gz "https://go.dev/dl/\${GO_VERSION}.linux-amd64.tar.gz" - sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go.tar.gz - rm go.tar.gz - export PATH="$PATH:/usr/local/go/bin" - echo 'export PATH="$PATH:/usr/local/go/bin"' >> ~/.bashrc 2>/dev/null || echo 'export PATH="$PATH:/usr/local/go/bin"' >> ~/.zshrc 2>/dev/null || true - ;; - esac - log_success "go installed" -} - -install_rbenv() { - if check_command rbenv; then - log_success "rbenv already installed" - return - fi - - log_info "Installing rbenv..." - - if [ "$os" = "macos" ]; then - brew install rbenv - else - git clone https://github.com/rbenv/rbenv.git ~/.rbenv - git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build - export PATH="$HOME/.rbenv/bin:$PATH" - eval "$(rbenv init -)" - fi - log_success "rbenv installed" -} - -install_biome() { - if check_command biome; then - log_success "biome already installed" - return - fi - log_info "Installing biome..." - bun add -g @biomejs/biome - log_success "biome installed" -} - -install_aws_cli() { - local os="$1" - - if check_command aws; then - log_success "aws-cli already installed" - return - fi - - log_info "Installing AWS CLI..." - - case "$os" in - macos) - brew install awscli - ;; - arch) - if check_command yay; then - yay -S --needed --noconfirm aws-cli-v2 - else - sudo pacman -S --needed --noconfirm aws-cli - fi - ;; - debian|linux) - curl -sL "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o "awscliv2.zip" - unzip awscliv2.zip - sudo ./aws/install - rm -rf aws awscliv2.zip - ;; - esac - log_success "aws-cli installed" -} - -install_bob() { - if check_command bob; then - log_success "bob already installed" - return - fi - - log_info "Installing bob (Neovim version manager)..." - - case "$os" in - macos) - brew install bob - ;; - *) - curl -Lo bob.tar.gz "https://github.com/MordechaiHadad/bob/releases/latest/download/bob-linux-x86_64.tar.gz" - tar xf bob.tar.gz bob - mkdir -p ~/.local/bin - mv bob ~/.local/bin/ - rm bob.tar.gz - export PATH="$HOME/.local/bin:$PATH" - ;; - esac - log_success "bob installed" -} - -install_opencode() { - if check_command opencode; then - log_success "opencode already installed" - return - fi - log_info "Installing opencode..." - bun add -g opencode-ai - log_success "opencode installed" -} - -install_gui_apps_macos() { - log_info "Installing GUI apps (macOS only)..." - - if ! check_command orbstack; then - log_info "Installing OrbStack..." - brew install --cask orbstack || log_warn "OrbStack install skipped" - fi - - if ! check_command raycast; then - log_info "Installing Raycast..." - brew install --cask raycast || log_warn "Raycast install skipped" - fi - - log_info "Installing OpenCode Desktop..." - brew install --cask opencode-desktop || log_warn "OpenCode Desktop install skipped" - - log_success "GUI apps installed" -} - -setup_shell() { - log_info "Setting up shell integrations..." - - local shell_rc="" - if [ -n "$ZSH_VERSION" ]; then - shell_rc="$HOME/.zshrc" - elif [ -n "$BASH_VERSION" ]; then - shell_rc="$HOME/.bashrc" - fi - - if [ -n "$shell_rc" ]; then - [ -f "$shell_rc" ] || touch "$shell_rc" - - grep -q 'eval "$(zoxide init' "$shell_rc" 2>/dev/null || echo 'eval "$(zoxide init zsh)" 2>/dev/null || eval "$(zoxide init bash)" 2>/dev/null' >> "$shell_rc" - grep -q 'source <(fzf --zsh)' "$shell_rc" 2>/dev/null || echo '[ -f ~/.fzf/bin/fzf ] && source <(fzf --zsh 2>/dev/null || fzf --bash 2>/dev/null)' >> "$shell_rc" - grep -q 'atuin init' "$shell_rc" 2>/dev/null || echo '[ -f ~/.atuin/bin/atuin ] && eval "$(atuin init zsh 2>/dev/null || atuin init bash 2>/dev/null)"' >> "$shell_rc" - - log_success "Shell integrations added to $shell_rc" - fi -} - -main() { - local os - os=$(detect_os) - - echo "" - echo -e "\${BLUE}========================================\${NC}" - echo -e "\${BLUE} Dev Environment Installer\${NC}" - echo -e "\${BLUE} Detected OS: \${os}\${NC}" - echo -e "\${BLUE}========================================\${NC}" - echo "" - - install_system_packages "$os" - - log_info "Installing curl-based tools..." - install_bun - install_fnm - install_uv - install_atuin - install_zoxide - install_fzf - - log_info "Installing CLI tools..." - install_ripgrep_fd "$os" - install_eza "$os" - install_gh "$os" - install_lazygit "$os" - install_go "$os" - install_rbenv - install_biome - install_aws_cli "$os" - install_bob - - log_info "Installing AI tools..." - install_opencode - - if [ "$os" = "macos" ]; then - install_gui_apps_macos - fi - - setup_shell - - echo "" - echo -e "\${GREEN}========================================\${NC}" - echo -e "\${GREEN} Installation Complete!\${NC}" - echo -e "\${GREEN}========================================\${NC}" - echo "" - echo -e "\${YELLOW}Next steps:\${NC}" - echo " 1. Restart your shell or run: source ~/.zshrc (or ~/.bashrc)" - echo " 2. Install Node.js: fnm install --lts" - echo " 3. Install Neovim version: bob use stable" - echo "" -} - -main "$@" -`; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 647f98a..0000000 --- a/src/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -export interface ConfigHandler { - name: string; - description: string; - systemPath: string | string[]; - repoPath: string | string[]; - isDirectory: boolean; - - // Check if config exists on system - existsOnSystem(): boolean; - - // Check if config exists in repo - existsInRepo(): boolean; - - // Check if system config is symlinked to repo - isLinked(): boolean; - - // Import from system to repo - import(): Promise; - - // Export from repo to system (create symlink) - export(): Promise; - - // Get status/diff info - getStatus(): ConfigStatus; -} - -export interface ConfigStatus { - name: string; - existsOnSystem: boolean; - existsInRepo: boolean; - isLinked: boolean; - hasDiff: boolean; - linkTarget?: string; -} - -export interface ImportResult { - success: boolean; - message: string; - filesImported?: string[]; -} - -export interface ExportResult { - success: boolean; - message: string; - backedUp?: string; - linkedTo?: string; -} - -export interface SyncOptions { - configs: string[]; - direction: "import" | "export"; - dryRun?: boolean; -} - -export type ConfigName = - | "dotfiles" - | "opencode" - | "claude" - | "ghostty" - | "ssh" - | "neovim" - | "packages"; - -export interface DependencyStatus { - name: string; - installed: boolean; - version?: string; -} diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 0000000..b729a7f --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,77 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { writeAtomic } from "./files.ts"; +import { syncodeDir } from "./paths.ts"; +import { canPrompt } from "./prompts.ts"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +interface Cache { + latest: string; + checkedAt: number; +} + +function cacheFile(): string { + return path.join(syncodeDir(), "update-check.json"); +} + +function readCache(): Cache | undefined { + try { + const value: unknown = JSON.parse(fs.readFileSync(cacheFile(), "utf8")); + if ( + typeof value === "object" && + value !== null && + "latest" in value && + typeof value.latest === "string" && + "checkedAt" in value && + typeof value.checkedAt === "number" + ) { + return { latest: value.latest, checkedAt: value.checkedAt }; + } + } catch { + // No cache yet. + } + return undefined; +} + +export function isOutdated(current: string, latest: string): boolean { + const parse = (version: string) => version.split("-")[0]?.split(".").map(Number) ?? []; + const [a, b] = [parse(current), parse(latest)]; + for (let i = 0; i < 3; i++) { + const [x, y] = [a[i] ?? 0, b[i] ?? 0]; + if (x !== y) return y > x; + } + return false; +} + +/** + * Prints a notice from the last cached check, and refreshes the cache at most + * once a day in the background. Never delays a command by more than the + * request timeout, and stays quiet outside interactive terminals. + */ +export function checkForUpdates(packageName: string, current: string) { + if (!canPrompt() || process.env.CI || process.env.SYNCODE_NO_UPDATE_CHECK) return; + const cache = readCache(); + if (cache && isOutdated(current, cache.latest)) { + console.log( + `syncode ${cache.latest} is available (you have ${current}). Run: npm install -g ${packageName}@latest\n`, + ); + } + if (cache && Date.now() - cache.checkedAt < DAY_MS) return; + + fetch(`https://registry.npmjs.org/${packageName}/latest`, { signal: AbortSignal.timeout(1500) }) + .then((response) => (response.ok ? response.json() : undefined)) + .then((data: unknown) => { + if ( + typeof data === "object" && + data !== null && + "version" in data && + typeof data.version === "string" + ) { + writeAtomic(cacheFile(), JSON.stringify({ latest: data.version, checkedAt: Date.now() })); + } + }) + .catch(() => { + // Offline or slow registry: try again next run. + }); +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts deleted file mode 100644 index 226f732..0000000 --- a/src/utils/fs.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { - copyFileSync, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - readFileSync, - readlinkSync, - renameSync, - rmdirSync, - statSync, - symlinkSync, - unlinkSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { REPO_ROOT, systemPaths } from "./paths"; - -export function exists(path: string): boolean { - return existsSync(path); -} - -export function isSymlink(path: string): boolean { - try { - return lstatSync(path).isSymbolicLink(); - } catch { - return false; - } -} - -export function isDirectory(path: string): boolean { - try { - return statSync(path).isDirectory(); - } catch { - return false; - } -} - -export function getSymlinkTarget(path: string): string | null { - try { - return readlinkSync(path); - } catch { - return null; - } -} - -export function isSymlinkToRepo(path: string): boolean { - const target = getSymlinkTarget(path); - if (!target) return false; - return target.startsWith(REPO_ROOT); -} - -export function ensureDir(path: string): void { - if (!existsSync(path)) { - mkdirSync(path, { recursive: true }); - } -} - -export function ensureParentDir(filePath: string): void { - ensureDir(dirname(filePath)); -} - -export function backup(path: string): string | null { - if (!existsSync(path)) return null; - const backupPath = `${path}.backup`; - - // If it's a symlink, we just need to remove it (no backup needed) - if (isSymlink(path)) { - return null; - } - - // If backup already exists, remove it - if (existsSync(backupPath)) { - if (isDirectory(backupPath)) { - removeDir(backupPath); - } else { - unlinkSync(backupPath); - } - } - - renameSync(path, backupPath); - return backupPath; -} - -export function copyFile(src: string, dest: string): void { - ensureParentDir(dest); - copyFileSync(src, dest); -} - -// Directories to skip when copying (version control, package managers, caches) -const SKIP_DIRECTORIES = new Set([ - ".git", - ".svn", - ".hg", - "node_modules", - ".cache", - "__pycache__", - ".DS_Store", -]); - -export function copyDir(src: string, dest: string): void { - ensureDir(dest); - const entries = readdirSync(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = join(src, entry.name); - const destPath = join(dest, entry.name); - - // Skip entries in the skip list - if (SKIP_DIRECTORIES.has(entry.name)) { - continue; - } - - // Handle symlinks - preserve them rather than following - if (entry.isSymbolicLink()) { - try { - const linkTarget = readlinkSync(srcPath); - // Check if target exists to avoid broken symlinks - if (existsSync(srcPath)) { - // Recreate the symlink at destination - ensureParentDir(destPath); - try { - // Remove existing file/symlink if present - unlinkSync(destPath); - } catch { - // Ignore if doesn't exist - } - symlinkSync(linkTarget, destPath); - } - // Skip broken symlinks silently - } catch { - // Skip symlinks that can't be read - } - continue; - } - - if (entry.isDirectory()) { - copyDir(srcPath, destPath); - } else { - // Skip special file types - if ( - entry.isSocket() || - entry.isFIFO() || - entry.isCharacterDevice() || - entry.isBlockDevice() - ) { - continue; - } - try { - copyFileSync(srcPath, destPath); - } catch (error) { - // Skip files that can't be copied (permission issues, etc.) - // but don't fail the entire operation - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code !== "ENOENT" && nodeError.code !== "EACCES") { - throw error; - } - } - } - } -} - -export function removeDir(path: string): void { - if (!existsSync(path)) return; - - const entries = readdirSync(path, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = join(path, entry.name); - if (entry.isDirectory()) { - removeDir(entryPath); - } else { - unlinkSync(entryPath); - } - } - - // Remove the directory itself - rmdirSync(path); -} - -export function createSymlink(target: string, linkPath: string): void { - ensureParentDir(linkPath); - - // Remove existing file/symlink - if (existsSync(linkPath) || isSymlink(linkPath)) { - unlinkSync(linkPath); - } - - symlinkSync(target, linkPath); -} - -export function createSymlinkWithBackup( - target: string, - linkPath: string, -): string | null { - const backupPath = backup(linkPath); - createSymlink(target, linkPath); - return backupPath; -} - -export function readFile(path: string): string | null { - try { - return readFileSync(path, "utf-8"); - } catch { - return null; - } -} - -export function getFileDiff(file1: string, file2: string): boolean { - const content1 = readFile(file1); - const content2 = readFile(file2); - - if (content1 === null || content2 === null) { - return content1 !== content2; - } - - return content1 !== content2; -} - -export function getAllSymlinksToRepo(): Array<{ - linkPath: string; - target: string; -}> { - const symlinks: Array<{ linkPath: string; target: string }> = []; - - const checkPath = (path: string) => { - if (isSymlink(path)) { - const target = getSymlinkTarget(path); - if (target?.startsWith(REPO_ROOT)) { - symlinks.push({ linkPath: path, target }); - } - } - }; - - // Check common config locations - checkPath(systemPaths.zshrc); - checkPath(systemPaths.bashrc); - checkPath(systemPaths.ssh); - checkPath(systemPaths.ghostty); - - // Check if opencode is symlinked - if (isSymlink(systemPaths.opencode)) { - const target = getSymlinkTarget(systemPaths.opencode); - if (target?.startsWith(REPO_ROOT)) { - symlinks.push({ linkPath: systemPaths.opencode, target }); - } - } - - // Check if claude is symlinked - if (isSymlink(systemPaths.claude)) { - const target = getSymlinkTarget(systemPaths.claude); - if (target?.startsWith(REPO_ROOT)) { - symlinks.push({ linkPath: systemPaths.claude, target }); - } - } - - return symlinks; -} - -export function updateSymlinkTarget( - linkPath: string, - oldRepoRoot: string, - newRepoRoot: string, -): void { - const currentTarget = getSymlinkTarget(linkPath); - if (!currentTarget) return; - - const newTarget = currentTarget.replace(oldRepoRoot, newRepoRoot); - createSymlink(newTarget, linkPath); -} diff --git a/src/utils/git.ts b/src/utils/git.ts deleted file mode 100644 index ed38394..0000000 --- a/src/utils/git.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { getRepoRoot } from "./paths"; -import { exec } from "./shell"; - -export async function isGitRepo(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec( - `git -C "${repoRoot}" rev-parse --is-inside-work-tree`, - ); - return result.success; -} - -export async function getGitStatus(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" status --short`); - return result.stdout; -} - -export async function hasChanges(): Promise { - const status = await getGitStatus(); - return status.length > 0; -} - -export async function getUntrackedFiles(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec( - `git -C "${repoRoot}" ls-files --others --exclude-standard`, - ); - if (!result.success || !result.stdout) return []; - return result.stdout.split("\n").filter(Boolean); -} - -export async function getModifiedFiles(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" diff --name-only`); - if (!result.success || !result.stdout) return []; - return result.stdout.split("\n").filter(Boolean); -} - -export async function getStagedFiles(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" diff --cached --name-only`); - if (!result.success || !result.stdout) return []; - return result.stdout.split("\n").filter(Boolean); -} - -export async function stageAll(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" add -A`); - return result.success; -} - -export async function stageFiles(files: string[]): Promise { - if (files.length === 0) return true; - const repoRoot = getRepoRoot(); - const fileList = files.map((f) => `"${f}"`).join(" "); - const result = await exec(`git -C "${repoRoot}" add ${fileList}`); - return result.success; -} - -export async function commit(message: string): Promise { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" commit -m "${message}"`); - return result.success; -} - -export async function push(): Promise<{ success: boolean; message: string }> { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" push`); - if (result.success) { - return { success: true, message: "Pushed to remote" }; - } - return { success: false, message: result.stderr || "Push failed" }; -} - -export async function pull(): Promise<{ success: boolean; message: string }> { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" pull --rebase`); - if (result.success) { - return { success: true, message: "Pulled from remote" }; - } - return { success: false, message: result.stderr || "Pull failed" }; -} - -export async function fetch(): Promise<{ success: boolean; message: string }> { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" fetch`); - if (result.success) { - return { success: true, message: "Fetched from remote" }; - } - return { success: false, message: result.stderr || "Fetch failed" }; -} - -export async function getAheadCount(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec( - `git -C "${repoRoot}" rev-list --count @{upstream}..HEAD 2>/dev/null || echo "0"`, - ); - return Number.parseInt(result.stdout, 10) || 0; -} - -export async function getBehindCount(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec( - `git -C "${repoRoot}" rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0"`, - ); - return Number.parseInt(result.stdout, 10) || 0; -} - -export async function getCurrentBranch(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" branch --show-current`); - return result.stdout || "unknown"; -} - -export async function getRemoteUrl(): Promise { - const repoRoot = getRepoRoot(); - const result = await exec(`git -C "${repoRoot}" remote get-url origin`); - return result.success ? result.stdout : null; -} - -export async function getDiff(file?: string): Promise { - const repoRoot = getRepoRoot(); - const fileArg = file ? ` -- "${file}"` : ""; - const result = await exec(`git -C "${repoRoot}" diff${fileArg}`); - return result.stdout; -} - -export async function getDiffCached(file?: string): Promise { - const repoRoot = getRepoRoot(); - const fileArg = file ? ` -- "${file}"` : ""; - const result = await exec(`git -C "${repoRoot}" diff --cached${fileArg}`); - return result.stdout; -} diff --git a/src/utils/paths.ts b/src/utils/paths.ts deleted file mode 100644 index e8fea67..0000000 --- a/src/utils/paths.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { tryGetRepoRoot } from "../config/manager"; -import { isMacOS } from "./platform"; - -// Home directory -export const HOME = homedir(); - -// Legacy repo root (for backward compatibility during migration) -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const LEGACY_REPO_ROOT = resolve(dirname(dirname(__dirname))); - -/** - * Get repository root path - * Tries to read from config, falls back to legacy path for backward compatibility - */ -export function getRepoRoot(): string { - const configPath = tryGetRepoRoot(); - if (configPath) { - return configPath; - } - - // Fall back to legacy path - return LEGACY_REPO_ROOT; -} - -/** - * Get configs directory in repo - */ -export function getConfigsDir(): string { - return join(getRepoRoot(), "configs"); -} - -// Deprecated: Use getRepoRoot() instead -// Kept for backward compatibility during migration -export const REPO_ROOT = LEGACY_REPO_ROOT; - -// Deprecated: Use getConfigsDir() instead -export const CONFIGS_DIR = join(LEGACY_REPO_ROOT, "configs"); - -// System paths - where configs live on the system -export const systemPaths = { - zshrc: join(HOME, ".zshrc"), - bashrc: join(HOME, ".bashrc"), - opencode: join(HOME, ".config", "opencode"), - claude: join(HOME, ".claude"), - ghostty: isMacOS - ? join( - HOME, - "Library", - "Application Support", - "com.mitchellh.ghostty", - "config", - ) - : join(HOME, ".config", "ghostty", "config"), - ssh: join(HOME, ".ssh", "config"), - neovim: join(HOME, ".config", "nvim"), - ohmyzsh: join(HOME, ".oh-my-zsh"), - cursor: isMacOS - ? join(HOME, "Library", "Application Support", "Cursor", "User") - : join(HOME, ".config", "Cursor", "User"), - windsurf: join(HOME, ".codeium", "windsurf"), - vscode: isMacOS - ? join(HOME, "Library", "Application Support", "Code", "User") - : join(HOME, ".config", "Code", "User"), -}; - -/** - * Get repo paths - where configs are stored in repo - * Uses dynamic repo root from config - */ -export function getRepoPaths() { - const configsDir = getConfigsDir(); - return { - zshrc: join(configsDir, "dotfiles", ".zshrc"), - bashrc: join(configsDir, "dotfiles", ".bashrc"), - opencode: join(configsDir, "opencode"), - claude: join(configsDir, "claude"), - ghostty: join(configsDir, "ghostty", "config"), - ssh: join(configsDir, "ssh", "config"), - neovim: join(configsDir, "nvim"), - installScript: join(getRepoRoot(), "install.sh"), - cursor: join(configsDir, "cursor"), - windsurf: join(configsDir, "windsurf"), - vscode: join(configsDir, "vscode"), - }; -} - -// Deprecated: Use getRepoPaths() instead -// Kept for backward compatibility during migration -export const repoPaths = { - zshrc: join(CONFIGS_DIR, "dotfiles", ".zshrc"), - bashrc: join(CONFIGS_DIR, "dotfiles", ".bashrc"), - opencode: join(CONFIGS_DIR, "opencode"), - claude: join(CONFIGS_DIR, "claude"), - ghostty: join(CONFIGS_DIR, "ghostty", "config"), - ssh: join(CONFIGS_DIR, "ssh", "config"), - neovim: join(CONFIGS_DIR, "nvim"), -}; - -// Expand ~ to home directory -export function expandHome(path: string): string { - if (path.startsWith("~/")) { - return join(HOME, path.slice(2)); - } - return path; -} - -// Contract home directory to ~ -export function contractHome(path: string): string { - if (path.startsWith(HOME)) { - return `~${path.slice(HOME.length)}`; - } - return path; -} diff --git a/src/utils/platform.ts b/src/utils/platform.ts deleted file mode 100644 index 631ea77..0000000 --- a/src/utils/platform.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; - -export const isMacOS = process.platform === "darwin"; -export const isLinux = process.platform === "linux"; - -export function isArch(): boolean { - if (!isLinux) return false; - try { - const osRelease = readFileSync("/etc/os-release", "utf-8"); - return osRelease.includes("Arch Linux") || osRelease.includes("ID=arch"); - } catch { - return false; - } -} - -export function isDebian(): boolean { - if (!isLinux) return false; - try { - const osRelease = readFileSync("/etc/os-release", "utf-8"); - return ( - osRelease.includes("ID=debian") || - osRelease.includes("ID=ubuntu") || - osRelease.includes("ID=pop") - ); - } catch { - return false; - } -} - -export function getPackageManager(): "brew" | "yay" | "pacman" | "apt" | null { - if (isMacOS) { - return existsSync("/opt/homebrew/bin/brew") || - existsSync("/usr/local/bin/brew") - ? "brew" - : null; - } - if (isLinux) { - if (existsSync("/usr/bin/yay")) return "yay"; - if (existsSync("/usr/bin/pacman")) return "pacman"; - if (existsSync("/usr/bin/apt") || existsSync("/usr/bin/apt-get")) { - return "apt"; - } - } - return null; -} - -export function getPlatformName(): string { - if (isMacOS) return "macOS"; - if (isArch()) return "Arch Linux"; - if (isDebian()) return "Debian/Ubuntu"; - if (isLinux) return "Linux"; - return "Unknown"; -} diff --git a/src/utils/shell.ts b/src/utils/shell.ts deleted file mode 100644 index 3f7e231..0000000 --- a/src/utils/shell.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { execSync } from "node:child_process"; - -export interface ExecResult { - success: boolean; - stdout: string; - stderr: string; - exitCode: number; -} - -export async function exec(command: string): Promise { - try { - const stdout = execSync(command, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); - return { - success: true, - stdout: stdout.trim(), - stderr: "", - exitCode: 0, - }; - } catch (error: any) { - return { - success: false, - stdout: error.stdout?.toString().trim() ?? "", - stderr: error.stderr?.toString().trim() ?? error.message, - exitCode: error.status ?? 1, - }; - } -} - -export async function execLive(command: string): Promise { - try { - execSync(command, { stdio: "inherit" }); - return true; - } catch { - return false; - } -} - -export async function commandExists(command: string): Promise { - const result = await exec(`which ${command}`); - return result.success; -} diff --git a/src/utils/trace.ts b/src/utils/trace.ts deleted file mode 100644 index 5f0f943..0000000 --- a/src/utils/trace.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { getRepoRoot } from "./paths"; - -export interface TraceData { - timestamp: string; - version: string; - platform: string; - command: string | undefined; - error: { - message: string; - stack?: string; - cause?: unknown; - name?: string; - }; - args?: string[]; -} - -export function logError( - thrown: unknown, - command?: string, - args?: string[], -): string { - const error = thrown instanceof Error ? thrown : new Error(String(thrown)); - const repoRoot = getRepoRoot(); - const timestamp = Date.now(); - const logDir = join(repoRoot, ".syncode-logs"); - - // Ensure log directory exists - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); - } - - const logFile = join(logDir, `syncode-trace-${timestamp}.log`); - - const traceData: TraceData = { - timestamp: new Date().toISOString(), - version: getVersion(), - platform: `${process.platform} ${process.arch}`, - command, - error: { - message: error.message, - stack: error.stack, - cause: error.cause, - name: error.name, - }, - args, - }; - - const logContent = formatLog(traceData); - writeFileSync(logFile, logContent, "utf-8"); - - return logFile; -} - -export function getErrorLogFile(logFile: string): string { - return `If this issue persists, open an issue at https://github.com/donnes/syncode/issues and paste the full error trace from file ${logFile}`; -} - -function getVersion(): string { - try { - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const packageJson = JSON.parse( - readFileSync(join(dirname(dirname(__dirname)), "package.json"), "utf-8"), - ); - return packageJson.version; - } catch { - return "unknown"; - } -} - -function formatLog(data: TraceData): string { - const lines: string[] = []; - - lines.push("=== Syncode Error Trace ==="); - lines.push(""); - lines.push(`Timestamp: ${data.timestamp}`); - lines.push(`Version: ${data.version}`); - lines.push(`Platform: ${data.platform}`); - - if (data.command) { - lines.push(`Command: ${data.command}`); - } - - if (data.args && data.args.length > 0) { - lines.push(`Arguments: ${data.args.join(" ")}`); - } - - lines.push(""); - lines.push("=== Error Details ==="); - lines.push(`Type: ${data.error.name || "Unknown"}`); - lines.push(`Message: ${data.error.message}`); - - if (data.error.stack) { - lines.push(""); - lines.push("=== Stack Trace ==="); - lines.push(data.error.stack); - } - - if (data.error.cause) { - lines.push(""); - lines.push("=== Cause ==="); - lines.push( - typeof data.error.cause === "string" - ? data.error.cause - : JSON.stringify(data.error.cause, null, 2), - ); - } - - lines.push(""); - lines.push("=== End of Trace ==="); - - return lines.join("\n"); -} diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts deleted file mode 100644 index ba446a8..0000000 --- a/src/utils/update-check.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { clearTimeout, setTimeout } from "node:timers"; - -export interface UpdateCheckOptions { - currentVersion: string; - packageName: string; -} - -const UPDATE_TIMEOUT_MS = 2000; - -export function isNpxInvocation() { - const argv1 = process.argv[1] || ""; - const execPath = process.env.npm_execpath || ""; - const userAgent = process.env.npm_config_user_agent || ""; - const npxPathMatch = argv1.includes("/_npx/") || argv1.includes("\\_npx\\"); - const execMatch = execPath.includes("npx") || execPath.includes("npx-cli"); - const agentMatch = userAgent.includes("npx"); - return npxPathMatch || execMatch || agentMatch; -} - -export function parseVersion(version: string) { - const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!match) return null; - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} - -export function isOutdated(current: string, latest: string) { - const currentParts = parseVersion(current); - const latestParts = parseVersion(latest); - if (!currentParts || !latestParts) return false; - for (let i = 0; i < 3; i += 1) { - const currentValue = currentParts[i]; - const latestValue = latestParts[i]; - if (currentValue === undefined || latestValue === undefined) return false; - if (latestValue > currentValue) return true; - if (latestValue < currentValue) return false; - } - return false; -} - -export function formatUpdateNotice(params: { - current: string; - latest: string; - packageName: string; -}) { - const updateCommand = `npm install -g ${params.packageName}@latest`; - const lines = [ - "πŸ“¦ A new version of Syncode is", - "available!", - "", - `Current: ${params.current}`, - `Latest: ${params.latest}`, - "", - "Run to update:", - updateCommand, - ]; - - const contentWidth = Math.max(...lines.map((line) => line.length)); - const top = `β”Œ${"─".repeat(contentWidth + 2)}┐`; - const bottom = `β””${"─".repeat(contentWidth + 2)}β”˜`; - const boxed = lines - .map((line) => `β”‚ ${line.padEnd(contentWidth, " ")} β”‚`) - .join("\n"); - return `${top}\n${boxed}\n${bottom}`; -} - -async function fetchLatestVersion(packageName: string) { - const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), UPDATE_TIMEOUT_MS); - - try { - const response = await fetch(url, { signal: controller.signal }); - if (!response.ok) return null; - const data = (await response.json()) as { version?: string }; - return typeof data.version === "string" ? data.version : null; - } catch { - return null; - } finally { - clearTimeout(timeout); - } -} - -export async function checkForUpdates({ - currentVersion, - packageName, -}: UpdateCheckOptions) { - if (isNpxInvocation()) return; - - const latest = await fetchLatestVersion(packageName); - if (!latest) return; - - if (isOutdated(currentVersion, latest)) { - console.log( - formatUpdateNotice({ - current: currentVersion, - latest, - packageName, - }), - ); - } -} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..d4f037f --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,217 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, expect, test } from "vitest"; +import { sandbox } from "./harness.ts"; + +const os = process.platform === "darwin" ? "macos" : "linux"; +const otherOs = os === "macos" ? "linux" : "macos"; + +describe("init", () => { + test("starts a repo from this machine's instructions and links them into each agent", () => { + const box = sandbox("init-new"); + box.write({ ".claude/CLAUDE.md": "# mine\n", ".codex/config.toml": "" }); + + const init = box.run("init", "--agents", "claude,codex"); + expect(init.status, init.output).toBe(0); + + expect(box.read(".syncode/repo/instructions/AGENTS.md")).toBe("# mine\n"); + expect(box.link(".claude/CLAUDE.md")).toBe(".syncode/repo/instructions/AGENTS.md"); + expect(box.link(".codex/AGENTS.md")).toBe(".syncode/repo/instructions/AGENTS.md"); + expect(box.read(".syncode/repo/.gitignore")).toContain("instructions/local.md"); + + // Applying again is a no-op. + const status = JSON.parse(box.run("status", "--json").output); + expect(status.entries.map((entry: { action: string }) => entry.action)).toEqual([ + "skip", + "skip", + ]); + }); + + test("refuses to set up in a folder that already has other files", () => { + const box = sandbox("init-existing"); + box.write({ "configs/README.md": "mine" }); + + const init = box.run("init", path.join(box.home, "configs")); + expect(init.status).toBe(1); + expect(box.read("configs/README.md")).toBe("mine"); + expect(fs.existsSync(path.join(box.home, "configs/.git"))).toBe(false); + }); + + test("migrates a 1.x setup without breaking the agents' folders", () => { + const box = sandbox("init-migrate"); + box.write({ + ".syncode/config.json": JSON.stringify({ + version: "1.0.0", + repoPath: "~/.syncode/repo", + agents: ["agents", "claude", "opencode", "gemini-cli", "windsurf"], + }), + ".syncode/repo/configs/opencode/opencode.json": "{}", + ".syncode/repo/configs/cursor/settings.json": "{}", + ".syncode/repo/configs/claude/CLAUDE.md": "# legacy\n", + ".syncode/repo/.agents/skills/review/SKILL.md": "review", + }); + // 1.x linked whole folders into the repo, plus relative skills links. + fs.symlinkSync("../../.agents/skills", path.join(box.repo, "configs/opencode/skills")); + fs.mkdirSync(path.join(box.home, ".config")); + fs.symlinkSync( + path.join(box.repo, "configs/opencode"), + path.join(box.home, ".config/opencode"), + ); + fs.symlinkSync(path.join(box.repo, ".agents"), path.join(box.home, ".agents")); + // On Linux, 1.x linked editor settings one folder deeper. + fs.mkdirSync(path.join(box.home, ".config/Cursor"), { recursive: true }); + fs.symlinkSync( + path.join(box.repo, "configs/cursor"), + path.join(box.home, ".config/Cursor/User"), + ); + // A shortcut to the repo itself is not a 1.x agent link. + fs.symlinkSync(box.repo, path.join(box.home, "configs-shortcut")); + + const init = box.run("init"); + expect(init.status, init.output).toBe(0); + + expect(box.link(".config/opencode")).toBeUndefined(); + expect(box.link("configs-shortcut")).toBe(".syncode/repo"); + expect(box.link(".config/Cursor/User")).toBeUndefined(); + expect(box.read(".config/Cursor/User/settings.json")).toBe("{}"); + expect(box.read(".config/opencode/opencode.json")).toBe("{}"); + expect(fs.existsSync(path.join(box.home, ".config/opencode/skills"))).toBe(false); + expect(box.link(".config/opencode/AGENTS.md")).toBe(".syncode/repo/instructions/AGENTS.md"); + expect(box.read(".syncode/repo/instructions/AGENTS.md")).toBe("# legacy\n"); + expect(box.link(".agents/skills/review")).toBe(".syncode/repo/skills/review"); + expect(box.link(".claude/skills/review")).toBe(".syncode/repo/skills/review"); + expect(JSON.parse(box.read(".syncode/config.json"))).toMatchObject({ + version: 2, + agents: ["claude", "opencode", "gemini"], + }); + expect(init.output).toContain("No longer managed by syncode: windsurf"); + }); +}); + +describe("apply", () => { + test("keeps a differing local file when it can't ask", () => { + const box = sandbox("apply-conflict"); + box.config(["codex"]); + box.write({ + ".syncode/repo/instructions/AGENTS.md": "repo\n", + ".codex/AGENTS.md": "local\n", + }); + + const apply = box.run("apply"); + expect(apply.status, apply.output).toBe(0); + expect(apply.output).toContain("[keep ] ~/.codex/AGENTS.md differs"); + expect(box.link(".codex/AGENTS.md")).toBeUndefined(); + expect(box.read(".codex/AGENTS.md")).toBe("local\n"); + expect(box.run("status").output).toContain("[differs] ~/.codex/AGENTS.md"); + }); + + test("composes OS and local instructions into one read-only file", () => { + const box = sandbox("apply-compose"); + box.config(["claude"]); + box.write({ + ".syncode/repo/instructions/AGENTS.md": "shared", + [`.syncode/repo/instructions/${os}.md`]: "this os", + [`.syncode/repo/instructions/${otherOs}.md`]: "other os", + ".syncode/repo/instructions/local.md": "this machine", + }); + + expect(box.run("apply").status).toBe(0); + expect(box.link(".claude/CLAUDE.md")).toBe(".syncode/repo/.generated/AGENTS.md"); + const composed = box.read(".claude/CLAUDE.md"); + expect(composed).toContain("shared\n\nthis os\n\nthis machine\n"); + expect(composed).not.toContain("other os"); + expect(() => + fs.accessSync(path.join(box.repo, ".generated/AGENTS.md"), fs.constants.W_OK), + ).toThrow(); + }); + + test("links skills into the shared and Claude folders, and prunes removed ones", () => { + const box = sandbox("apply-skills"); + box.config(["claude", "codex"]); + box.write({ + ".syncode/repo/skills/a/SKILL.md": "a", + ".syncode/repo/skills/b/SKILL.md": "b", + }); + + expect(box.run("apply").status).toBe(0); + for (const root of [".agents/skills", ".claude/skills"]) { + expect(box.link(`${root}/a`)).toBe(".syncode/repo/skills/a"); + expect(box.link(`${root}/b`)).toBe(".syncode/repo/skills/b"); + } + + fs.rmSync(path.join(box.repo, "skills/b"), { recursive: true }); + const apply = box.run("apply"); + expect(apply.output).toContain("[prune ] ~/.agents/skills/b"); + expect(fs.existsSync(path.join(box.home, ".agents/skills/b"))).toBe(false); + expect(box.link(".claude/skills/b")).toBeUndefined(); + expect(box.link(".claude/skills/a")).toBe(".syncode/repo/skills/a"); + }); + + test("treats a skills folder linked to another as the same folder", () => { + // 1.x left ~/.claude/skills linked to ~/.agents/skills. + const box = sandbox("apply-aliased-skills"); + box.config(["claude", "codex"]); + box.write({ ".syncode/repo/skills/a/SKILL.md": "a" }); + fs.mkdirSync(path.join(box.home, ".agents/skills"), { recursive: true }); + fs.mkdirSync(path.join(box.home, ".claude")); + fs.symlinkSync(path.join(box.home, ".agents/skills"), path.join(box.home, ".claude/skills")); + + // One real link to make, and status agrees with apply about it. + expect(box.status().map((entry) => entry.action)).toEqual(["link"]); + expect(box.run("apply").status).toBe(0); + expect(box.status().map((entry) => entry.action)).toEqual(["skip"]); + + // Disabling Codex must not prune the link Claude now reaches it through. + box.config(["claude"]); + expect(box.run("apply").status).toBe(0); + expect(box.link(".agents/skills/a")).toBe(".syncode/repo/skills/a"); + }); +}); + +describe("adopt", () => { + test("moves a config file into the repo and links it back", () => { + const box = sandbox("adopt-file"); + box.config(["claude"]); + box.write({ ".claude/settings.json": '{"theme":"dark"}' }); + + const adopt = box.run("adopt", path.join(box.home, ".claude/settings.json")); + expect(adopt.status, adopt.output).toBe(0); + expect(box.read(".syncode/repo/agents/claude/settings.json")).toBe('{"theme":"dark"}'); + expect(box.link(".claude/settings.json")).toBe(".syncode/repo/agents/claude/settings.json"); + }); + + test("refuses credentials", () => { + const box = sandbox("adopt-secret"); + box.config(["codex"]); + box.write({ ".codex/auth.json": "secret" }); + + const adopt = box.run("adopt", path.join(box.home, ".codex/auth.json")); + expect(adopt.status).toBe(1); + expect(adopt.output).toContain("Refusing to adopt auth.json"); + expect(box.read(".codex/auth.json")).toBe("secret"); + expect(fs.existsSync(path.join(box.repo, "agents"))).toBe(false); + + // Also when they're inside an adopted folder. + box.write({ ".codex/rules/.env": "TOKEN=1" }); + const folder = box.run("adopt", path.join(box.home, ".codex/rules")); + expect(folder.status).toBe(1); + expect(folder.output).toContain("Refusing to adopt .env"); + expect(box.read(".codex/rules/.env")).toBe("TOKEN=1"); + }); +}); + +test("unlink swaps links for copies so agents keep their configs", () => { + const box = sandbox("unlink"); + box.config(["claude"]); + box.write({ + ".syncode/repo/instructions/AGENTS.md": "shared", + ".syncode/repo/skills/a/SKILL.md": "a", + }); + expect(box.run("apply").status).toBe(0); + + expect(box.run("unlink").status).toBe(0); + expect(box.link(".claude/CLAUDE.md")).toBeUndefined(); + expect(box.read(".claude/CLAUDE.md")).toBe("shared"); + expect(box.link(".claude/skills/a")).toBeUndefined(); + expect(box.read(".claude/skills/a/SKILL.md")).toBe("a"); +}); diff --git a/tests/fs.test.ts b/tests/fs.test.ts deleted file mode 100644 index 7a4452b..0000000 --- a/tests/fs.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env tsx - -/** - * Unit tests for fs.ts - * - * These tests validate the filesystem helpers used by sync operations. - * - * Run with: npx tsx tests/fs.test.ts - */ - -import assert from "node:assert/strict"; -import { - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { createServer } from "node:net"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - backup, - copyDir, - copyFile, - createSymlink, - ensureDir, - ensureParentDir, - exists, - getFileDiff, - getSymlinkTarget, - isDirectory, - isSymlink, - readFile, - removeDir, -} from "../src/utils/fs"; - -type TestCase = { - name: string; - run: () => void | Promise; -}; - -const tests: TestCase[] = []; - -function test(name: string, run: TestCase["run"]) { - tests.push({ name, run }); -} - -function makeTempDir(label: string): string { - return mkdtempSync(join(tmpdir(), `syncode-${label}-`)); -} - -function cleanupTempDir(dir: string): void { - rmSync(dir, { recursive: true, force: true }); -} - -test("exists/isDirectory basics", () => { - const dir = makeTempDir("fs-basic"); - try { - const missing = join(dir, "missing"); - assert.equal(exists(missing), false); - assert.equal(isDirectory(missing), false); - - const subdir = join(dir, "subdir"); - ensureDir(subdir); - assert.equal(exists(subdir), true); - assert.equal(isDirectory(subdir), true); - - const file = join(dir, "file.txt"); - writeFileSync(file, "data"); - assert.equal(exists(file), true); - assert.equal(isDirectory(file), false); - assert.equal(isSymlink(file), false); - } finally { - cleanupTempDir(dir); - } -}); - -test("ensureParentDir creates parent path", () => { - const dir = makeTempDir("fs-parent"); - try { - const filePath = join(dir, "a", "b", "c.txt"); - ensureParentDir(filePath); - assert.equal(isDirectory(join(dir, "a", "b")), true); - } finally { - cleanupTempDir(dir); - } -}); - -test("readFile/getFileDiff", () => { - const dir = makeTempDir("fs-read"); - try { - const fileA = join(dir, "a.txt"); - const fileB = join(dir, "b.txt"); - writeFileSync(fileA, "same"); - writeFileSync(fileB, "same"); - - assert.equal(readFile(fileA), "same"); - assert.equal(readFile(join(dir, "missing.txt")), null); - assert.equal(getFileDiff(fileA, fileB), false); - - writeFileSync(fileB, "diff"); - assert.equal(getFileDiff(fileA, fileB), true); - } finally { - cleanupTempDir(dir); - } -}); - -test("copyFile copies content", () => { - const dir = makeTempDir("fs-copyfile"); - try { - const src = join(dir, "src.txt"); - const dest = join(dir, "out", "dest.txt"); - writeFileSync(src, "hello"); - - copyFile(src, dest); - assert.equal(readFileSync(dest, "utf-8"), "hello"); - } finally { - cleanupTempDir(dir); - } -}); - -test("copyDir copies nested entries", () => { - const dir = makeTempDir("fs-copydir"); - try { - const src = join(dir, "src"); - const dest = join(dir, "dest"); - ensureDir(join(src, "nested")); - writeFileSync(join(src, "root.txt"), "root"); - writeFileSync(join(src, "nested", "child.txt"), "child"); - - copyDir(src, dest); - assert.equal(readFileSync(join(dest, "root.txt"), "utf-8"), "root"); - assert.equal( - readFileSync(join(dest, "nested", "child.txt"), "utf-8"), - "child", - ); - } finally { - cleanupTempDir(dir); - } -}); - -test("copyDir skips unix sockets", async () => { - if (process.platform === "win32") { - console.log("β†· skipped socket test on win32"); - return; - } - - const dir = makeTempDir("fs-socket"); - const server = createServer(); - const socketPath = join(dir, "src", "agent.sock"); - - try { - const src = join(dir, "src"); - const dest = join(dir, "dest"); - ensureDir(src); - writeFileSync(join(src, "file.txt"), "ok"); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, () => resolve()); - }); - - copyDir(src, dest); - assert.equal(readFileSync(join(dest, "file.txt"), "utf-8"), "ok"); - assert.equal(existsSync(join(dest, "agent.sock")), false); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - if (existsSync(socketPath)) { - rmSync(socketPath, { force: true }); - } - cleanupTempDir(dir); - } -}); - -test("backup moves file to .backup", () => { - const dir = makeTempDir("fs-backup"); - try { - const file = join(dir, "config.json"); - writeFileSync(file, "data"); - - const backupPath = backup(file); - assert.equal(backupPath, `${file}.backup`); - assert.equal(exists(file), false); - assert.equal(readFileSync(`${file}.backup`, "utf-8"), "data"); - } finally { - cleanupTempDir(dir); - } -}); - -test("createSymlink and getSymlinkTarget", () => { - if (process.platform === "win32") { - console.log("β†· skipped symlink test on win32"); - return; - } - - const dir = makeTempDir("fs-symlink"); - try { - const target = join(dir, "target.txt"); - const link = join(dir, "link.txt"); - writeFileSync(target, "content"); - - createSymlink(target, link); - assert.equal(isSymlink(link), true); - assert.equal(getSymlinkTarget(link), target); - } finally { - cleanupTempDir(dir); - } -}); - -test("removeDir deletes nested directory", () => { - const dir = makeTempDir("fs-removedir"); - try { - const nested = join(dir, "nested", "deep"); - ensureDir(nested); - writeFileSync(join(nested, "file.txt"), "data"); - - removeDir(join(dir, "nested")); - assert.equal(exists(join(dir, "nested")), false); - } finally { - cleanupTempDir(dir); - } -}); - -test("copyDir skips .git directories", () => { - const dir = makeTempDir("fs-skipgit"); - try { - const src = join(dir, "src"); - const dest = join(dir, "dest"); - ensureDir(join(src, ".git", "objects")); - ensureDir(join(src, "skill")); - writeFileSync(join(src, ".git", "config"), "git config"); - writeFileSync(join(src, ".git", "objects", "pack.idx"), "pack data"); - writeFileSync(join(src, "skill", "test.md"), "skill content"); - writeFileSync(join(src, "config.json"), "app config"); - - copyDir(src, dest); - - // .git should NOT be copied - assert.equal(exists(join(dest, ".git")), false); - // Regular files should be copied - assert.equal( - readFileSync(join(dest, "config.json"), "utf-8"), - "app config", - ); - assert.equal( - readFileSync(join(dest, "skill", "test.md"), "utf-8"), - "skill content", - ); - } finally { - cleanupTempDir(dir); - } -}); - -test("copyDir skips node_modules", () => { - const dir = makeTempDir("fs-skipnodemod"); - try { - const src = join(dir, "src"); - const dest = join(dir, "dest"); - ensureDir(join(src, "node_modules", "some-package")); - writeFileSync( - join(src, "node_modules", "some-package", "index.js"), - "module", - ); - writeFileSync(join(src, "index.ts"), "source"); - - copyDir(src, dest); - - // node_modules should NOT be copied - assert.equal(exists(join(dest, "node_modules")), false); - // Regular files should be copied - assert.equal(readFileSync(join(dest, "index.ts"), "utf-8"), "source"); - } finally { - cleanupTempDir(dir); - } -}); - -async function run() { - for (const { name, run } of tests) { - try { - await run(); - console.log(`βœ“ ${name}`); - } catch (error) { - console.error(`βœ— ${name}`); - console.error(error); - process.exitCode = 1; - } - } - - if (process.exitCode) { - process.exit(1); - } -} - -await run(); diff --git a/tests/harness.ts b/tests/harness.ts new file mode 100644 index 0000000..5b207a9 --- /dev/null +++ b/tests/harness.ts @@ -0,0 +1,70 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cli = path.join(root, "src", "index.ts"); +const scratch = path.join(root, ".scratch", "tests"); + +export interface Sandbox { + home: string; + repo: string; + /** Writes files relative to the sandbox home. */ + write: (files: Record) => void; + read: (file: string) => string; + /** Link target relative to home, or undefined when not a link. */ + link: (file: string) => string | undefined; + config: (agents: string[]) => void; + run: (...args: string[]) => { status: number | null; output: string }; + /** Entries from `syncode status --json`. */ + status: () => { action: string; path: string }[]; +} + +export function sandbox(name: string): Sandbox { + const home = path.join(scratch, name.replace(/\W+/g, "-")); + fs.rmSync(home, { recursive: true, force: true }); + fs.mkdirSync(home, { recursive: true }); + const repo = path.join(home, ".syncode", "repo"); + const at = (file: string) => path.join(home, file); + + return { + home, + repo, + write(files) { + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(at(file)), { recursive: true }); + fs.writeFileSync(at(file), content); + } + }, + read: (file) => fs.readFileSync(at(file), "utf8"), + link(file) { + const stat = fs.lstatSync(at(file), { throwIfNoEntry: false }); + return stat?.isSymbolicLink() ? path.relative(home, fs.readlinkSync(at(file))) : undefined; + }, + config(agents) { + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync(at(".syncode/config.json"), JSON.stringify({ version: 2, repo, agents })); + }, + run(...args) { + // Not a TTY, so the CLI never prompts. Only allowlisted variables pass + // through, so the real agent homes can't leak in. + const result = spawnSync(process.execPath, [cli, ...args], { + cwd: home, + encoding: "utf8", + env: { + PATH: process.env.PATH, + HOME: home, + GIT_CONFIG_GLOBAL: path.join(home, ".gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + }, + }); + return { status: result.status, output: result.stdout + result.stderr }; + }, + status() { + const result = this.run("status", "--json"); + if (result.status !== 0) throw new Error(result.output); + return JSON.parse(result.output).entries; + }, + }; +} diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts deleted file mode 100644 index eb5d5f4..0000000 --- a/tests/update-check.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env tsx - -import assert from "node:assert/strict"; -import * as updateCheck from "../src/utils/update-check"; - -type TestCase = { - name: string; - run: () => void | Promise; -}; - -const tests: TestCase[] = []; -const originalArgv1 = process.argv[1]; -const originalEnvExecPath = process.env.npm_execpath; -const originalEnvUserAgent = process.env.npm_config_user_agent; - -function test(name: string, run: TestCase["run"]) { - tests.push({ name, run }); -} - -function cleanup() { - process.argv[1] = originalArgv1; - process.env.npm_execpath = originalEnvExecPath ?? ""; - process.env.npm_config_user_agent = originalEnvUserAgent ?? ""; -} - -test("parseVersion handles valid versions", () => { - assert.deepStrictEqual(updateCheck.parseVersion("1.2.3"), [1, 2, 3]); - assert.deepStrictEqual(updateCheck.parseVersion("0.0.0"), [0, 0, 0]); - assert.deepStrictEqual(updateCheck.parseVersion("10.20.30"), [10, 20, 30]); -}); - -test("parseVersion handles invalid versions", () => { - assert.strictEqual(updateCheck.parseVersion("1.2"), null); - assert.strictEqual(updateCheck.parseVersion("1.2.3.4"), null); - assert.strictEqual(updateCheck.parseVersion("v1.2.3"), null); - assert.strictEqual(updateCheck.parseVersion(""), null); - assert.strictEqual(updateCheck.parseVersion("invalid"), null); -}); - -test("isOutdated detects newer versions", () => { - assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.0.1"), true); - assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.1.0"), true); - assert.strictEqual(updateCheck.isOutdated("1.0.0", "2.0.0"), true); - assert.strictEqual(updateCheck.isOutdated("1.2.3", "1.2.4"), true); -}); - -test("isOutdated returns false when up to date", () => { - assert.strictEqual(updateCheck.isOutdated("1.0.0", "1.0.0"), false); - assert.strictEqual(updateCheck.isOutdated("1.1.0", "1.0.0"), false); - assert.strictEqual(updateCheck.isOutdated("1.0.1", "1.0.0"), false); - assert.strictEqual(updateCheck.isOutdated("1.2.4", "1.2.3"), false); - assert.strictEqual(updateCheck.isOutdated("2.0.0", "1.0.0"), false); -}); - -test("isOutdated handles invalid versions gracefully", () => { - assert.strictEqual(updateCheck.isOutdated("invalid", "1.0.0"), false); - assert.strictEqual(updateCheck.isOutdated("1.0.0", "invalid"), false); - assert.strictEqual(updateCheck.isOutdated("1.2", "1.2.4"), false); -}); - -test("formatUpdateNotice creates boxed output", () => { - const output = updateCheck.formatUpdateNotice({ - current: "1.0.0", - latest: "1.1.0", - packageName: "@donnes/syncode", - }); - - assert.ok(output.includes("β”Œ")); - assert.ok(output.includes("┐")); - assert.ok(output.includes("β””")); - assert.ok(output.includes("β”˜")); - assert.ok(output.includes("β”‚")); - assert.ok(output.includes("─")); - assert.ok(output.includes("Current: 1.0.0")); - assert.ok(output.includes("Latest: 1.1.0")); - assert.ok(output.includes("npm install -g @donnes/syncode@latest")); -}); - -test("formatUpdateNotice handles different package names", () => { - const output = updateCheck.formatUpdateNotice({ - current: "1.0.0", - latest: "2.0.0", - packageName: "test-package", - }); - - assert.ok(output.includes("npm install -g test-package@latest")); -}); - -test("isNpxInvocation detects npx execution", () => { - const originalArgv1 = process.argv[1]; - try { - process.argv[1] = "/tmp/_npx/package/lib/index.js"; - assert.strictEqual(updateCheck.isNpxInvocation(), true); - - process.argv[1] = "C:\\Users\\_npx\\package\\lib\\index.js"; - assert.strictEqual(updateCheck.isNpxInvocation(), true); - } finally { - process.argv[1] = originalArgv1; - } -}); - -test("isNpxInvocation detects npx via npm_execpath", () => { - const originalEnvNodeExecpath = process.env.npm_execpath; - try { - process.env.npm_execpath = "/usr/local/lib/node_modules/npm/bin/npx-cli.js"; - assert.strictEqual(updateCheck.isNpxInvocation(), true); - - process.env.npm_execpath = - "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js"; - assert.strictEqual(updateCheck.isNpxInvocation(), true); - } finally { - process.env.npm_execpath = originalEnvNodeExecpath ?? ""; - } -}); - -test("isNpxInvocation detects npx via user agent", () => { - const originalEnvUserAgent = process.env.npm_config_user_agent; - try { - process.env.npm_config_user_agent = "npm/9.0.0 node/v18.0.0 npx/9.0.0"; - assert.strictEqual(updateCheck.isNpxInvocation(), true); - } finally { - process.env.npm_config_user_agent = originalEnvUserAgent ?? ""; - } -}); - -test("isNpxInvocation returns false for global install", () => { - const originalArgv1 = process.argv[1]; - const originalEnvNodeExecpath = process.env.npm_execpath; - const originalEnvUserAgent = process.env.npm_config_user_agent; - - try { - process.argv[1] = "/usr/local/bin/syncode"; - process.env.npm_execpath = ""; - process.env.npm_config_user_agent = ""; - - assert.strictEqual(updateCheck.isNpxInvocation(), false); - } finally { - process.argv[1] = originalArgv1; - process.env.npm_execpath = originalEnvNodeExecpath ?? ""; - process.env.npm_config_user_agent = originalEnvUserAgent ?? ""; - } -}); - -test("isOutdated comparison edge cases", () => { - assert.strictEqual(updateCheck.isOutdated("9.9.9", "10.0.0"), true); - assert.strictEqual(updateCheck.isOutdated("10.0.0", "9.9.9"), false); - assert.strictEqual(updateCheck.isOutdated("0.0.0", "0.0.1"), true); - assert.strictEqual(updateCheck.isOutdated("1.9.9", "2.0.0"), true); - assert.strictEqual(updateCheck.isOutdated("2.0.0", "2.0.1"), true); - assert.strictEqual(updateCheck.isOutdated("2.0.0", "2.1.0"), true); - assert.strictEqual(updateCheck.isOutdated("2.1.0", "3.0.0"), true); -}); - -async function run() { - for (const { name, run } of tests) { - try { - await run(); - console.log(`βœ“ ${name}`); - } catch (error) { - console.error(`βœ— ${name}`); - console.error(error); - process.exitCode = 1; - } finally { - cleanup(); - } - } - - if (process.exitCode) { - process.exit(1); - } -} - -await run(); diff --git a/tsconfig.json b/tsconfig.json index 6432eb3..a07086c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,21 @@ { "compilerOptions": { - "lib": ["ESNext"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "allowJs": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "noEmit": true, + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, - "esModuleInterop": true + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["src", "tests", "*.config.ts"] } diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..a2dadec --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: "esm", + platform: "node", + target: "node22", + dts: false, +}); diff --git a/tsup.config.ts b/tsup.config.ts deleted file mode 100644 index fa37a26..0000000 --- a/tsup.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], - dts: true, - clean: true, - shims: true, -}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a353c61 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + // Each test spawns the real CLI against a sandbox home folder. + testTimeout: 20_000, + }, +});